Compare commits

...

95 Commits

Author SHA1 Message Date
[Kesavan-001]
b53e9d6386 fix: Cost center mapping issue 2026-03-17 18:02:23 +05:30
[Kesavan-001]
a084feba96 Fix:Cost center mapping issue 2026-03-14 12:50:47 +05:30
[Kesavan-001]
10fe8580d5 Fix:Cost center mapping issue 2026-03-14 12:39:48 +05:30
Mihir Kandoi
fc8647d1da Merge pull request #52209 from mihir-kandoi/fix-precision-pr 2026-01-30 12:05:16 +05:30
Mihir Kandoi
f82f1da706 Merge pull request #52213 from mihir-kandoi/st58513 2026-01-30 11:58:53 +05:30
Mihir Kandoi
6e17ccf499 fix: hide close button on WO if WO is completed 2026-01-30 11:56:38 +05:30
Mihir Kandoi
40bfd08866 Merge pull request #52210 from mihir-kandoi/gh52189 2026-01-30 11:46:49 +05:30
Mihir Kandoi
89f6f0f46f fix(barcode): failing request when item has both batch and serial 2026-01-30 11:37:21 +05:30
Mihir Kandoi
838d245215 fix: add precision to rejected batch no qty calculation 2026-01-30 10:19:35 +05:30
Dany Robert
b565dd3da8 fix: missing depr_series causing error on jv creation (#52085) 2026-01-29 23:10:04 +05:30
Mihir Kandoi
e1b6ec340c Merge pull request #52201 from mihir-kandoi/gh52199 2026-01-29 21:42:29 +05:30
Mihir Kandoi
c38f884095 fix: hide item_wise_tax_details table from print 2026-01-29 21:25:39 +05:30
rohitwaghchaure
464560a949 Merge pull request #52190 from rohitwaghchaure/fixed-lead-time-calculation-for-fg
fix: lead time calculation for FG item
2026-01-29 17:58:44 +05:30
rohitwaghchaure
f7f2e73f79 Add Landed Cost Voucher Amount in Internal Purchase Receipt (#52158)
* fix(stock): set incoming_rate with lcv rate for internal purchase

* test: add unit test to check internal purchase with lcv
2026-01-29 17:32:13 +05:30
Rohit Waghchaure
646688c291 fix: lead time calculation for FG item 2026-01-29 17:30:22 +05:30
kavin-114
dd4fd89ef8 test: add unit test to check internal purchase with lcv 2026-01-29 16:48:39 +05:30
kavin-114
f0dccc3cd7 fix(stock): set incoming_rate with lcv rate for internal purchase 2026-01-29 16:48:32 +05:30
Mihir Kandoi
91b1df49a4 Merge pull request #52181 from mihir-kandoi/st58663 2026-01-29 15:30:29 +05:30
Mihir Kandoi
7f6f39f5e7 fix: js error if user does not have write permission for date field 2026-01-29 15:28:55 +05:30
Jacob Salvi
cdcf3fa593 refactor: new accounting icons (#52173)
* chore: new icons share-management

* chore: new accounting icons

* chore: trigger CI

---------

Co-authored-by: Soham Kulkarni <77533095+sokumon@users.noreply.github.com>
Co-authored-by: ruthra kumar <ruthra@erpnext.com>
2026-01-29 14:42:47 +05:30
Diptanil Saha
e11ba21b42 fix(demo): removed toolbar eventlistener (#52171) 2026-01-29 05:57:12 +00:00
Mihir Kandoi
1a4ecba742 Merge pull request #52166 from mihir-kandoi/gh52113 2026-01-29 10:49:53 +05:30
Mihir Kandoi
4e19c7e8bd fix: production plan not considering planning datetime when creating WO 2026-01-29 10:16:06 +05:30
Aarol D'Souza
578b06e027 Merge pull request #52092 from AarDG10/fix-email-render-rfq
fix(RFQ): render email templates for preview and sending
2026-01-29 09:06:51 +05:30
Soham Kulkarni
3d65db2ac3 feat: clear demo data from desktop screen (#52128) 2026-01-28 17:13:22 +05:30
rohitwaghchaure
fabc26bb69 Merge pull request #52007 from aerele/fix/set-zero-rate-for-expired-batch
Fix: Set Zero Rate for Standalone Credit Note with Expired Batch
2026-01-28 16:12:37 +05:30
mahsem
27226b1d82 chore: delete swedish 2024 chart of accounts template (#52032) 2026-01-28 15:09:48 +05:30
Mihir Kandoi
0dc804f9b4 Merge pull request #51961 from aerele/sales-order-project-dimensions 2026-01-27 21:53:39 +05:30
Mihir Kandoi
3192f3f011 Merge pull request #52084 from harrishragavan/fix/shipment-field-validation 2026-01-27 21:26:42 +05:30
harrishragavan
3c6eb9a531 fix(shipment): user contact validation to use full name 2026-01-27 20:52:26 +05:30
Soham Kulkarni
8dae178728 Merge pull request #52119 from sokumon/blue-icons 2026-01-27 20:43:51 +05:30
sokumon
6f9cd8c261 chore: change color of icons in accounting folders 2026-01-27 20:15:00 +05:30
ruthra kumar
d6189b8101 Merge pull request #51894 from ruthra-kumar/refactor_accounting_workspace
refactor: accounting workspace
2026-01-27 18:45:10 +05:30
Nikhil Kothari
48f4a44fb5 feat(accounts): retain filters when switching between financial statements (#51668) 2026-01-27 18:38:37 +05:30
ruthra kumar
f0332c4dc7 refactor: reuse icon for invoicing 2026-01-27 18:30:03 +05:30
NaviN
ec41f1b0f5 fix(asset capitalization): update total_asset_cost on asset capitalisation submission (#52077)
fix(asset capitalization): update total_asset_cost on asset capitalization submission
2026-01-27 18:04:04 +05:30
madelyngamble2
7e9647f3f0 fix: unable to split asset from capitalization (#52020)
* fix: Allow split asset from capitalized composite asset (fixes #52016)

* test: Add test case for splitting asset created via capitalization (fixes #52016)

* docs: Add docstring to before_submit method

* fix: Remove unused variable and fix UTF-8 encoding in asset files

* fix: Remove UTF-8 BOM from asset.py to fix linting

* fix: Fix test_split_asset_created_via_capitalization test parameters

* fix: Remove unused import create_item

* chore: remove unnecessary comments

Removed validation comments for composite asset capitalization in before_submit method.

---------

Co-authored-by: Khushi Rawat <142375893+khushi8112@users.noreply.github.com>
2026-01-27 17:46:58 +05:30
ruthra kumar
fb9656b975 refactor: rename Accounts to Accounting 2026-01-27 17:23:40 +05:30
Mihir Kandoi
1b6fe8498d Merge pull request #52106 from mihir-kandoi/gh34977 2026-01-27 16:27:14 +05:30
Mihir Kandoi
5eeebbde7f test: fix tests 2026-01-27 16:10:44 +05:30
ruthra kumar
f7abf9c1da refactor: link payments dashboard to sidebar 2026-01-27 15:52:57 +05:30
ruthra kumar
99406ccc15 refactor: payments dashboard 2026-01-27 15:52:57 +05:30
ruthra kumar
1295d7aa30 refactor: shed duplicates from invoicing sidebar 2026-01-27 15:52:57 +05:30
ruthra kumar
5a680d5037 refactor: reorder accounts setup sidebar 2026-01-27 15:52:57 +05:30
ruthra kumar
7528d42187 refactor: introduce setup icon and reorder 2026-01-27 15:52:57 +05:30
ruthra kumar
cbdc945287 refactor: payments sidebar and icon 2026-01-27 15:52:57 +05:30
ruthra kumar
faf0dcb102 chore: rename accounting to invoicing 2026-01-27 15:52:57 +05:30
ruthra kumar
5e02b4009e chore: remove accounting icon 2026-01-27 15:52:57 +05:30
ruthra kumar
8125f9035c refactor: invoicing icon 2026-01-27 15:52:57 +05:30
Vishnu Priya Baskaran
efa3973b77 fix: check the payment ledger entry has the dimension (#51823)
* fix: check the payment ledger entry has the dimension

* fix: add project in payment ledger entry
2026-01-27 15:45:33 +05:30
Mihir Kandoi
71371b0ba5 fix: show everything else besides other party specific item 2026-01-27 15:43:58 +05:30
SowmyaArunachalam
543b6e51c0 fix: handle parent level project change 2026-01-27 15:37:14 +05:30
kavin-114
3460a7efb5 test(credit-note): add unit test for zero valuation rate on expired batch 2026-01-27 15:13:46 +05:30
kavin-114
e78c750b4e fix(credit-note): set incoming rate as zero for expired batch 2026-01-27 15:13:34 +05:30
Dharanidharan S
d82c92a237 fix(accounts): correct base grand total and rounded total mismatch (#51739) 2026-01-27 14:08:16 +05:30
Mihir Kandoi
826cf66af8 Merge pull request #52088 from mihir-kandoi/gh51577 2026-01-27 12:19:58 +05:30
Mihir Kandoi
b49c679a50 fix: show message if image is removed from item description 2026-01-27 12:04:49 +05:30
NaviN
5f05714e9d fix(payment entry): update currency symbol (#51956) 2026-01-27 12:01:11 +05:30
AarDG10
37cdae2f34 ci: minor text correction 2026-01-27 11:59:18 +05:30
SowmyaArunachalam
3b27f49d79 chore: use frappe.model.set_value 2026-01-27 11:36:51 +05:30
AarDG10
525b3960e1 fix(RFQ): render email templates for preview and sending 2026-01-27 11:35:50 +05:30
kavin-114
04cdf88715 feat(credit-note): add checkbox to set valuation rate as zero for expired batch 2026-01-27 11:04:36 +05:30
V Shankar
f8f626975f fix(journal-entry): prevent submit failure due to double background queuing (#52083) 2026-01-27 11:00:38 +05:30
rohitwaghchaure
31c536e33f Merge pull request #52062 from rohitwaghchaure/fixed-github-52028
fix: not able to complete the job card
2026-01-26 23:14:20 +05:30
Mihir Kandoi
c1fef8269a Merge pull request #52064 from Shankarv19bcr/customer-auto-name-fix 2026-01-26 15:16:21 +05:30
Shankarv19bcr
e5ba0e6401 fix: strip whitespace in customer_name 2026-01-26 14:37:55 +05:30
Rohit Waghchaure
696ea68f86 fix: not able to complete the job card 2026-01-26 11:35:48 +05:30
MochaMind
71d00f5290 chore: update POT file (#52057) 2026-01-25 20:37:19 +01:00
Henning Wendtland
0fb37ad792 feat(Transaction Deletion Record): Editable "DocTypes To Delete" List with CSV import/export (#50592)
* feat: add editable DocTypes To Delete list with import/export

Add user control over transaction deletion with reviewable and reusable deletion templates.

- New "DocTypes To Delete" table allows users to review and customize what will be deleted before submission
- Import/Export CSV templates for reusability across environments
- Company field rule: only filter by company if field is specifically named "company", otherwise delete all records
- Child tables (istable=1) automatically excluded from selection
- "Remove Zero Counts" helper button to clean up list
- Backward compatible with existing deletion records

* refactor: improve Transaction Deletion Record code quality

- Remove unnecessary chatty comments from AI-generated code
- Add concise docstrings to all new methods
- Remove redundant @frappe.whitelist() decorators from internal methods
- Improve CSV import validation (header check, child table filtering)
- Add better error feedback with consolidated skip messages
- Reorder form fields: To Delete list now appears before Excluded list
- Add conditional visibility for Summary table (legacy records only)
- Improve architectural clarity: single API entry point per feature

Technical improvements:
- export_to_delete_template_method and import_to_delete_template_method
  are now internal helpers without whitelist decorators
- CSV import now validates format and provides detailed skip reasons
- Summary table only shows for submitted records without To Delete list
- Maintains backward compatibility for existing deletion records

* fix: field order

* test: fix broken tests and add new ones

* fix: adapt create_transaction_deletion_request

* test: fix assertRaises trigger

* fix: conditionally execute Transaction Deletion pre-tasks based on selected DocTypes

* refactor: replace boolean task flags with status fields

* fix: remove UI comment

* fix: don't allow virtual doctype selection and improve protected Doctype List

* fix: replace outdated frappe.db.sql by frappe.qb

* feat: add support for multiple company fields

* fix: autofill comapny field, add docstrings, filter for company_field

* fix: add edge case handling for update_naming_series and add tests for prefix extraction

* fix: use redis for running deletion validation, check per doctype instead of company
2026-01-25 14:20:28 +05:30
rohitwaghchaure
88069779b2 Merge pull request #52050 from mahsem/swedish_address_template
fix: swedish_address_template
2026-01-25 10:51:39 +05:30
rohitwaghchaure
c5a4164a6b Merge pull request #52043 from rohitwaghchaure/fixed-uom-not-fetching-in-bom
fix: UOM of item not fetching in BOM
2026-01-25 10:44:28 +05:30
mahsem
334e8ada30 fix: swedish_address_template 2026-01-24 23:41:30 +01:00
Rohit Waghchaure
ba8eadda52 fix: UOM of item not fetching in BOM 2026-01-24 14:15:56 +05:30
Smit Vora
297a2ea259 Merge pull request #51691 from Abdeali099/do-not-warn-filter-missing 2026-01-24 12:37:45 +05:30
Smit Vora
e129e1438e Merge pull request #51670 from Abdeali099/fix-bank-quik-entry-erroe 2026-01-24 12:36:46 +05:30
HALFWARE
e810cd8440 fix: update country_wise_tax.json for Algerian Taxes (#51878)
* Algeria chart of accounts

Algeria chart of accounts

* Update Algeria Chart Of Account

* Algeria chart of account

* Algeria Chart of Account

Algeria Chart of Account

* Modify Algeria tax entries in country_wise_tax.json

Updated tax rates and account names for Algeria.

* Rename account for Algeria tax from VAT to TVA

Rename account for Algeria tax from VAT to TVA
2026-01-24 12:02:39 +05:30
El-Shafei H.
50b3396064 fix: Ensure paid_amount is always numeric before calling allocate_amount_to_references (#50935)
fix: ensure paid_amount is not null in allocate_party_amount_against_ref_docs
2026-01-24 11:47:38 +05:30
Abdeali Chharchhoda
9322095786 refactor: use console.error for error logging in Plaid integration 2026-01-24 11:34:34 +05:30
Abdeali Chharchhoda
8a1b8259bd fix: handle undefined bank_transaction_mapping in quick entry 2026-01-24 11:33:42 +05:30
Abdeali Chharchhoda
d905f78984 refactor: not warn when filter field is missing in FS reports 2026-01-24 11:21:17 +05:30
rohitwaghchaure
7250ee4429 Merge pull request #52024 from rohitwaghchaure/fixed-support-56966
fix: Bin reserved qty for production for extra material transfer
2026-01-23 21:13:20 +05:30
Rohit Waghchaure
f5378b6573 fix: Bin reserved qty for production for extra material transfer 2026-01-23 19:35:47 +05:30
Diptanil Saha
c4e35f1284 Merge pull request #51976 from diptanilsaha/gh_51348 2026-01-23 16:30:27 +05:30
Mihir Kandoi
6a9c0e22de Merge pull request #51999 from aerele/support/fix-58134 2026-01-23 14:03:26 +05:30
rohitwaghchaure
809b29fe90 Merge pull request #52006 from rohitwaghchaure/fixed-negative-stock-for-purchase-return
fix: negative stock for purchase return
2026-01-23 11:33:25 +05:30
Rohit Waghchaure
d68a04ad16 fix: negative stock for purchae return 2026-01-23 01:19:52 +05:30
rohitwaghchaure
6954811c55 Merge pull request #52003 from rohitwaghchaure/fixed-hide-stock-entry-button
fix: do not show stock entry button if timer is running
2026-01-22 23:56:54 +05:30
rohitwaghchaure
8cdc21c264 Merge pull request #51989 from aerele/fix/autofill-warehouse
fix: autofill warehouse for packed items
2026-01-22 22:57:16 +05:30
Rohit Waghchaure
466668c6b8 fix: do not show stock entry button if timer is running 2026-01-22 22:56:34 +05:30
MochaMind
e7e22c809e fix: sync translations from crowdin (#51913)
* fix: Arabic translations

* fix: Arabic translations

* fix: Arabic translations

* fix: Arabic translations
2026-01-22 13:52:18 +00:00
Bharathidhasan06
2606ca6fa9 fix(stock): use purchase UOM in Supplier Quotation items 2026-01-22 18:07:36 +05:30
diptanilsaha
54acaa2aec fix(payment_reconciliation): retain journal entry accounts child table order 2026-01-22 14:06:56 +05:30
Sudharsanan11
3f8a0a4833 fix: autofill warehouse for packed items 2026-01-22 12:54:51 +05:30
SowmyaArunachalam
9e51701e2a fix(sales order): set project at item level from parent 2026-01-21 16:11:04 +05:30
Abdeali Chharchhoda
7c7ba0154a refactor: remove redundant onload function for bank mapping table 2026-01-12 00:23:02 +05:30
103 changed files with 10654 additions and 14029 deletions

View File

@@ -60,7 +60,7 @@ body:
description: Share exact version number of Frappe and ERPNext you are using.
placeholder: |
Frappe version -
ERPNext Verion -
ERPNext version -
validations:
required: true

View File

@@ -1,3 +1,4 @@
<div align="center">
<a href="https://frappe.io/erpnext">
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80xp"/>

View File

@@ -0,0 +1,50 @@
{
"cards": [
{
"card": "Total Outgoing Bills"
},
{
"card": "Total Incoming Bills"
},
{
"card": "Total Incoming Payment"
},
{
"card": "Total Outgoing Payment"
}
],
"charts": [
{
"chart": "Incoming Bills (Purchase Invoice)",
"width": "Half"
},
{
"chart": "Outgoing Bills (Sales Invoice)",
"width": "Half"
},
{
"chart": "Accounts Receivable Ageing",
"width": "Half"
},
{
"chart": "Accounts Payable Ageing",
"width": "Half"
},
{
"chart": "Bank Balance",
"width": "Full"
}
],
"creation": "2026-01-26 21:25:12.793893",
"dashboard_name": "Payments",
"docstatus": 0,
"doctype": "Dashboard",
"idx": 0,
"is_default": 0,
"is_standard": 1,
"modified": "2026-01-26 21:25:12.793893",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payments",
"owner": "Administrator"
}

View File

@@ -3,9 +3,6 @@
frappe.provide("erpnext.integrations");
frappe.ui.form.on("Bank", {
onload: function (frm) {
add_fields_to_mapping_table(frm);
},
refresh: function (frm) {
add_fields_to_mapping_table(frm);
frm.toggle_display(["address_html", "contact_html"], !frm.doc.__islocal);
@@ -37,11 +34,11 @@ let add_fields_to_mapping_table = function (frm) {
});
});
frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property(
"bank_transaction_field",
"options",
options
);
const grid = frm.fields_dict.bank_transaction_mapping?.grid;
if (grid) {
grid.update_docfield_property("bank_transaction_field", "options", options);
}
};
erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
@@ -116,7 +113,7 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
"There was an issue connecting to Plaid's authentication server. Check browser console for more information"
)
);
console.log(error);
console.error(error);
}
plaid_success(token, response) {

View File

@@ -179,7 +179,7 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], [])
def submit(self):
if len(self.accounts) > 100:
if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit")
else:
return self._submit()

View File

@@ -400,6 +400,16 @@ frappe.ui.form.on("Payment Entry", {
);
frm.refresh_fields();
const party_currency =
frm.doc.payment_type === "Receive" ? "paid_from_account_currency" : "paid_to_account_currency";
var reference_grid = frm.fields_dict["references"].grid;
["total_amount", "outstanding_amount", "allocated_amount"].forEach((fieldname) => {
reference_grid.update_docfield_property(fieldname, "options", party_currency);
});
reference_grid.refresh();
},
show_general_ledger: function (frm) {
@@ -1104,7 +1114,7 @@ frappe.ui.form.on("Payment Entry", {
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
await frm.call("allocate_amount_to_references", {
paid_amount: paid_amount,
paid_amount: flt(paid_amount),
paid_amount_change: paid_amount_change,
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});

View File

@@ -132,6 +132,12 @@
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "due_date",

View File

@@ -38,6 +38,7 @@ class PaymentLedgerEntry(Document):
amount_in_account_currency: DF.Currency
company: DF.Link | None
cost_center: DF.Link | None
project: DF.Link | None
delinked: DF.Check
due_date: DF.Date | None
finance_book: DF.Link | None

View File

@@ -746,7 +746,7 @@ class PaymentReconciliation(Document):
ple = qb.DocType("Payment Ledger Entry")
for x in self.dimensions:
dimension = x.fieldname
if self.get(dimension):
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):

View File

@@ -1610,13 +1610,14 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2025-08-04 22:22:31.471752",
"modified": "2026-01-29 21:20:51.376875",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -1625,7 +1625,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"collapsible": 1,
@@ -1667,7 +1668,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2025-12-15 06:41:38.237728",
"modified": "2026-01-29 21:21:53.051193",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -2250,7 +2250,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
},
{
"default": "0",
@@ -2304,7 +2305,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2025-12-24 18:29:50.242618",
"modified": "2026-01-29 21:22:30.074645",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -4739,6 +4739,66 @@ class TestSalesInvoice(ERPNextTestSuite):
doc.db_set("do_not_use_batchwise_valuation", original_value)
@change_settings("Selling Settings", {"set_zero_rate_for_expired_batch": True})
def test_zero_valuation_for_standalone_credit_note_with_expired_batch(self):
item_code = "_Test Item for Expiry Batch Zero Valuation"
make_item_for_si(
item_code,
{
"is_stock_item": 1,
"has_batch_no": 1,
"has_expiry_date": 1,
"shelf_life_in_days": 2,
"create_new_batch": 1,
"batch_number_series": "TBATCH-EBZV.####",
},
)
se = make_stock_entry(
item_code=item_code,
qty=10,
target="_Test Warehouse - _TC",
rate=100,
)
# fetch batch no from bundle
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
si = create_sales_invoice(
posting_date=add_days(nowdate(), 3),
item=item_code,
qty=-10,
rate=100,
is_return=1,
update_stock=1,
use_serial_batch_fields=1,
do_not_save=1,
do_not_submit=1,
)
si.items[0].batch_no = batch_no
si.save()
si.submit()
si.reload()
# check zero incoming rate in voucher
self.assertEqual(si.items[0].incoming_rate, 0.0)
# chekc zero incoming rate in stock ledger
stock_ledger_entry = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Sales Invoice",
"voucher_no": si.name,
"item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
},
["incoming_rate", "valuation_rate"],
as_dict=True,
)
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -43,16 +43,18 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:55.008837",
"modified": "2025-11-14 16:17:25.584675",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Transaction Deletion Record Details",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -663,6 +663,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
d["allocated_amount"] = d["allocated_amount"] * -1
d["unadjusted_amount"] = d["unadjusted_amount"] * -1
insert_position = -1
if flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) != 0:
# adjust the unreconciled balance
amount_in_account_currency = flt(d["unadjusted_amount"]) - flt(d["allocated_amount"])
@@ -674,9 +675,10 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
)
else:
journal_entry.remove(jv_detail)
insert_position += jv_detail.idx
# new row with references
new_row = journal_entry.append("accounts")
new_row = journal_entry.append("accounts", position=insert_position)
# Copy field values into new row
[
@@ -1947,6 +1949,7 @@ def get_payment_ledger_entries(gl_entries, cancel=0):
account=gle.account,
party_type=gle.party_type,
party=gle.party,
project=gle.project,
cost_center=gle.cost_center,
finance_book=gle.finance_book,
due_date=gle.due_date,

View File

@@ -14,10 +14,10 @@
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 3,
"idx": 4,
"indicator_color": "",
"is_hidden": 0,
"label": "Accounting",
"label": "Invoicing",
"links": [
{
"hidden": 0,
@@ -587,10 +587,10 @@
"type": "Link"
}
],
"modified": "2025-12-24 13:20:34.857205",
"modified": "2026-01-23 11:05:47.246213",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting",
"name": "Invoicing",
"number_cards": [
{
"label": "Outgoing Bills",
@@ -617,6 +617,6 @@
"roles": [],
"sequence_id": 2.0,
"shortcuts": [],
"title": "Accounting",
"title": "Invoicing",
"type": "Workspace"
}

View File

@@ -244,6 +244,8 @@ class Asset(AccountsController):
def before_submit(self):
if self.is_composite_asset and not has_active_capitalization(self.name):
if self.split_from and has_active_capitalization(self.split_from):
return
frappe.throw(_("Please capitalize this asset before submitting."))
def on_submit(self):

View File

@@ -246,7 +246,9 @@ def _make_journal_entry_for_depreciation(
def setup_journal_entry_metadata(je, depr_schedule_doc, depr_series, depr_schedule, asset):
je.voucher_type = "Depreciation Entry"
je.naming_series = depr_series
if depr_series:
je.naming_series = depr_series
je.posting_date = depr_schedule.schedule_date
je.company = asset.company
je.finance_book = depr_schedule_doc.finance_book

View File

@@ -1828,6 +1828,71 @@ class TestDepreciationBasics(AssetSetup):
pr.submit()
self.assertTrue(get_gl_entries("Purchase Receipt", pr.name))
def test_split_asset_created_via_capitalization(self):
"""Test that assets created via Asset Capitalization can be split without capitalization error"""
from erpnext.assets.doctype.asset_capitalization.test_asset_capitalization import (
create_asset_capitalization,
create_asset_capitalization_data,
)
# Ensure test data exists
create_asset_capitalization_data()
company = "_Test Company with perpetual inventory"
set_depreciation_settings_in_company(company=company)
name = frappe.db.get_value(
"Asset Category Account",
filters={"parent": "Computers", "company_name": company},
fieldname=["name"],
)
frappe.db.set_value("Asset Category Account", name, "capital_work_in_progress_account", "")
stock_rate = 1000
stock_qty = 2
total_amount = 2000
# Create composite asset
wip_composite_asset = create_asset(
asset_name="Asset Capitalization WIP Composite Asset for Split",
is_composite_asset=1,
warehouse="Stores - TCP1",
company=company,
asset_quantity=2, # Set quantity > 1 to allow splitting
)
# Create and submit Asset Capitalization
asset_capitalization = create_asset_capitalization(
target_asset=wip_composite_asset.name,
stock_qty=stock_qty,
stock_rate=stock_rate,
company=company,
submit=1,
)
# Verify asset was capitalized
target_asset = frappe.get_doc("Asset", asset_capitalization.target_asset)
self.assertEqual(target_asset.net_purchase_amount, total_amount)
self.assertEqual(target_asset.status, "Work In Progress")
# Submit the capitalized asset
target_asset.submit()
self.assertEqual(target_asset.status, "Submitted")
# Split the asset - this should work without capitalization error
split_qty = 1
splitted_asset = split_asset(target_asset.name, split_qty)
# Verify split asset was created and submitted successfully
self.assertIsNotNone(splitted_asset)
self.assertEqual(splitted_asset.asset_quantity, split_qty)
self.assertEqual(splitted_asset.split_from, target_asset.name)
self.assertEqual(splitted_asset.docstatus, 1) # Should be submitted
self.assertEqual(splitted_asset.status, "Submitted")
# Verify original asset was updated
target_asset.reload()
self.assertEqual(target_asset.asset_quantity, 1) # Remaining quantity
def get_gl_entries(doctype, docname):
gl_entry = frappe.qb.DocType("GL Entry")

View File

@@ -573,13 +573,19 @@ class AssetCapitalization(StockController):
if self.docstatus == 2:
net_purchase_amount = asset_doc.net_purchase_amount - total_target_asset_value
purchase_amount = asset_doc.purchase_amount - total_target_asset_value
asset_doc.db_set("total_asset_cost", asset_doc.total_asset_cost - total_target_asset_value)
total_asset_cost = asset_doc.total_asset_cost - total_target_asset_value
else:
net_purchase_amount = asset_doc.net_purchase_amount + total_target_asset_value
purchase_amount = asset_doc.purchase_amount + total_target_asset_value
total_asset_cost = asset_doc.total_asset_cost + total_target_asset_value
asset_doc.db_set("net_purchase_amount", net_purchase_amount)
asset_doc.db_set("purchase_amount", purchase_amount)
asset_doc.db_set(
{
"net_purchase_amount": net_purchase_amount,
"purchase_amount": purchase_amount,
"total_asset_cost": total_asset_cost,
}
)
frappe.msgprint(
_("Asset {0} has been updated. Please set the depreciation details if any and submit it.").format(

View File

@@ -803,7 +803,7 @@ frappe.ui.form.on("Purchase Order", "is_subcontracted", function (frm) {
function prevent_past_schedule_dates(frm) {
if (frm.doc.transaction_date) {
frm.fields_dict["schedule_date"].datepicker.update({
frm.fields_dict["schedule_date"].datepicker?.update({
minDate: new Date(frm.doc.transaction_date),
});
}

View File

@@ -1301,7 +1301,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"grid_page_length": 50,
@@ -1309,7 +1310,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-09-28 11:00:56.635116",
"modified": "2026-01-29 21:22:54.323838",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -149,6 +149,7 @@ class PurchaseOrder(BuyingController):
supplied_items: DF.Table[PurchaseOrderItemSupplied]
supplier: DF.Link
supplier_address: DF.Link | None
supplier_group: DF.Link | None
supplier_name: DF.Data | None
supplier_warehouse: DF.Link | None
tax_category: DF.Link | None

View File

@@ -304,12 +304,17 @@ class RequestforQuotation(BuyingController):
else:
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
rendered_message = frappe.render_template(self.message_for_supplier, doc_args)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation")
)
rendered_subject = frappe.render_template(subject_source, doc_args)
if preview:
return {
"message": self.message_for_supplier,
"subject": self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation"),
"message": rendered_message,
"subject": rendered_subject,
}
attachments = []
@@ -333,10 +338,8 @@ class RequestforQuotation(BuyingController):
self.send_email(
data,
sender,
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation"),
self.message_for_supplier,
rendered_subject,
rendered_message,
attachments,
)

View File

@@ -938,7 +938,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"grid_page_length": 50,
@@ -947,7 +948,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-07-23 02:22:43.526822",
"modified": "2026-01-29 21:23:13.778468",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",

View File

@@ -1075,12 +1075,9 @@ class BuyingController(SubcontractingController):
}
)
for dimension in accounting_dimensions[0]:
asset.update(
{
dimension["fieldname"]: self.get(dimension["fieldname"])
or dimension.get("default_dimension")
}
)
fieldname = dimension["fieldname"]
default_dimension = accounting_dimensions[1].get(self.company, {}).get(fieldname)
asset.update({fieldname: row.get(fieldname) or self.get(fieldname) or default_dimension})
asset.flags.ignore_validate = True
asset.flags.ignore_mandatory = True

View File

@@ -212,7 +212,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
party = filters.get("customer") or filters.get("supplier")
item_rules_list = frappe.get_all(
"Party Specific Item",
filters={"party": party},
filters={
"party": ["!=", party],
"party_type": "Customer" if filters.get("customer") else "Supplier",
},
fields=["restrict_based_on", "based_on_value"],
)
@@ -226,7 +229,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
filters_dict[rule.restrict_based_on].append(rule.based_on_value)
for filter in filters_dict:
filters[scrub(filter)] = ["in", filters_dict[filter]]
filters[scrub(filter)] = ["not in", filters_dict[filter]]
if filters.get("customer"):
del filters["customer"]

View File

@@ -12,7 +12,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
import erpnext
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method
from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate
class StockOverReturnError(frappe.ValidationError):
@@ -759,6 +759,29 @@ def get_rate_for_return(
StockLedgerEntry = frappe.qb.DocType("Stock Ledger Entry")
select_field = Abs(StockLedgerEntry.stock_value_difference / StockLedgerEntry.actual_qty)
item_details = frappe.get_cached_value("Item", item_code, ["has_batch_no", "has_expiry_date"], as_dict=1)
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
"Selling Settings", "set_zero_rate_for_expired_batch"
)
if (
set_zero_rate_for_expired_batch
and item_details.has_batch_no
and item_details.has_expiry_date
and not return_against
and voucher_type in ["Sales Invoice", "Delivery Note"]
):
# set incoming_rate zero explicitly for standalone credit note with expired batch
batch_no = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "batch_no")
if batch_no and is_batch_expired(batch_no, sle.get("posting_date")):
frappe.db.set_value(
voucher_type + " Item",
voucher_detail_no,
"incoming_rate",
0,
)
return 0
rate = flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field))
if not (rate and return_against) and voucher_type in ["Sales Invoice", "Delivery Note"]:
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
@@ -1276,3 +1299,17 @@ def get_sales_invoice_item_from_consolidated_invoice(return_against_pos_invoice,
return result[0].name if result else None
except Exception:
return None
def is_batch_expired(batch_no, posting_date):
"""
To check whether the batch is expired or not based on the posting date.
"""
expiry_date = frappe.db.get_value("Batch", batch_no, "expiry_date")
if not expiry_date:
return
if getdate(posting_date) > getdate(expiry_date):
return True
return False

View File

@@ -8,7 +8,7 @@ from frappe.utils import cint, flt, get_link_to_form, nowtime
from erpnext.accounts.party import render_address
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.item.item import set_item_default
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
@@ -536,16 +536,31 @@ class SellingController(StockController):
allow_at_arms_length_price = frappe.get_cached_value(
"Stock Settings", None, "allow_internal_transfer_at_arms_length_price"
)
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
"Selling Settings", "set_zero_rate_for_expired_batch"
)
items = self.get("items") + (self.get("packed_items") or [])
for d in items:
if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"):
continue
item_details = frappe.get_cached_value(
"Item", d.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
"Item", d.item_code, ["has_serial_no", "has_batch_no", "has_expiry_date"], as_dict=1
)
if not self.get("return_against") or (
if (
set_zero_rate_for_expired_batch
and item_details.has_batch_no
and item_details.has_expiry_date
and self.get("is_return")
and not self.get("return_against")
and is_batch_expired(d.batch_no, self.get("posting_date"))
):
# set incoming rate as zero for stand-lone credit note with expired batch
d.incoming_rate = 0
elif not self.get("return_against") or (
get_valuation_method(d.item_code, self.company) == "Moving Average"
and self.get("is_return")
and not item_details.has_serial_no

View File

@@ -552,7 +552,10 @@ class StockController(AccountsController):
if is_rejected:
serial_nos = row.get("rejected_serial_no")
type_of_transaction = "Inward" if not self.is_return else "Outward"
qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0)
qty = flt(
row.get("rejected_qty") * row.get("conversion_factor", 1.0),
frappe.get_precision("Serial and Batch Entry", "qty"),
)
warehouse = row.get("rejected_warehouse")
if (

View File

@@ -1,20 +1,20 @@
{
"app": "erpnext",
"creation": "2025-11-17 20:55:11.854086",
"creation": "2026-01-27 17:02:43.440221",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "accounting",
"icon_type": "Link",
"icon_type": "Folder",
"idx": 1,
"label": "Accounting",
"link_to": "Accounting",
"link_to": "",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.203651",
"modified": "2026-01-27 17:04:04.351402",
"modified_by": "Administrator",
"name": "Accounting",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "",
"restrict_removal": 0,
"roles": [],
"standard": 1

View File

@@ -1,18 +0,0 @@
{
"app": "erpnext",
"creation": "2025-11-12 13:07:51.988728",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon_type": "Folder",
"idx": 1,
"label": "Accounts",
"link_type": "DocType",
"logo_url": "",
"modified": "2025-11-17 17:39:36.915358",
"modified_by": "Administrator",
"name": "Accounts",
"owner": "Administrator",
"roles": [],
"standard": 1
}

View File

@@ -0,0 +1,21 @@
{
"app": "erpnext",
"bg_color": "blue",
"creation": "2026-01-27 17:37:55.824821",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon_type": "Link",
"idx": 3,
"label": "Accounts Setup",
"link_to": "Accounts Setup",
"link_type": "Workspace Sidebar",
"modified": "2026-01-27 18:34:57.092350",
"modified_by": "Administrator",
"name": "Accounts Setup",
"owner": "Administrator",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -14,7 +14,7 @@
"modified_by": "Administrator",
"name": "Banking",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1

View File

@@ -1,20 +1,20 @@
{
"app": "erpnext",
"creation": "2025-11-10 16:54:04.780644",
"creation": "2026-01-23 11:00:23.272751",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "expenses",
"icon_type": "Link",
"idx": 4,
"idx": 6,
"label": "Budget",
"link_to": "Budget",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.449176",
"modified": "2026-01-23 14:39:30.839274",
"modified_by": "Administrator",
"name": "Budget",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1

View File

@@ -1,20 +1,20 @@
{
"app": "erpnext",
"creation": "2025-11-17 20:55:11.772622",
"creation": "2026-01-23 11:00:23.250819",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "file",
"icon_type": "Link",
"idx": 0,
"idx": 2,
"label": "Financial Reports",
"link_to": "Financial Reports",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.253367",
"modified": "2026-01-23 14:38:46.479759",
"modified_by": "Administrator",
"name": "Financial Reports",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"sidebar": "",

View File

@@ -0,0 +1,21 @@
{
"app": "erpnext",
"creation": "2026-01-23 10:51:05.799725",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "accounting",
"icon_type": "Link",
"idx": 0,
"label": "Invoicing",
"link_to": "Invoicing",
"link_type": "Workspace Sidebar",
"modified": "2026-01-23 15:17:23.564795",
"modified_by": "Administrator",
"name": "Invoicing",
"owner": "Administrator",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -0,0 +1,22 @@
{
"app": "erpnext",
"bg_color": "blue",
"creation": "2026-01-27 17:37:55.866525",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "receipt-text",
"icon_type": "Link",
"idx": 1,
"label": "Payments",
"link_to": "Payments",
"link_type": "Workspace Sidebar",
"modified": "2026-01-27 18:31:59.617181",
"modified_by": "Administrator",
"name": "Payments",
"owner": "Administrator",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -1,19 +1,19 @@
{
"app": "erpnext",
"creation": "2026-01-12 12:31:53.444807",
"creation": "2026-01-23 11:00:23.303554",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon_type": "Link",
"idx": 8,
"idx": 7,
"label": "Share Management",
"link_to": "Share Management",
"link_type": "Workspace Sidebar",
"modified": "2026-01-12 12:31:53.444807",
"modified": "2026-01-23 14:39:34.128991",
"modified_by": "Administrator",
"name": "Share Management",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1

View File

@@ -1,20 +1,20 @@
{
"app": "erpnext",
"creation": "2025-11-10 16:14:25.976756",
"creation": "2026-01-23 11:00:23.344237",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "monitor-check",
"icon_type": "Link",
"idx": 99,
"idx": 8,
"label": "Subscription",
"link_to": "Subscription",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.548581",
"modified": "2026-01-23 14:39:37.830722",
"modified_by": "Administrator",
"name": "Subscription",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"standard": 1

View File

@@ -1,20 +1,20 @@
{
"app": "erpnext",
"creation": "2025-11-12 15:05:54.474218",
"creation": "2026-01-23 11:00:23.262357",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "book-text",
"icon_type": "Link",
"idx": 3,
"idx": 4,
"label": "Taxes",
"link_to": "Taxes",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.356333",
"modified": "2026-01-23 14:39:25.636166",
"modified_by": "Administrator",
"name": "Taxes",
"owner": "Administrator",
"parent_icon": "Accounts",
"parent_icon": "Accounting",
"restrict_removal": 0,
"roles": [],
"sidebar": "",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -697,8 +697,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
do_not_explode: d.do_not_explode,
},
callback: function (r) {
d = locals[cdt][cdn];
$.extend(d, r.message);
refresh_field("items");
refresh_field("scrap_items");

View File

@@ -1547,6 +1547,9 @@ def add_operating_cost_component_wise(
if job_card and job_card.operation_id != row.name:
continue
if not row.actual_operation_time:
continue
workstation_cost = frappe.get_all(
"Workstation Cost",
fields=["operating_component", "operating_cost"],
@@ -1609,7 +1612,7 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_
job_card=job_card,
)
if not cost_added:
if not cost_added and not job_card:
stock_entry.append(
"additional_costs",
{

View File

@@ -127,8 +127,6 @@ frappe.ui.form.on("Job Card", {
},
refresh: function (frm) {
frm.trigger("setup_stock_entry");
let has_items = frm.doc.items && frm.doc.items.length;
frm.trigger("make_fields_read_only");
@@ -196,6 +194,8 @@ frappe.ui.form.on("Job Card", {
frm.trigger("toggle_operation_number");
let is_timer_running = false;
if (
frm.doc.for_quantity + frm.doc.process_loss_qty > frm.doc.total_completed_qty &&
(frm.doc.skip_material_transfer ||
@@ -269,12 +269,18 @@ frappe.ui.form.on("Job Card", {
frm.add_custom_button(__("Complete Job"), () => {
frm.trigger("complete_job_card");
});
is_timer_running = true;
}
frm.trigger("make_dashboard");
}
}
if (!is_timer_running) {
frm.trigger("setup_stock_entry");
}
frm.trigger("setup_quality_inspection");
if (frm.doc.work_order) {

View File

@@ -746,19 +746,21 @@ class ProductionPlan(Document):
"project": self.project,
}
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse)
key = (d.item_code, d.sales_order, d.sales_order_item, d.warehouse, d.planned_start_date)
if self.combine_items:
key = (d.item_code, d.sales_order, d.warehouse)
key = (d.item_code, d.sales_order, d.warehouse, d.planned_start_date)
if not d.sales_order:
key = (d.name, d.item_code, d.warehouse)
key = (d.name, d.item_code, d.warehouse, d.planned_start_date)
if not item_details["project"] and d.sales_order:
item_details["project"] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({"qty": d.planned_qty})
item_dict[(d.item_code, d.material_request_item, d.warehouse)] = item_details
item_dict[
(d.item_code, d.material_request_item, d.warehouse, d.planned_start_date)
] = item_details
else:
item_details.update(
{

View File

@@ -999,7 +999,7 @@ class TestProductionPlan(IntegrationTestCase):
items_data = pln.get_production_items()
# Update qty
items_data[(pln.po_items[0].name, item, None)]["qty"] = qty
items_data[(pln.po_items[0].name, item, None, pln.po_items[0].planned_start_date)]["qty"] = qty
# Create and Submit Work Order for each item in items_data
for _key, item in items_data.items():

View File

@@ -3725,6 +3725,53 @@ class TestWorkOrder(IntegrationTestCase):
wo = make_wo_order_test_record(item="Top Level Parent")
self.assertEqual([item.item_code for item in wo.required_items], expected)
def test_reserved_qty_for_pp_with_extra_material_transfer(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
rm_item_code = make_item(
"_Test Reserved Qty PP Item",
{
"is_stock_item": 1,
},
).name
fg_item_code = make_item(
"_Test Reserved Qty PP FG Item",
{
"is_stock_item": 1,
},
).name
make_stock_entry_test_record(
item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100
)
make_bom(
item=fg_item_code,
raw_materials=[rm_item_code],
)
wo_order = make_wo_order_test_record(
item=fg_item_code,
qty=1,
source_warehouse="_Test Warehouse - _TC",
skip_transfer=0,
target_warehouse="_Test Warehouse - _TC",
)
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1)
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
s.items[0].qty += 2 # extra material transfer
s.submit()
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -710,7 +710,7 @@ erpnext.work_order = {
set_custom_buttons: function (frm) {
var doc = frm.doc;
if (doc.docstatus === 1 && doc.status !== "Closed") {
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
frm.add_custom_button(
__("Close"),
function () {
@@ -720,9 +720,6 @@ erpnext.work_order = {
},
__("Status")
);
}
if (doc.docstatus === 1 && !["Closed", "Completed"].includes(doc.status)) {
if (doc.status != "Stopped" && doc.status != "Completed") {
frm.add_custom_button(
__("Stop"),

View File

@@ -2654,6 +2654,9 @@ def get_reserved_qty_for_production(
qty_field = wo_item.required_qty
else:
qty_field = Case()
qty_field = qty_field.when(
((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0
)
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)

View File

@@ -453,7 +453,6 @@ class MaterialRequirementsPlanningReport:
row[field] = rm_details.get(field)
self.update_required_qty(row)
row.release_date = add_days(row.delivery_date, row.lead_time * -1)
if i != 0:
data.append(frappe._dict({}))
@@ -462,7 +461,15 @@ class MaterialRequirementsPlanningReport:
if rm_details.raw_materials:
row.capacity = get_item_capacity(row.item_code, self.filters.bucket_size)
row.type_of_material = "Manufacture"
if row.lead_time and row.required_qty:
row.lead_time = math.ceil(row.required_qty / row.lead_time)
elif not row.required_qty:
row.lead_time = 0
if not row.lead_time and rm_details.raw_materials:
row.lead_time = self.get_lead_time_from_raw_materials(rm_details.raw_materials)
row.release_date = add_days(row.delivery_date, row.lead_time * -1)
data.append(row)
if rm_details.raw_materials:
self.update_rm_details(
@@ -471,6 +478,15 @@ class MaterialRequirementsPlanningReport:
return data
def get_lead_time_from_raw_materials(self, raw_materials):
lead_time = 0
for material in raw_materials:
lead_time += math.ceil(material.lead_time)
if material.raw_materials:
lead_time += self.get_lead_time_from_raw_materials(material.raw_materials)
return lead_time
def add_non_planned_so(self, row):
if so_details := self._so_details.get((row.item_code, row.delivery_date)):
row.adhoc_qty = so_details.qty
@@ -1198,8 +1214,10 @@ def get_item_lead_time(item_code, type_of_material):
if type_of_material == "Manufacture":
query = query.select(
Case()
.when(doctype.manufacturing_time_in_mins.isnull(), 0)
.else_(doctype.manufacturing_time_in_mins / 1440 + doctype.buffer_time)
.when(
(doctype.manufacturing_time_in_mins.isnull() | (doctype.manufacturing_time_in_mins <= 0)), 0
)
.else_(1440 / doctype.manufacturing_time_in_mins + doctype.buffer_time)
.as_("lead_time")
)
else:

View File

@@ -459,3 +459,4 @@ erpnext.patches.v16_0.fix_barcode_typo
erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing")
erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2

View File

@@ -0,0 +1,42 @@
import frappe
def execute():
"""
Migrate Transaction Deletion Record boolean task flags to status Select fields.
Renames fields from old names to new names with _status suffix.
Maps: 0 -> "Pending", 1 -> "Completed"
"""
if not frappe.db.table_exists("tabTransaction Deletion Record"):
return
# Field mapping: old boolean field name -> new status field name
field_mapping = {
"delete_bin_data": "delete_bin_data_status",
"delete_leads_and_addresses": "delete_leads_and_addresses_status",
"reset_company_default_values": "reset_company_default_values_status",
"clear_notifications": "clear_notifications_status",
"initialize_doctypes_table": "initialize_doctypes_table_status",
"delete_transactions": "delete_transactions_status",
}
# Get all Transaction Deletion Records
records = frappe.db.get_all("Transaction Deletion Record", pluck="name")
for name in records or []:
updates = {}
for old_field, new_field in field_mapping.items():
# Read from old boolean field
current_value = frappe.db.get_value("Transaction Deletion Record", name, old_field)
# Map to new status and write to new field name
if current_value in (1, "1", True):
updates[new_field] = "Completed"
else:
# Handle 0, "0", False, None, empty string
updates[new_field] = "Pending"
# Update all fields at once
if updates:
frappe.db.set_value("Transaction Deletion Record", name, updates, update_modified=False)

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M19.2857 15.4286H22.1786C23.7763 15.4286 25.0714 16.7237 25.0714 18.3214V24.1071C25.0714 25.7048 23.7763 27 22.1786 27H19.2857V38.5714H15.4286V27H11.5714V23.1428H21.2143V19.2857H11.5714V15.4286H15.4286V11.5714H19.2857V15.4286ZM38.5714 38.5714H34.7143V34.7143H31.8214C30.2238 34.7143 28.9286 33.4191 28.9286 31.8214V26.0357C28.9286 24.438 30.2238 23.1428 31.8214 23.1428H34.7143V11.5714H38.5714V23.1428H42.4286V27H32.7857V30.8571H42.4286V34.7143H38.5714V38.5714Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 787 B

View File

@@ -1,4 +0,0 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M36.8127 12C39.4016 12 41.5002 14.0987 41.5002 16.6875V37.3125C41.5002 39.9013 39.4016 42 36.8127 42H16.1877C13.5989 42 11.5002 39.9013 11.5002 37.3125V16.6875C11.5002 14.0987 13.5989 12 16.1877 12H36.8127ZM15.2502 38.25H37.7502V25.125H15.2502V38.25ZM22.7502 36.375H19.0002V32.625H22.7502V36.375ZM28.3752 36.375H24.6252V32.625H28.3752V36.375ZM34.0002 36.375H30.2502V32.625H34.0002V36.375ZM22.7502 30.75H19.0002V27H22.7502V30.75ZM28.3752 30.75H24.6252V27H28.3752V30.75ZM34.0002 30.75H30.2502V27H34.0002V30.75ZM15.2502 21.375H37.7502V15.75H15.2502V21.375ZM35.8752 20.4375H30.2502V16.6875H35.8752V20.4375Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 928 B

View File

@@ -1,4 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M31.4897 14.5C32.639 14.5 33.7415 14.9569 34.5542 15.7695L38.231 19.4463C39.0435 20.2589 39.5005 21.3616 39.5005 22.5107V36.167C39.5 38.5598 37.5594 40.5 35.1665 40.5H13.5005V37.0332H36.0337C36.0005 37 36.0332 36.6452 36.0337 36.167V22.5107C36.0337 22.281 35.9422 22.06 35.7798 21.8975L32.103 18.2207C31.9405 18.0582 31.7196 17.9668 31.4897 17.9668H29.1001V14.5H31.4897ZM23.5571 14.5H27.3667V17.9668H23.5571V22.2998H23.8999C25.4121 22.2998 26.8745 22.8788 27.9624 23.9277C29.0525 24.9789 29.6772 26.4188 29.6772 27.9336C29.6771 29.4482 29.0524 30.8874 27.9624 31.9385C26.8745 32.9873 25.4121 33.5664 23.8999 33.5664H23.5571V35.9805H20.0903V33.5664H15.7817V30.0996H20.0903V25.7666H19.5659C18.0537 25.7664 16.5912 25.1876 15.5034 24.1387C14.4135 23.0877 13.7888 21.6483 13.7886 20.1338C13.7886 18.6191 14.4135 17.1791 15.5034 16.1279C16.5912 15.079 18.0538 14.5002 19.5659 14.5H20.0903V12.7666H23.5571V14.5ZM23.5571 30.0996H23.8999C24.5325 30.0996 25.1273 29.8567 25.5562 29.4434C25.9828 29.0319 26.2103 28.4873 26.2104 27.9336C26.2104 27.3798 25.9829 26.8354 25.5562 26.4238C25.1272 26.0102 24.5327 25.7666 23.8999 25.7666H23.5571V30.0996ZM19.5659 17.9668C18.9334 17.967 18.3385 18.2097 17.9097 18.623C17.4831 19.0346 17.2554 19.5801 17.2554 20.1338C17.2556 20.6874 17.483 21.2322 17.9097 21.6436C18.3385 22.057 18.9334 22.2996 19.5659 22.2998H20.0903V17.9668H19.5659Z" fill="white"/>
<path d="M32.5522 11.5714C33.8307 11.5716 35.057 12.0799 35.9611 12.9839L40.0518 17.0746C40.9558 17.9787 41.4641 19.205 41.4643 20.4835V35.6786C41.4643 38.3413 39.3056 40.4999 36.6429 40.5H17.941C14.0902 40.4999 11.7934 36.2082 13.9294 33.0042L16.7677 28.7478C18.4246 26.2629 22.4502 26.4437 24.1071 28.9286L28.2393 22.1503L31.5465 24.1354L27.597 30.7196C26.0159 33.3546 21.9546 33.4143 20.25 30.8574C20.5231 30.8574 19.9769 30.8574 20.25 30.8574L17.1387 35.1437C16.7115 35.7845 17.1709 36.6428 17.941 36.6428H37.6071C37.6071 36.6428 37.6071 36.2111 37.6071 35.6786V20.4835C37.607 20.228 37.5053 19.9824 37.3246 19.8017L33.234 15.7111C33.0533 15.5304 32.8077 15.4287 32.5522 15.4286H16.3929C16.3929 15.4286 16.393 15.8604 16.3929 16.3928V23.1428H12.5357V16.3928C12.5358 13.7301 14.6944 11.5714 17.3571 11.5714H32.5522Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M34.6209 11.5228C35.9515 11.5229 37.21 12.0858 38.0412 13.0518L40.9712 16.4569C41.5924 17.1789 41.9316 18.0762 41.9317 19.0008V36.2889C41.9317 40.1042 36.7946 41.8256 34.1769 38.8872L32.7866 37.3263L30.1714 39.465C28.6326 40.7243 26.3336 40.7459 24.7695 39.5138L21.9828 37.3169L20.5602 38.966C18.051 41.8744 13.0031 40.2255 13.0031 36.4971H17.1358L18.7582 34.9137C20.2135 33.2268 22.8799 32.9886 24.6545 34.3865L27.4412 36.5815L30.0564 34.4428C31.8061 33.0108 34.4819 33.207 35.9688 34.8762L37.799 36.5065V19.0008C37.7989 18.9489 37.7794 18.8989 37.7446 18.8582L34.8146 15.4513C34.7676 15.3967 34.696 15.3651 34.6209 15.365H17.1358V15.5957V30.7338H13.0031V15.5957C13.0031 13.3464 14.9646 11.5228 17.384 11.5228H34.6209Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7"/>
<path d="M19.2857 18.3214H15.4286C15.4286 18.3214 15.4286 18.7532 15.4286 19.2857V36.6429C15.4286 37.1754 15.4286 37.6072 15.4286 37.6072H38.5714C38.5714 37.6072 38.5714 37.1754 38.5714 36.6429V19.2857C38.5714 18.7532 38.5714 18.3214 38.5714 18.3214H34.7143V14.4643H37.6072C40.27 14.4643 42.4286 16.6229 42.4286 19.2857V36.6429C42.4286 39.3057 40.27 41.4643 37.6072 41.4643H16.3929C13.7301 41.4643 11.5714 39.3057 11.5714 36.6429V19.2857C11.5714 16.6229 13.7301 14.4643 16.3929 14.4643H19.2857V18.3214ZM36.6429 35.6786H28.9286V31.8214H36.6429V35.6786ZM33.9214 12.9067L32.7857 14.4643L31.6501 16.0237L28.9286 14.0387V24.1072H25.0714V14.0387L22.35 16.0237L21.2143 14.4643L20.0786 12.9067L25.8643 8.68988H28.1357L33.9214 12.9067Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M19.2857 15.4286H22.1786C23.7762 15.4286 25.0714 16.7238 25.0714 18.3215V24.1072C25.0714 25.7049 23.7762 27 22.1786 27H19.2857V38.5715H15.4286V27H11.5714V23.1429H21.2143V19.2858H11.5714V15.4286H15.4286V11.5715H19.2857V15.4286ZM38.5714 38.5715H34.7143V34.7143H31.8214C30.2237 34.7143 28.9286 33.4192 28.9286 31.8215V26.0358C28.9286 24.4381 30.2237 23.1429 31.8214 23.1429H34.7143V11.5715H38.5714V23.1429H42.4286V27H32.7857V30.8572H42.4286V34.7143H38.5714V38.5715Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -1,4 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.09"/>
<path d="M31.4897 14.5C32.639 14.5 33.7415 14.9569 34.5542 15.7695L38.231 19.4463C39.0435 20.2589 39.5005 21.3616 39.5005 22.5107V36.167C39.5 38.5598 37.5594 40.5 35.1665 40.5H13.5005V37.0332H36.0337C36.0005 37 36.0332 36.6452 36.0337 36.167V22.5107C36.0337 22.281 35.9422 22.06 35.7798 21.8975L32.103 18.2207C31.9405 18.0582 31.7196 17.9668 31.4897 17.9668H29.1001V14.5H31.4897ZM23.5571 14.5H27.3667V17.9668H23.5571V22.2998H23.8999C25.4121 22.2998 26.8745 22.8788 27.9624 23.9277C29.0525 24.9789 29.6772 26.4188 29.6772 27.9336C29.6771 29.4482 29.0524 30.8874 27.9624 31.9385C26.8745 32.9873 25.4121 33.5664 23.8999 33.5664H23.5571V35.9805H20.0903V33.5664H15.7817V30.0996H20.0903V25.7666H19.5659C18.0537 25.7664 16.5912 25.1876 15.5034 24.1387C14.4135 23.0877 13.7888 21.6483 13.7886 20.1338C13.7886 18.6191 14.4135 17.1791 15.5034 16.1279C16.5912 15.079 18.0538 14.5002 19.5659 14.5H20.0903V12.7666H23.5571V14.5ZM23.5571 30.0996H23.8999C24.5325 30.0996 25.1273 29.8567 25.5562 29.4434C25.9828 29.0319 26.2103 28.4873 26.2104 27.9336C26.2104 27.3798 25.9829 26.8354 25.5562 26.4238C25.1272 26.0102 24.5327 25.7666 23.8999 25.7666H23.5571V30.0996ZM19.5659 17.9668C18.9334 17.967 18.3385 18.2097 17.9097 18.623C17.4831 19.0346 17.2554 19.5801 17.2554 20.1338C17.2556 20.6874 17.483 21.2322 17.9097 21.6436C18.3385 22.057 18.9334 22.2996 19.5659 22.2998H20.0903V17.9668H19.5659Z" fill="#0981E3"/>
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M32.5522 11.5715C33.8306 11.5716 35.057 12.08 35.9611 12.984L40.0517 17.0747C40.9558 17.9787 41.4641 19.2051 41.4643 20.4836V35.6786C41.4643 38.3414 39.3056 40.5 36.6428 40.5H17.941C14.0902 40.5 11.7934 36.2083 13.9294 33.0042L16.7676 28.7478C18.4246 26.2629 22.4502 26.4437 24.1071 28.9286L28.2392 22.1504L31.5464 24.1354L27.597 30.7197C26.0159 33.3547 21.9546 33.4143 20.25 30.8574C20.5231 30.8574 19.9769 30.8574 20.25 30.8574L17.1387 35.1437C16.7115 35.7845 17.1709 36.6428 17.941 36.6429H37.6071C37.6071 36.6429 37.6071 36.2111 37.6071 35.6786V20.4836C37.607 20.2281 37.5053 19.9825 37.3246 19.8018L33.2339 15.7111C33.0533 15.5305 32.8077 15.4288 32.5522 15.4286H16.3928C16.3928 15.4286 16.3929 15.8604 16.3928 16.3929V23.1429H12.5357V16.3929C12.5358 13.7302 14.6944 11.5715 17.3571 11.5715H32.5522Z" fill="#0981E3"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M34.6208 11.5228C35.9514 11.5229 37.2099 12.0858 38.0412 13.0518L40.9712 16.4569C41.5924 17.1789 41.9315 18.0762 41.9317 19.0008V36.2888C41.9317 40.1042 36.7945 41.8256 34.1769 38.8872L32.7866 37.3263L30.1714 39.465C28.6326 40.7243 26.3335 40.7459 24.7695 39.5138L21.9827 37.3169L20.5601 38.966C18.051 41.8744 13.0031 40.2255 13.0031 36.4971H17.1358L18.7581 34.9137C20.2135 33.2268 22.8798 32.9886 24.6544 34.3865L27.4412 36.5815L30.0563 34.4428C31.8061 33.0108 34.4819 33.207 35.9688 34.8762L37.799 36.5065V19.0008C37.7989 18.9489 37.7793 18.8989 37.7445 18.8582L34.8145 15.4513C34.7676 15.3967 34.696 15.3651 34.6208 15.365H17.1358V15.5957V30.7338H13.0031V15.5957C13.0031 13.3464 14.9646 11.5228 17.384 11.5228H34.6208Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.5714 0H15.4286C6.90761 0 0 6.90761 0 15.4286V38.5714C0 47.0924 6.90761 54 15.4286 54H38.5714C47.0924 54 54 47.0924 54 38.5714V15.4286C54 6.90761 47.0924 0 38.5714 0Z" fill="#0289F7" fill-opacity="0.1"/>
<path d="M19.2858 18.3214H15.4286C15.4286 18.3214 15.4286 18.7532 15.4286 19.2857V36.6429C15.4286 37.1754 15.4286 37.6072 15.4286 37.6072H38.5715C38.5715 37.6072 38.5715 37.1754 38.5715 36.6429V19.2857C38.5715 18.7532 38.5715 18.3214 38.5715 18.3214H34.7143V14.4643H37.6072C40.27 14.4643 42.4286 16.6229 42.4286 19.2857V36.6429C42.4286 39.3057 40.27 41.4643 37.6072 41.4643H16.3929C13.7301 41.4643 11.5715 39.3057 11.5715 36.6429V19.2857C11.5715 16.6229 13.7301 14.4643 16.3929 14.4643H19.2858V18.3214ZM36.6429 35.6786H28.9286V31.8214H36.6429V35.6786ZM33.9214 12.9067L32.7858 14.4643L31.6501 16.0237L28.9286 14.0387V24.1072H25.0715V14.0387L22.35 16.0237L21.2143 14.4643L20.0787 12.9067L25.8644 8.68988H28.1357L33.9214 12.9067Z" fill="#0981E3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -637,6 +637,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
tax_count ? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff : this.frm.doc.net_total
);
// total taxes and charges is calculated before adjusting base grand total
this.frm.doc.total_taxes_and_charges = flt(
this.frm.doc.grand_total - this.frm.doc.net_total - grand_total_diff,
precision("total_taxes_and_charges")
);
if (
["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(
this.frm.doc.doctype
@@ -679,11 +685,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
]);
}
this.frm.doc.total_taxes_and_charges = flt(
this.frm.doc.grand_total - this.frm.doc.net_total - grand_total_diff,
precision("total_taxes_and_charges")
);
this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges"]);
// Round grand total as per precision

View File

@@ -3131,10 +3131,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
set_warehouse() {
this.autofill_warehouse(this.frm.doc.items, "warehouse", this.frm.doc.set_warehouse);
this.autofill_warehouse(this.frm.doc.packed_items, "warehouse", this.frm.doc.set_warehouse);
}
set_target_warehouse() {
this.autofill_warehouse(this.frm.doc.items, "target_warehouse", this.frm.doc.set_target_warehouse);
this.autofill_warehouse(
this.frm.doc.packed_items,
"target_warehouse",
this.frm.doc.set_target_warehouse
);
}
set_from_warehouse() {

View File

@@ -1,12 +1,17 @@
frappe.provide("erpnext.financial_statements");
function get_filter_value(filter_name) {
// not warn when the filter is missing
return frappe.query_report.get_filter_value(filter_name, false);
}
erpnext.financial_statements = {
filters: get_filters(),
baseData: null,
get_pdf_format: function (report, custom_format) {
// If report template is selected, use default pdf formatting
return report.get_filter_value("report_template") ? null : custom_format;
return get_filter_value("report_template") ? null : custom_format;
},
formatter: function (value, row, column, data, default_formatter, filter) {
@@ -15,14 +20,14 @@ erpnext.financial_statements = {
if (erpnext.financial_statements._is_special_view(column, data))
return erpnext.financial_statements._format_special_view(...report_params);
if (frappe.query_report.get_filter_value("report_template"))
if (get_filter_value("report_template"))
return erpnext.financial_statements._format_custom_report(...report_params);
else return erpnext.financial_statements._format_standard_report(...report_params);
},
_is_special_view: function (column, data) {
if (!data) return false;
const view = frappe.query_report.get_filter_value("selected_view");
const view = get_filter_value("selected_view");
return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2);
},
@@ -100,7 +105,7 @@ erpnext.financial_statements = {
from_date: formatting.from_date || formatting.period_start_date,
to_date: formatting.to_date || formatting.period_end_date,
account_type: formatting.account_type,
company: frappe.query_report.get_filter_value("company"),
company: get_filter_value("company"),
};
column.link_onclick =
@@ -177,7 +182,7 @@ erpnext.financial_statements = {
},
_format_special_view: function (value, row, column, data, default_formatter) {
const selectedView = frappe.query_report.get_filter_value("selected_view");
const selectedView = get_filter_value("selected_view");
if (selectedView === "Growth") {
const growthPercent = data[column.fieldname];
@@ -252,7 +257,7 @@ erpnext.financial_statements = {
frappe.route_options = {
account: data.account || data.accounts,
company: frappe.query_report.get_filter_value("company"),
company: get_filter_value("company"),
from_date: data.from_date || data.year_start_date,
to_date: data.to_date || data.year_end_date,
project: project && project.length > 0 ? project[0].get_value() : "",
@@ -305,17 +310,49 @@ erpnext.financial_statements = {
report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Balance Sheet", { company: filters.company });
frappe.set_route("query-report", "Balance Sheet", {
company: filters.company,
filter_based_on: filters.filter_based_on,
period_start_date: filters.period_start_date,
period_end_date: filters.period_end_date,
from_fiscal_year: filters.from_fiscal_year,
to_fiscal_year: filters.to_fiscal_year,
periodicity: filters.periodicity,
presentation_currency: filters.presentation_currency,
cost_center: filters.cost_center,
project: filters.project,
});
});
report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Profit and Loss Statement", { company: filters.company });
frappe.set_route("query-report", "Profit and Loss Statement", {
company: filters.company,
filter_based_on: filters.filter_based_on,
period_start_date: filters.period_start_date,
period_end_date: filters.period_end_date,
from_fiscal_year: filters.from_fiscal_year,
to_fiscal_year: filters.to_fiscal_year,
periodicity: filters.periodicity,
presentation_currency: filters.presentation_currency,
cost_center: filters.cost_center,
project: filters.project,
});
});
report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Cash Flow", { company: filters.company });
frappe.set_route("query-report", "Cash Flow", {
company: filters.company,
filter_based_on: filters.filter_based_on,
period_start_date: filters.period_start_date,
period_end_date: filters.period_end_date,
from_fiscal_year: filters.from_fiscal_year,
to_fiscal_year: filters.to_fiscal_year,
periodicity: filters.periodicity,
cost_center: filters.cost_center,
project: filters.project,
});
});
}
},
@@ -345,7 +382,7 @@ function get_filters() {
default: ["Fiscal Year"],
reqd: 1,
on_change: function () {
let filter_based_on = frappe.query_report.get_filter_value("filter_based_on");
let filter_based_on = get_filter_value("filter_based_on");
frappe.query_report.toggle_filter_display(
"from_fiscal_year",
filter_based_on === "Date Range"
@@ -422,7 +459,7 @@ function get_filters() {
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Cost Center", txt, {
company: frappe.query_report.get_filter_value("company"),
company: get_filter_value("company"),
});
},
options: "Cost Center",
@@ -433,7 +470,7 @@ function get_filters() {
fieldtype: "MultiSelectList",
get_data: function (txt) {
return frappe.db.get_link_options("Project", txt, {
company: frappe.query_report.get_filter_value("company"),
company: get_filter_value("company"),
});
},
options: "Project",

View File

@@ -138,14 +138,14 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
frappe.run_serially([
() => this.set_selector_trigger_flag(data),
() => this.set_serial_no(row, serial_no),
() => this.set_batch_no(row, batch_no),
() => this.set_barcode(row, barcode),
() => this.set_warehouse(row),
() =>
this.set_item(row, item_code, barcode, batch_no, serial_no).then((qty) => {
this.show_scan_message(row.idx, !is_new_row, qty);
}),
() => this.set_serial_no(row, serial_no),
() => this.set_batch_no(row, batch_no),
() => this.clean_up(),
() => this.set_barcode_uom(row, uom),
() => this.revert_selector_flag(),

View File

@@ -1,21 +1,18 @@
frappe.provide("erpnext.demo");
$(document).on("toolbar_setup", function () {
if (frappe.boot.sysdefaults.demo_company) {
render_clear_demo_action();
}
$(document).on("desktop_screen", function (event, data) {
data.desktop.add_menu_item({
label: __("Clear Demo Data"),
icon: "trash",
condition: function () {
return frappe.boot.sysdefaults.demo_company;
},
onClick: function () {
return erpnext.demo.clear_demo();
},
});
});
function render_clear_demo_action() {
let demo_action = $(
`<a class="dropdown-item" onclick="return erpnext.demo.clear_demo()">
${__("Clear Demo Data")}
</a>`
);
demo_action.appendTo($("#toolbar-user"));
}
erpnext.demo.clear_demo = function () {
frappe.confirm(__("Are you sure you want to clear all demo data?"), () => {
frappe.call({

View File

@@ -495,7 +495,30 @@ erpnext.sales_common = {
}
}
project() {
project(doc, cdt, cdn) {
if (!cdt || !cdn) {
if (this.frm.doc.project) {
$.each(this.frm.doc["items"] || [], function (i, item) {
if (!item.project) {
frappe.model.set_value(item.doctype, item.name, "project", doc.project);
}
});
}
} else {
const item = frappe.get_doc(cdt, cdn);
if (item.project) {
$.each(this.frm.doc["items"] || [], function (i, other_item) {
if (!other_item.project) {
frappe.model.set_value(
other_item.doctype,
other_item.name,
"project",
item.project
);
}
});
}
}
let me = this;
if (["Delivery Note", "Sales Invoice", "Sales Order"].includes(this.frm.doc.doctype)) {
if (this.frm.doc.project) {

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -117,6 +117,7 @@ class Customer(TransactionBase):
set_name_from_naming_options(frappe.get_meta(self.doctype).autoname, self)
def get_customer_name(self):
self.customer_name = self.customer_name.strip()
if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import:
count = frappe.db.sql(
"""select ifnull(MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), 0) from tabCustomer

View File

@@ -35,8 +35,7 @@ class TestPartySpecificItem(IntegrationTestCase):
items = item_query(
doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False
)
for item in items:
self.assertEqual(item[0], self.item.name)
self.assertTrue(self.item.name in flatten(items))
def test_item_query_for_supplier(self):
create_party_specific_item(
@@ -49,5 +48,14 @@ class TestPartySpecificItem(IntegrationTestCase):
items = item_query(
doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False
)
for item in items:
self.assertEqual(item[2], self.item.item_group)
self.assertTrue(self.item.item_group in flatten(items))
def flatten(lst):
result = []
for item in lst:
if isinstance(item, tuple):
result.extend(flatten(item))
else:
result.append(item)
return result

View File

@@ -3,7 +3,7 @@
"allow_auto_repeat": 1,
"allow_import": 1,
"autoname": "naming_series:",
"creation": "2013-05-24 19:29:08",
"creation": "2026-01-29 21:18:32.391385",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
@@ -1115,14 +1115,15 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"icon": "fa fa-shopping-cart",
"idx": 82,
"is_submittable": 1,
"links": [],
"modified": "2025-07-31 17:23:48.875382",
"modified": "2026-01-29 21:18:48.836168",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation",

View File

@@ -1704,7 +1704,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"grid_page_length": 50,
@@ -1712,7 +1713,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2025-10-12 12:14:29.760988",
"modified": "2026-01-29 21:23:48.362401",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",

View File

@@ -39,6 +39,7 @@
"enable_cutoff_date_on_bulk_delivery_note_creation",
"allow_zero_qty_in_quotation",
"allow_zero_qty_in_sales_order",
"set_zero_rate_for_expired_batch",
"experimental_section",
"use_legacy_js_reactivity",
"subcontracting_inward_tab",
@@ -289,6 +290,13 @@
"fieldname": "use_legacy_js_reactivity",
"fieldtype": "Check",
"label": "Use Legacy (Client side) Reactivity"
},
{
"default": "0",
"description": "If enabled, system will set incoming rate as zero for stand-alone credit notes with expired batch item.",
"fieldname": "set_zero_rate_for_expired_batch",
"fieldtype": "Check",
"label": "Set Incoming Rate as Zero for Expired Batch"
}
],
"grid_page_length": 50,
@@ -298,7 +306,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-01-21 17:28:37.027837",
"modified": "2026-01-23 00:04:33.105916",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",

View File

@@ -44,6 +44,7 @@ class SellingSettings(Document):
role_to_override_stop_action: DF.Link | None
sales_update_frequency: DF.Literal["Monthly", "Each Transaction", "Daily"]
selling_price_list: DF.Link | None
set_zero_rate_for_expired_batch: DF.Check
so_required: DF.Literal["No", "Yes"]
territory: DF.Link | None
use_legacy_js_reactivity: DF.Check

View File

@@ -182,6 +182,10 @@ def create_transaction_deletion_record(company):
transaction_deletion_record.company = company
transaction_deletion_record.process_in_single_transaction = True
transaction_deletion_record.save(ignore_permissions=True)
transaction_deletion_record.generate_to_delete_list()
transaction_deletion_record.reload()
transaction_deletion_record.submit()
transaction_deletion_record.start_deletion_tasks()

View File

@@ -1081,6 +1081,8 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No
@frappe.whitelist()
def create_transaction_deletion_request(company):
frappe.only_for("System Manager")
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
is_deletion_doc_running,
)
@@ -1088,12 +1090,16 @@ def create_transaction_deletion_request(company):
is_deletion_doc_running(company)
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
tdr.insert()
tdr.generate_to_delete_list()
tdr.reload()
tdr.submit()
tdr.start_deletion_tasks()
frappe.msgprint(
_("A Transaction Deletion Document: {0} is triggered for {0}").format(
get_link_to_form("Transaction Deletion Record", tdr.name)
),
frappe.bold(company),
_("Transaction Deletion Document {0} has been triggered for company {1}").format(
get_link_to_form("Transaction Deletion Record", tdr.name), frappe.bold(company)
)
)

View File

@@ -8,38 +8,77 @@ from frappe.tests import IntegrationTestCase
class TestTransactionDeletionRecord(IntegrationTestCase):
def setUp(self):
# Clear all deletion cache flags from previous tests
self._clear_all_deletion_cache_flags()
create_company("Dunder Mifflin Paper Co")
def tearDown(self):
# Clean up all deletion cache flags after each test
self._clear_all_deletion_cache_flags()
frappe.db.rollback()
def _clear_all_deletion_cache_flags(self):
"""Clear all deletion_running_doctype:* cache keys"""
# Get all keys matching the pattern
cache_keys = frappe.cache.get_keys("deletion_running_doctype:*")
if cache_keys:
for key in cache_keys:
# Decode bytes to string if needed
key_str = key.decode() if isinstance(key, bytes) else key
# Extract just the key name (remove site prefix if present)
# Keys are in format: site_prefix|deletion_running_doctype:DocType
if "|" in key_str:
key_name = key_str.split("|")[1]
else:
key_name = key_str
frappe.cache.delete_value(key_name)
def test_doctypes_contain_company_field(self):
tdr = create_transaction_deletion_doc("Dunder Mifflin Paper Co")
for doctype in tdr.doctypes:
contains_company = False
doctype_fields = frappe.get_meta(doctype.doctype_name).as_dict()["fields"]
for doctype_field in doctype_fields:
if doctype_field["fieldtype"] == "Link" and doctype_field["options"] == "Company":
contains_company = True
break
self.assertTrue(contains_company)
"""Test that all DocTypes in To Delete list have a valid company link field"""
tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
for doctype_row in tdr.doctypes_to_delete:
# If company_field is specified, verify it's a valid Company link field
if doctype_row.company_field:
field_found = False
doctype_fields = frappe.get_meta(doctype_row.doctype_name).as_dict()["fields"]
for doctype_field in doctype_fields:
if (
doctype_field["fieldname"] == doctype_row.company_field
and doctype_field["fieldtype"] == "Link"
and doctype_field["options"] == "Company"
):
field_found = True
break
self.assertTrue(
field_found,
f"DocType {doctype_row.doctype_name} should have company field '{doctype_row.company_field}'",
)
def test_no_of_docs_is_correct(self):
for _i in range(5):
"""Test that document counts are calculated correctly in To Delete list"""
for _ in range(5):
create_task("Dunder Mifflin Paper Co")
tdr = create_transaction_deletion_doc("Dunder Mifflin Paper Co")
tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
tdr.reload()
for doctype in tdr.doctypes:
# Check To Delete list has correct count
task_found = False
for doctype in tdr.doctypes_to_delete:
if doctype.doctype_name == "Task":
self.assertEqual(doctype.no_of_docs, 5)
self.assertEqual(doctype.document_count, 5)
task_found = True
break
self.assertTrue(task_found, "Task should be in To Delete list")
def test_deletion_is_successful(self):
"""Test that deletion actually removes documents"""
create_task("Dunder Mifflin Paper Co")
create_transaction_deletion_doc("Dunder Mifflin Paper Co")
create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co")
tasks_containing_company = frappe.get_all("Task", filters={"company": "Dunder Mifflin Paper Co"})
self.assertEqual(tasks_containing_company, [])
def test_company_transaction_deletion_request(self):
"""Test creation via company deletion request method"""
from erpnext.setup.doctype.company.company import create_transaction_deletion_request
# don't reuse below company for other test cases
@@ -49,15 +88,314 @@ class TestTransactionDeletionRecord(IntegrationTestCase):
# below call should not raise any exceptions or throw errors
create_transaction_deletion_request(company)
def test_generate_to_delete_list(self):
"""Test automatic generation of To Delete list"""
company = "Dunder Mifflin Paper Co"
create_task(company)
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.insert()
# Generate To Delete list
tdr.generate_to_delete_list()
tdr.reload()
# Should have at least Task in the list
self.assertGreater(len(tdr.doctypes_to_delete), 0)
task_in_list = any(d.doctype_name == "Task" for d in tdr.doctypes_to_delete)
self.assertTrue(task_in_list, "Task should be in To Delete list")
def test_validation_prevents_child_tables(self):
"""Test that child tables cannot be added to To Delete list"""
company = "Dunder Mifflin Paper Co"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.append("doctypes_to_delete", {"doctype_name": "Sales Invoice Item"}) # Child table
# Should throw validation error
with self.assertRaises(frappe.ValidationError):
tdr.insert()
def test_validation_prevents_protected_doctypes(self):
"""Test that protected DocTypes cannot be added to To Delete list"""
company = "Dunder Mifflin Paper Co"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.append("doctypes_to_delete", {"doctype_name": "DocType"}) # Protected
# Should throw validation error
with self.assertRaises(frappe.ValidationError):
tdr.insert()
def test_csv_export_import(self):
"""Test CSV export and import functionality with company_field column"""
company = "Dunder Mifflin Paper Co"
create_task(company)
# Create and generate To Delete list
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.insert()
tdr.generate_to_delete_list()
tdr.reload()
original_count = len(tdr.doctypes_to_delete)
self.assertGreater(original_count, 0)
# Export as CSV
tdr.export_to_delete_template_method()
csv_content = frappe.response.get("result")
self.assertIsNotNone(csv_content)
self.assertIn("doctype_name", csv_content)
self.assertIn("company_field", csv_content) # New: verify company_field column exists
# Create new record and import
tdr2 = frappe.new_doc("Transaction Deletion Record")
tdr2.company = company
tdr2.insert()
result = tdr2.import_to_delete_template_method(csv_content)
tdr2.reload()
# Should have same entries (counts may differ due to new task)
self.assertEqual(len(tdr2.doctypes_to_delete), original_count)
self.assertGreaterEqual(result["imported"], 1)
# Verify company_field values are preserved
for row in tdr2.doctypes_to_delete:
if row.doctype_name == "Task":
# Task should have company field set
self.assertIsNotNone(row.company_field, "Task should have company_field set after import")
def test_progress_tracking(self):
"""Test that deleted checkbox is marked when DocType deletion completes"""
company = "Dunder Mifflin Paper Co"
create_task(company)
tdr = create_and_submit_transaction_deletion_doc(company)
tdr.reload()
# After deletion, Task should be marked as deleted in To Delete list
# Note: Must match using composite key (doctype_name + company_field)
task_row = None
for doctype in tdr.doctypes_to_delete:
if doctype.doctype_name == "Task":
task_row = doctype
break
if task_row:
self.assertEqual(task_row.deleted, 1, "Task should be marked as deleted")
def test_composite_key_validation(self):
"""Test that duplicate (doctype_name + company_field) combinations are prevented"""
company = "Dunder Mifflin Paper Co"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"})
tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"}) # Duplicate!
# Should throw validation error for duplicate composite key
with self.assertRaises(frappe.ValidationError):
tdr.insert()
def test_same_doctype_different_company_field_allowed(self):
"""Test that same DocType can be added with different company_field values"""
company = "Dunder Mifflin Paper Co"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
# Same DocType but one with company field, one without (None)
tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"})
tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": None})
# Should NOT throw error - different company_field values are allowed
try:
tdr.insert()
self.assertEqual(
len(tdr.doctypes_to_delete),
2,
"Should allow 2 Task entries with different company_field values",
)
except frappe.ValidationError as e:
self.fail(f"Should allow same DocType with different company_field values, but got error: {e}")
def test_company_field_validation(self):
"""Test that invalid company_field values are rejected"""
company = "Dunder Mifflin Paper Co"
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
# Add Task with invalid company field
tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "nonexistent_field"})
# Should throw validation error for invalid company field
with self.assertRaises(frappe.ValidationError):
tdr.insert()
def test_get_naming_series_prefix_with_dot(self):
"""Test prefix extraction for standard dot-separated naming series"""
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
TransactionDeletionRecord,
)
# Standard patterns with dot separator
self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("TDL.####", "Task"), "TDL")
self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("PREFIX.#####", "Task"), "PREFIX")
self.assertEqual(
TransactionDeletionRecord.get_naming_series_prefix("TASK-.YYYY.-.#####", "Task"), "TASK-.YYYY.-"
)
def test_get_naming_series_prefix_with_brace(self):
"""Test prefix extraction for format patterns with brace separators"""
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
TransactionDeletionRecord,
)
# Format patterns with brace separator
self.assertEqual(
TransactionDeletionRecord.get_naming_series_prefix("QA-ACT-{#####}", "Quality Action"), "QA-ACT-"
)
self.assertEqual(
TransactionDeletionRecord.get_naming_series_prefix("PREFIX-{####}", "Task"), "PREFIX-"
)
self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("{####}", "Task"), "")
def test_get_naming_series_prefix_fallback(self):
"""Test prefix extraction fallback for patterns without standard separators"""
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
TransactionDeletionRecord,
)
# Edge case: pattern with # but no dot or brace (shouldn't happen in practice)
self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("PREFIX####", "Task"), "PREFIX")
# Edge case: pattern with no # at all
self.assertEqual(
TransactionDeletionRecord.get_naming_series_prefix("JUSTPREFIX", "Task"), "JUSTPREFIX"
)
def test_cache_flag_management(self):
"""Test that cache flags can be set and cleared correctly"""
company = "Dunder Mifflin Paper Co"
create_task(company)
tdr = frappe.new_doc("Transaction Deletion Record")
tdr.company = company
tdr.insert()
tdr.generate_to_delete_list()
tdr.reload()
# Test _set_deletion_cache
tdr._set_deletion_cache()
# Verify flag is set for Task specifically
cached_value = frappe.cache.get_value("deletion_running_doctype:Task")
self.assertEqual(cached_value, tdr.name, "Cache flag should be set for Task")
# Test _clear_deletion_cache
tdr._clear_deletion_cache()
# Verify flag is cleared
cached_value = frappe.cache.get_value("deletion_running_doctype:Task")
self.assertIsNone(cached_value, "Cache flag should be cleared for Task")
def test_check_for_running_deletion_blocks_save(self):
"""Test that check_for_running_deletion_job blocks saves when cache flag exists"""
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
check_for_running_deletion_job,
)
company = "Dunder Mifflin Paper Co"
# Manually set cache flag to simulate running deletion
frappe.cache.set_value("deletion_running_doctype:Task", "TDR-00001", expires_in_sec=60)
try:
# Try to validate a new Task
new_task = frappe.new_doc("Task")
new_task.company = company
new_task.subject = "Should be blocked"
# Should throw error when cache flag exists
with self.assertRaises(frappe.ValidationError) as context:
check_for_running_deletion_job(new_task)
error_message = str(context.exception)
self.assertIn("currently deleting", error_message)
self.assertIn("TDR-00001", error_message)
finally:
# Cleanup: clear the manually set flag
frappe.cache.delete_value("deletion_running_doctype:Task")
def test_check_for_running_deletion_allows_save_when_no_flag(self):
"""Test that documents can be saved when no deletion is running"""
company = "Dunder Mifflin Paper Co"
# Ensure no cache flag exists
frappe.cache.delete_value("deletion_running_doctype:Task")
# Try to create and save a new Task
new_task = frappe.new_doc("Task")
new_task.company = company
new_task.subject = "Should be allowed"
# Should NOT throw error when no cache flag - actually save it
try:
new_task.insert()
# Cleanup
frappe.delete_doc("Task", new_task.name)
except frappe.ValidationError as e:
self.fail(f"Should allow save when no deletion is running, but got: {e}")
def test_only_one_deletion_allowed_globally(self):
"""Test that only one deletion can be submitted at a time (global enforcement)"""
company1 = "Dunder Mifflin Paper Co"
company2 = "Sabre Corporation"
create_company(company2)
# Create and submit first deletion (but don't start it)
tdr1 = frappe.new_doc("Transaction Deletion Record")
tdr1.company = company1
tdr1.insert()
tdr1.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"})
tdr1.save()
tdr1.submit() # Status becomes "Queued"
try:
# Try to submit second deletion for different company
tdr2 = frappe.new_doc("Transaction Deletion Record")
tdr2.company = company2 # Different company!
tdr2.insert()
tdr2.append("doctypes_to_delete", {"doctype_name": "Lead", "company_field": "company"})
tdr2.save()
# Should throw error - only one deletion allowed globally
with self.assertRaises(frappe.ValidationError) as context:
tdr2.submit()
self.assertIn("already", str(context.exception).lower())
self.assertIn(tdr1.name, str(context.exception))
finally:
# Cleanup
tdr1.cancel()
def create_company(company_name):
company = frappe.get_doc({"doctype": "Company", "company_name": company_name, "default_currency": "INR"})
company.insert(ignore_if_duplicate=True)
def create_transaction_deletion_doc(company):
def create_and_submit_transaction_deletion_doc(company):
"""Create and execute a transaction deletion record"""
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
tdr.insert()
tdr.generate_to_delete_list()
tdr.reload()
tdr.process_in_single_transaction = True
tdr.submit()
tdr.start_deletion_tasks()

View File

@@ -2,13 +2,58 @@
// For license information, please see license.txt
frappe.ui.form.on("Transaction Deletion Record", {
setup: function (frm) {
// Set up query for DocTypes to exclude child tables and virtual doctypes
// Note: Same DocType can be added multiple times with different company_field values
frm.set_query("doctype_name", "doctypes_to_delete", function () {
// Build exclusion list from protected and ignored doctypes
let excluded_doctypes = ["Transaction Deletion Record"]; // Always exclude self
// Add protected doctypes (fetched in onload)
if (frm.protected_doctypes_list && frm.protected_doctypes_list.length > 0) {
excluded_doctypes = excluded_doctypes.concat(frm.protected_doctypes_list);
}
// Add doctypes from the ignore list
if (frm.doc.doctypes_to_be_ignored && frm.doc.doctypes_to_be_ignored.length > 0) {
frm.doc.doctypes_to_be_ignored.forEach((row) => {
if (row.doctype_name) {
excluded_doctypes.push(row.doctype_name);
}
});
}
let filters = [
["DocType", "istable", "=", 0], // Exclude child tables
["DocType", "is_virtual", "=", 0], // Exclude virtual doctypes
];
// Only add "not in" filter if we have items to exclude
if (excluded_doctypes.length > 0) {
filters.push(["DocType", "name", "not in", excluded_doctypes]);
}
return { filters: filters };
});
},
onload: function (frm) {
if (frm.doc.docstatus == 0) {
let doctypes_to_be_ignored_array;
// Fetch protected doctypes list for filtering
frappe.call({
method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_protected_doctypes",
callback: function (r) {
if (r.message) {
frm.protected_doctypes_list = r.message;
}
},
});
// Fetch ignored doctypes and populate table
frappe.call({
method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_doctypes_to_be_ignored",
callback: function (r) {
doctypes_to_be_ignored_array = r.message;
let doctypes_to_be_ignored_array = r.message;
populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm);
frm.refresh_field("doctypes_to_be_ignored");
},
@@ -17,20 +62,264 @@ frappe.ui.form.on("Transaction Deletion Record", {
},
refresh: function (frm) {
if (frm.doc.docstatus == 1 && ["Queued", "Failed"].find((x) => x == frm.doc.status)) {
let execute_btn = frm.doc.status == "Queued" ? __("Start Deletion") : __("Retry");
// Override submit button to show custom confirmation
if (frm.doc.docstatus === 0 && !frm.is_new()) {
frm.page.clear_primary_action();
frm.page.set_primary_action(__("Submit"), () => {
if (!frm.doc.doctypes_to_delete || frm.doc.doctypes_to_delete.length === 0) {
frappe.msgprint(__("Please generate the To Delete list before submitting"));
return;
}
frm.add_custom_button(execute_btn, () => {
// Entry point for chain of events
let message =
`<div style='margin-bottom: 15px;'><b style='color: #d73939;'>⚠ ${__(
"Warning: This action cannot be undone!"
)}</b></div>` +
`<div style='margin-bottom: 10px;'>${__(
"You are about to permanently delete data for {0} entries for company {1}.",
[`<b>${frm.doc.doctypes_to_delete.length}</b>`, `<b>${frm.doc.company}</b>`]
)}</div>` +
`<div style='margin-bottom: 10px;'><b>${__("What will be deleted:")}</b></div>` +
`<ul style='margin-left: 20px; margin-bottom: 10px;'>` +
`<li><b>${__("DocTypes with a company field:")}</b> ${__(
"Only records belonging to {0} will be deleted",
[`<b>${frm.doc.company}</b>`]
)}</li>` +
`<li><b>${__("DocTypes without a company field:")}</b> ${__(
"ALL records will be deleted (entire DocType cleared)"
)}</li>` +
`</ul>` +
`<div style='margin-bottom: 10px; padding: 10px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;'>` +
`<b style='color: #856404;'>📦 ${__(
"IMPORTANT: Create a backup before proceeding!"
)}</b>` +
`</div>` +
`<div style='margin-top: 10px;'>${__(
"Deletion will start automatically after submission."
)}</div>`;
frappe.confirm(
message,
() => {
frm.save("Submit");
},
() => {}
);
});
}
if (frm.doc.docstatus == 0) {
frm.add_custom_button(__("Generate To Delete List"), () => {
frm.call({
method: "generate_to_delete_list",
doc: frm.doc,
callback: (r) => {
frappe.show_alert({
message: __("To Delete list generated with {0} DocTypes", [r.message.count]),
indicator: "green",
});
frm.refresh();
},
});
});
if (frm.doc.doctypes_to_delete && frm.doc.doctypes_to_delete.length > 0) {
frm.add_custom_button(
__("Export"),
() => {
open_url_post(
"/api/method/erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.export_to_delete_template",
{
name: frm.doc.name,
}
);
},
__("Template")
);
frm.add_custom_button(__("Remove Zero Counts"), () => {
let removed_count = 0;
let rows_to_keep = [];
frm.doc.doctypes_to_delete.forEach((row) => {
if (row.document_count && row.document_count > 0) {
rows_to_keep.push(row);
} else {
removed_count++;
}
});
if (removed_count === 0) {
frappe.msgprint(__("No rows with zero document count found"));
return;
}
frm.doc.doctypes_to_delete = rows_to_keep;
frm.refresh_field("doctypes_to_delete");
frm.dirty();
frappe.show_alert({
message: __(
"Removed {0} rows with zero document count. Please save to persist changes.",
[removed_count]
),
indicator: "orange",
});
});
}
frm.add_custom_button(
__("Import"),
() => {
new frappe.ui.FileUploader({
doctype: "Transaction Deletion Record",
docname: frm.doc.name,
folder: "Home/Attachments",
restrictions: {
allowed_file_types: [".csv"],
},
on_success: (file_doc) => {
frappe.call({
method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.process_import_template",
args: {
transaction_deletion_record_name: frm.doc.name,
file_url: file_doc.file_url,
},
freeze: true,
freeze_message: __("Processing import..."),
callback: (r) => {
if (r.message) {
frappe.show_alert({
message: __("Imported {0} DocTypes", [r.message.imported]),
indicator: "green",
});
frappe.model.clear_doc(frm.doctype, frm.docname);
frm.reload_doc();
}
},
});
},
});
},
__("Template")
);
}
// Only show Retry button for Failed status (deletion starts automatically on submit)
if (frm.doc.docstatus == 1 && frm.doc.status == "Failed") {
frm.add_custom_button(__("Retry"), () => {
frm.call({
method: "start_deletion_tasks",
doc: frm.doc,
callback: () => {
frappe.show_alert({
message: __("Deletion process restarted"),
indicator: "blue",
});
frm.reload_doc();
},
});
});
}
},
});
frappe.ui.form.on("Transaction Deletion Record To Delete", {
doctype_name: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.doctype_name) {
// Fetch company fields for auto-selection (only if exactly 1 field exists)
frappe.call({
method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_company_link_fields",
args: {
doctype_name: row.doctype_name,
},
callback: function (r) {
if (r.message && r.message.length === 1 && !row.company_field) {
frappe.model.set_value(cdt, cdn, "company_field", r.message[0]);
} else if (r.message && r.message.length > 1) {
// Show message with available options when multiple company fields exist
frappe.show_alert({
message: __("Multiple company fields available: {0}. Please select manually.", [
r.message.join(", "),
]),
indicator: "blue",
});
}
},
});
// Auto-populate child DocTypes and document count
frm.call({
method: "populate_doctype_details",
doc: frm.doc,
args: {
doctype_name: row.doctype_name,
company: frm.doc.company,
company_field: row.company_field,
},
callback: function (r) {
if (r.message) {
if (r.message.error) {
frappe.msgprint({
title: __("Error"),
indicator: "red",
message: __("Error getting details for {0}: {1}", [
row.doctype_name,
r.message.error,
]),
});
}
frappe.model.set_value(cdt, cdn, "child_doctypes", r.message.child_doctypes || "");
frappe.model.set_value(cdt, cdn, "document_count", r.message.document_count || 0);
}
},
});
}
},
company_field: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.doctype_name && row.company_field !== undefined) {
// Check for duplicates using composite key (doctype_name + company_field)
let duplicates = frm.doc.doctypes_to_delete.filter(
(r) =>
r.doctype_name === row.doctype_name &&
r.company_field === row.company_field &&
r.name !== row.name
);
if (duplicates.length > 0) {
frappe.msgprint(
__("DocType {0} with company field '{1}' is already in the list", [
row.doctype_name,
row.company_field || __("(none)"),
])
);
frappe.model.set_value(cdt, cdn, "company_field", "");
return;
}
// Recalculate document count if company_field changes
if (row.doctype_name) {
frm.call({
method: "populate_doctype_details",
doc: frm.doc,
args: {
doctype_name: row.doctype_name,
company: frm.doc.company,
company_field: row.company_field,
},
callback: function (r) {
if (r.message && r.message.document_count !== undefined) {
frappe.model.set_value(cdt, cdn, "document_count", r.message.document_count || 0);
}
},
});
}
}
},
});
function populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm) {
if (frm.doc.doctypes_to_be_ignored.length === 0) {
var i;

View File

@@ -11,14 +11,17 @@
"status",
"error_log",
"tasks_section",
"delete_bin_data",
"delete_leads_and_addresses",
"reset_company_default_values",
"clear_notifications",
"initialize_doctypes_table",
"delete_transactions",
"delete_bin_data_status",
"delete_leads_and_addresses_status",
"column_break_tasks_1",
"reset_company_default_values_status",
"clear_notifications_status",
"column_break_tasks_2",
"initialize_doctypes_table_status",
"delete_transactions_status",
"section_break_tbej",
"doctypes",
"doctypes_to_delete",
"doctypes_to_be_ignored",
"amended_from",
"process_in_single_transaction"
@@ -33,6 +36,7 @@
"reqd": 1
},
{
"depends_on": "eval:doc.docstatus > 0 && (!doc.doctypes_to_delete || doc.doctypes_to_delete.length == 0)",
"fieldname": "doctypes",
"fieldtype": "Table",
"label": "Summary",
@@ -41,11 +45,17 @@
"read_only": 1
},
{
"fieldname": "doctypes_to_delete",
"fieldtype": "Table",
"label": "DocTypes To Delete",
"options": "Transaction Deletion Record To Delete"
},
{
"description": "DocTypes that will NOT be deleted.",
"fieldname": "doctypes_to_be_ignored",
"fieldtype": "Table",
"label": "Excluded DocTypes",
"options": "Transaction Deletion Record Item",
"read_only": 1
"options": "Transaction Deletion Record Item"
},
{
"fieldname": "amended_from",
@@ -69,56 +79,71 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.docstatus==1",
"fieldname": "tasks_section",
"fieldtype": "Section Break",
"label": "Tasks"
},
{
"default": "0",
"fieldname": "delete_bin_data",
"fieldtype": "Check",
"default": "Pending",
"fieldname": "delete_bin_data_status",
"fieldtype": "Select",
"label": "Delete Bins",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
"default": "0",
"fieldname": "delete_leads_and_addresses",
"fieldtype": "Check",
"default": "Pending",
"fieldname": "delete_leads_and_addresses_status",
"fieldtype": "Select",
"label": "Delete Leads and Addresses",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
"default": "0",
"fieldname": "clear_notifications",
"fieldtype": "Check",
"label": "Clear Notifications",
"no_copy": 1,
"read_only": 1
"fieldname": "column_break_tasks_1",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "reset_company_default_values",
"fieldtype": "Check",
"default": "Pending",
"fieldname": "reset_company_default_values_status",
"fieldtype": "Select",
"label": "Reset Company Default Values",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
"default": "0",
"fieldname": "delete_transactions",
"fieldtype": "Check",
"label": "Delete Transactions",
"default": "Pending",
"fieldname": "clear_notifications_status",
"fieldtype": "Select",
"label": "Clear Notifications",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
"default": "0",
"fieldname": "initialize_doctypes_table",
"fieldtype": "Check",
"fieldname": "column_break_tasks_2",
"fieldtype": "Column Break"
},
{
"default": "Pending",
"fieldname": "initialize_doctypes_table_status",
"fieldtype": "Select",
"label": "Initialize Summary Table",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
"default": "Pending",
"fieldname": "delete_transactions_status",
"fieldtype": "Select",
"label": "Delete Transactions",
"no_copy": 1,
"options": "Pending\nCompleted\nSkipped",
"read_only": 1
},
{
@@ -144,7 +169,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:54.828051",
"modified": "2025-11-18 15:02:46.427695",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record",
@@ -165,8 +190,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -7,6 +7,7 @@ import frappe
from frappe import _, qb
from frappe.desk.notifications import clear_notifications
from frappe.model.document import Document
from frappe.query_builder.functions import Max
from frappe.utils import cint, comma_and, create_batch, get_link_to_form
from frappe.utils.background_jobs import get_job, is_job_enqueued
from frappe.utils.caching import request_cache
@@ -19,6 +20,95 @@ LEDGER_ENTRY_DOCTYPES = frozenset(
)
)
DELETION_CACHE_TTL = 4 * 60 * 60 # 4 hours in seconds
PROTECTED_CORE_DOCTYPES = frozenset(
(
# Core Meta
"DocType",
"DocField",
"Custom Field",
"Property Setter",
"DocPerm",
"Custom DocPerm",
# User & Permissions
"User",
"Role",
"Has Role",
"User Permission",
"User Type",
# System Configuration
"Module Def",
"Workflow",
"Workflow State",
"System Settings",
# Critical System DocTypes
"File",
"Version",
"Activity Log",
"Error Log",
"Scheduled Job Type",
"Scheduled Job Log",
"Server Script",
"Client Script",
"Data Import",
"Data Export",
"Report",
"Print Format",
"Email Template",
"Assignment Rule",
"Workspace",
"Dashboard",
"Access Log",
# Transaction Deletion
"Transaction Deletion Record",
"Company",
)
)
@frappe.whitelist()
def get_protected_doctypes():
"""Get list of protected DocTypes that cannot be deleted (whitelisted for frontend)"""
frappe.only_for("System Manager")
return _get_protected_doctypes_internal()
@frappe.whitelist()
def get_company_link_fields(doctype_name):
"""Get all Company Link field names for a DocType (whitelisted for frontend autocomplete)
Args:
doctype_name: The DocType to check
Returns:
list: List of field names that link to Company DocType, ordered by field index
"""
frappe.only_for("System Manager")
if not doctype_name or not frappe.db.exists("DocType", doctype_name):
return []
return frappe.get_all(
"DocField",
filters={"parent": doctype_name, "fieldtype": "Link", "options": "Company"},
pluck="fieldname",
order_by="idx",
)
def _get_protected_doctypes_internal():
"""Internal method to get protected doctypes"""
protected = []
for doctype in PROTECTED_CORE_DOCTYPES:
if frappe.db.exists("DocType", doctype):
protected.append(doctype)
singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name")
protected.extend(singles)
return protected
class TransactionDeletionRecord(Document):
# begin: auto-generated types
@@ -35,19 +125,23 @@ class TransactionDeletionRecord(Document):
from erpnext.setup.doctype.transaction_deletion_record_item.transaction_deletion_record_item import (
TransactionDeletionRecordItem,
)
from erpnext.setup.doctype.transaction_deletion_record_to_delete.transaction_deletion_record_to_delete import (
TransactionDeletionRecordToDelete,
)
amended_from: DF.Link | None
clear_notifications: DF.Check
clear_notifications_status: DF.Literal["Pending", "Completed", "Skipped"]
company: DF.Link
delete_bin_data: DF.Check
delete_leads_and_addresses: DF.Check
delete_transactions: DF.Check
delete_bin_data_status: DF.Literal["Pending", "Completed", "Skipped"]
delete_leads_and_addresses_status: DF.Literal["Pending", "Completed", "Skipped"]
delete_transactions_status: DF.Literal["Pending", "Completed", "Skipped"]
doctypes: DF.Table[TransactionDeletionRecordDetails]
doctypes_to_be_ignored: DF.Table[TransactionDeletionRecordItem]
doctypes_to_delete: DF.Table[TransactionDeletionRecordToDelete]
error_log: DF.LongText | None
initialize_doctypes_table: DF.Check
initialize_doctypes_table_status: DF.Literal["Pending", "Completed", "Skipped"]
process_in_single_transaction: DF.Check
reset_company_default_values: DF.Check
reset_company_default_values_status: DF.Literal["Pending", "Completed", "Skipped"]
status: DF.Literal["Queued", "Running", "Failed", "Completed", "Cancelled"]
# end: auto-generated types
@@ -71,33 +165,90 @@ class TransactionDeletionRecord(Document):
def validate(self):
frappe.only_for("System Manager")
self.validate_doctypes_to_be_ignored()
self.validate_to_delete_list()
def validate_doctypes_to_be_ignored(self):
doctypes_to_be_ignored_list = get_doctypes_to_be_ignored()
for doctype in self.doctypes_to_be_ignored:
if doctype.doctype_name not in doctypes_to_be_ignored_list:
def validate_to_delete_list(self):
"""Validate To Delete list: existence, protection status, child table exclusion, duplicates"""
if not self.doctypes_to_delete:
return
protected = _get_protected_doctypes_internal()
seen_combinations = set()
for item in self.doctypes_to_delete:
if not frappe.db.exists("DocType", item.doctype_name):
frappe.throw(_("DocType {0} does not exist").format(item.doctype_name))
# Check for duplicates using composite key
composite_key = (item.doctype_name, item.company_field or None)
if composite_key in seen_combinations:
field_desc = f" with company field '{item.company_field}'" if item.company_field else ""
frappe.throw(
_(
"DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it."
),
title=_("Not Allowed"),
_("Duplicate entry: {0}{1}").format(item.doctype_name, field_desc),
title=_("Duplicate DocType"),
)
seen_combinations.add(composite_key)
# Validate protected DocTypes
if item.doctype_name in protected:
frappe.throw(
_("Cannot delete protected core DocType: {0}").format(item.doctype_name),
title=_("Protected DocType"),
)
is_child_table = frappe.db.get_value("DocType", item.doctype_name, "istable")
if is_child_table:
frappe.throw(
_(
"Cannot add child table {0} to deletion list. Child tables are automatically deleted with their parent DocTypes."
).format(item.doctype_name),
title=_("Child Table Not Allowed"),
)
is_virtual = frappe.db.get_value("DocType", item.doctype_name, "is_virtual")
if is_virtual:
frappe.throw(
_(
"Cannot delete virtual DocType: {0}. Virtual DocTypes do not have database tables."
).format(item.doctype_name),
title=_("Virtual DocType"),
)
# Validate company_field if specified
if item.company_field:
valid_company_fields = self._get_company_link_fields(item.doctype_name)
if item.company_field not in valid_company_fields:
frappe.throw(
_("Field '{0}' is not a valid Company link field for DocType {1}").format(
item.company_field, item.doctype_name
),
title=_("Invalid Company Field"),
)
def _is_any_doctype_in_deletion_list(self, doctypes_list):
"""Check if any DocType from the list is in the To Delete list"""
if not self.doctypes_to_delete:
return False
deletion_doctypes = {d.doctype_name for d in self.doctypes_to_delete}
return any(doctype in deletion_doctypes for doctype in doctypes_list)
def generate_job_name_for_task(self, task=None):
"""Generate unique job name for a specific task"""
method = self.task_to_internal_method_map[task]
return f"{self.name}_{method}"
def generate_job_name_for_next_tasks(self, task=None):
"""Generate job names for all tasks following the specified task"""
job_names = []
current_task_idx = list(self.task_to_internal_method_map).index(task)
for idx, task in enumerate(self.task_to_internal_method_map.keys(), 0):
# generate job_name for next tasks
if idx > current_task_idx:
job_names.append(self.generate_job_name_for_task(task))
return job_names
def generate_job_name_for_all_tasks(self):
"""Generate job names for all tasks in the deletion workflow"""
job_names = []
for task in self.task_to_internal_method_map.keys():
job_names.append(self.generate_job_name_for_task(task))
@@ -106,28 +257,28 @@ class TransactionDeletionRecord(Document):
def before_submit(self):
if queued_docs := frappe.db.get_all(
"Transaction Deletion Record",
filters={"company": self.company, "status": ("in", ["Running", "Queued"]), "docstatus": 1},
filters={"status": ("in", ["Running", "Queued"]), "docstatus": 1},
pluck="name",
):
frappe.throw(
_(
"Cannot enqueue multi docs for one company. {0} is already queued/running for company: {1}"
).format(
comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs]),
frappe.bold(self.company),
)
"Cannot start deletion. Another deletion {0} is already queued/running. Please wait for it to complete."
).format(comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs]))
)
if not self.doctypes_to_delete and not self.doctypes_to_be_ignored:
frappe.throw(_("Please generate To Delete list before submitting"))
if not self.doctypes_to_be_ignored:
self.populate_doctypes_to_be_ignored_table()
def reset_task_flags(self):
self.clear_notifications = 0
self.delete_bin_data = 0
self.delete_leads_and_addresses = 0
self.delete_transactions = 0
self.initialize_doctypes_table = 0
self.reset_company_default_values = 0
self.clear_notifications_status = "Pending"
self.delete_bin_data_status = "Pending"
self.delete_leads_and_addresses_status = "Pending"
self.delete_transactions_status = "Pending"
self.initialize_doctypes_table_status = "Pending"
self.reset_company_default_values_status = "Pending"
def before_save(self):
self.status = ""
@@ -136,17 +287,288 @@ class TransactionDeletionRecord(Document):
def on_submit(self):
self.db_set("status", "Queued")
self.start_deletion_tasks()
def on_cancel(self):
self.db_set("status", "Cancelled")
self._clear_deletion_cache()
def _set_deletion_cache(self):
"""Set Redis cache flags for per-doctype validation"""
for item in self.doctypes_to_delete:
frappe.cache.set_value(
f"deletion_running_doctype:{item.doctype_name}",
self.name,
expires_in_sec=DELETION_CACHE_TTL,
)
def _clear_deletion_cache(self):
"""Clear Redis cache flags"""
for item in self.doctypes_to_delete:
frappe.cache.delete_value(f"deletion_running_doctype:{item.doctype_name}")
def _get_child_tables(self, doctype_name):
"""Get list of child table DocType names for a given DocType
Args:
doctype_name: The parent DocType to check
Returns:
list: List of child table DocType names (Table field options)
"""
return frappe.get_all(
"DocField", filters={"parent": doctype_name, "fieldtype": "Table"}, pluck="options"
)
def _get_to_delete_row_infos(self, doctype_name, company_field=None, company=None):
"""Get child tables and document count for a To Delete list row
Args:
doctype_name: The DocType to get information for
company_field: Optional company field name to filter by
company: Optional company value (defaults to self.company)
Returns:
dict: {"child_doctypes": str, "document_count": int}
"""
company = company or self.company
child_tables = self._get_child_tables(doctype_name)
child_doctypes_str = ", ".join(child_tables) if child_tables else ""
if company_field and company:
doc_count = frappe.db.count(doctype_name, filters={company_field: company})
else:
doc_count = frappe.db.count(doctype_name)
return {
"child_doctypes": child_doctypes_str,
"document_count": doc_count,
}
def _has_company_field(self, doctype_name):
"""Check if DocType has a field specifically named 'company' linking to Company"""
return frappe.db.exists(
"DocField",
{"parent": doctype_name, "fieldname": "company", "fieldtype": "Link", "options": "Company"},
)
def _get_company_link_fields(self, doctype_name):
"""Get all Company Link field names for a DocType
Args:
doctype_name: The DocType to check
Returns:
list: List of field names that link to Company DocType, ordered by field index
"""
company_fields = frappe.get_all(
"DocField",
filters={"parent": doctype_name, "fieldtype": "Link", "options": "Company"},
pluck="fieldname",
order_by="idx",
)
return company_fields or []
@frappe.whitelist()
def generate_to_delete_list(self):
"""Generate To Delete list with one row per company field"""
self.doctypes_to_delete = []
excluded = [d.doctype_name for d in self.doctypes_to_be_ignored]
excluded.extend(_get_protected_doctypes_internal())
excluded.append(self.doctype) # Exclude self
# Get all DocTypes that have Company link fields
doctypes_with_company_field = frappe.get_all(
"DocField",
filters={"fieldtype": "Link", "options": "Company"},
pluck="parent",
distinct=True,
)
# Filter to get only valid DocTypes (not child tables, not virtual, not excluded)
doctypes_with_company = []
for doctype_name in doctypes_with_company_field:
if doctype_name in excluded:
continue
# Check if doctype exists and is not a child table or virtual
if frappe.db.exists("DocType", doctype_name):
meta = frappe.get_meta(doctype_name)
if not meta.istable and not meta.is_virtual:
doctypes_with_company.append(doctype_name)
for doctype_name in doctypes_with_company:
# Get ALL company fields for this DocType
company_fields = self._get_company_link_fields(doctype_name)
# Get child tables once (same for all company fields of this DocType)
child_tables = self._get_child_tables(doctype_name)
child_doctypes_str = ", ".join(child_tables) if child_tables else ""
for company_field in company_fields:
doc_count = frappe.db.count(doctype_name, {company_field: self.company})
self.append(
"doctypes_to_delete",
{
"doctype_name": doctype_name,
"company_field": company_field,
"document_count": doc_count,
"child_doctypes": child_doctypes_str,
},
)
self.save()
return {"count": len(self.doctypes_to_delete)}
@frappe.whitelist()
def populate_doctype_details(self, doctype_name, company=None, company_field=None):
"""Get child DocTypes and document count for specified DocType
Args:
doctype_name: The DocType to get details for
company: Optional company value for filtering (defaults to self.company)
company_field: Optional company field name to use for filtering
"""
frappe.only_for("System Manager")
if not doctype_name:
return {}
if not frappe.db.exists("DocType", doctype_name):
frappe.throw(_("DocType {0} does not exist").format(doctype_name))
is_child_table = frappe.db.get_value("DocType", doctype_name, "istable")
if is_child_table:
return {
"child_doctypes": "",
"document_count": 0,
"error": _("{0} is a child table and will be deleted automatically with its parent").format(
doctype_name
),
}
try:
return self._get_to_delete_row_infos(doctype_name, company_field=company_field, company=company)
except Exception as e:
frappe.log_error(
f"Error in populate_doctype_details for {doctype_name}: {e!s}", "Transaction Deletion Record"
)
return {
"child_doctypes": "",
"document_count": 0,
"error": _("Unable to fetch DocType details. Please contact system administrator."),
}
def export_to_delete_template_method(self):
"""Export To Delete list as CSV template"""
if not self.doctypes_to_delete:
frappe.throw(_("Generate To Delete list first"))
import csv
from io import StringIO
output = StringIO()
writer = csv.writer(output)
writer.writerow(["doctype_name", "company_field", "child_doctypes"])
for item in self.doctypes_to_delete:
writer.writerow([item.doctype_name, item.company_field or "", item.child_doctypes or ""])
frappe.response["result"] = output.getvalue()
frappe.response["type"] = "csv"
frappe.response[
"doctype"
] = f"deletion_template_{self.company}_{frappe.utils.now_datetime().strftime('%Y%m%d')}"
def import_to_delete_template_method(self, csv_content):
"""Import CSV template and regenerate counts"""
import csv
from io import StringIO
reader = csv.DictReader(StringIO(csv_content))
if "doctype_name" not in (reader.fieldnames or []):
frappe.throw(_("Invalid CSV format. Expected column: doctype_name"))
self.doctypes_to_delete = []
protected = _get_protected_doctypes_internal()
imported_count = 0
skipped = []
for row in reader:
doctype_name = row.get("doctype_name", "").strip()
company_field = row.get("company_field", "").strip() or None
if not doctype_name:
continue
if doctype_name in protected:
skipped.append(_("{0}: Protected DocType").format(doctype_name))
continue
if not frappe.db.exists("DocType", doctype_name):
skipped.append(_("{0}: Not found").format(doctype_name))
continue
is_child = frappe.db.get_value("DocType", doctype_name, "istable")
if is_child:
skipped.append(_("{0}: Child table (auto-deleted with parent)").format(doctype_name))
continue
is_virtual = frappe.db.get_value("DocType", doctype_name, "is_virtual")
if is_virtual:
skipped.append(_("{0}: Virtual DocType (no database table)").format(doctype_name))
continue
db_company_fields = self._get_company_link_fields(doctype_name)
import_company_field = ""
if not db_company_fields: # Case no company field exists
details = self._get_to_delete_row_infos(doctype_name)
elif (
company_field and company_field in db_company_fields
): # Case it is provided by export and valid
details = self._get_to_delete_row_infos(doctype_name, company_field)
import_company_field = company_field
else: # Company field exists but not provided by export or invalid
if "company" in db_company_fields: # Check if 'company' is a valid field
details = self._get_to_delete_row_infos(doctype_name, "company")
import_company_field = "company"
else: # Fallback to first valid company field
details = self._get_to_delete_row_infos(doctype_name, db_company_fields[0])
import_company_field = db_company_fields[0]
self.append(
"doctypes_to_delete",
{
"doctype_name": doctype_name,
"company_field": import_company_field,
"document_count": details["document_count"],
"child_doctypes": details["child_doctypes"],
},
)
imported_count += 1
self.save()
if skipped:
frappe.msgprint(
_("Skipped {0} DocType(s):<br>{1}").format(len(skipped), "<br>".join(skipped)),
title=_("Import Summary"),
indicator="orange",
)
return {"imported": imported_count, "skipped": len(skipped)}
def enqueue_task(self, task: str | None = None):
"""Enqueue a deletion task for background execution"""
if task and task in self.task_to_internal_method_map:
# make sure that none of next tasks are already running
job_names = self.generate_job_name_for_next_tasks(task=task)
self.validate_running_task_for_doc(job_names=job_names)
# Generate Job Id to uniquely identify each task for this document
job_id = self.generate_job_name_for_task(task)
if self.process_in_single_transaction:
@@ -176,12 +598,13 @@ class TransactionDeletionRecord(Document):
message = "Traceback: <br>" + traceback
frappe.db.set_value(self.doctype, self.name, "error_log", message)
frappe.db.set_value(self.doctype, self.name, "status", "Failed")
self._clear_deletion_cache()
def delete_notifications(self):
self.validate_doc_status()
if not self.clear_notifications:
if self.clear_notifications_status == "Pending":
clear_notifications()
self.db_set("clear_notifications", 1)
self.db_set("clear_notifications_status", "Completed")
self.enqueue_task(task="Initialize Summary Table")
def populate_doctypes_to_be_ignored_table(self):
@@ -215,23 +638,46 @@ class TransactionDeletionRecord(Document):
def start_deletion_tasks(self):
# This method is the entry point for the chain of events that follow
self.db_set("status", "Running")
self._set_deletion_cache()
self.enqueue_task(task="Delete Bins")
def delete_bins(self):
self.validate_doc_status()
if not self.delete_bin_data:
if self.delete_bin_data_status == "Pending":
stock_related_doctypes = [
"Item",
"Warehouse",
"Stock Entry",
"Delivery Note",
"Purchase Receipt",
"Stock Reconciliation",
"Material Request",
"Purchase Invoice",
"Sales Invoice",
]
if not self._is_any_doctype_in_deletion_list(stock_related_doctypes):
self.db_set("delete_bin_data_status", "Skipped")
self.enqueue_task(task="Delete Leads and Addresses")
return
frappe.db.sql(
"""delete from `tabBin` where warehouse in
(select name from tabWarehouse where company=%s)""",
self.company,
)
self.db_set("delete_bin_data", 1)
self.db_set("delete_bin_data_status", "Completed")
self.enqueue_task(task="Delete Leads and Addresses")
def delete_lead_addresses(self):
"""Delete addresses to which leads are linked"""
self.validate_doc_status()
if not self.delete_leads_and_addresses:
if self.delete_leads_and_addresses_status == "Pending":
if not self._is_any_doctype_in_deletion_list(["Lead"]):
self.db_set("delete_leads_and_addresses_status", "Skipped")
self.enqueue_task(task="Reset Company Values")
return
leads = frappe.db.get_all("Lead", filters={"company": self.company}, pluck="name")
addresses = []
if leads:
@@ -268,54 +714,94 @@ class TransactionDeletionRecord(Document):
customer = qb.DocType("Customer")
qb.update(customer).set(customer.lead_name, None).where(customer.lead_name.isin(leads)).run()
self.db_set("delete_leads_and_addresses", 1)
self.db_set("delete_leads_and_addresses_status", "Completed")
self.enqueue_task(task="Reset Company Values")
def reset_company_values(self):
self.validate_doc_status()
if not self.reset_company_default_values:
if self.reset_company_default_values_status == "Pending":
sales_related_doctypes = [
"Sales Order",
"Sales Invoice",
"Quotation",
"Delivery Note",
]
if not self._is_any_doctype_in_deletion_list(sales_related_doctypes):
self.db_set("reset_company_default_values_status", "Skipped")
self.enqueue_task(task="Clear Notifications")
return
company_obj = frappe.get_doc("Company", self.company)
company_obj.total_monthly_sales = 0
company_obj.sales_monthly_history = None
company_obj.save()
self.db_set("reset_company_default_values", 1)
self.db_set("reset_company_default_values_status", "Completed")
self.enqueue_task(task="Clear Notifications")
def initialize_doctypes_to_be_deleted_table(self):
"""Initialize deletion table from To Delete list or fall back to original logic"""
self.validate_doc_status()
if not self.initialize_doctypes_table:
doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
if self.initialize_doctypes_table_status == "Pending":
# Use To Delete list if available (new behavior)
if not self.doctypes_to_delete:
frappe.throw(
_("No DocTypes in To Delete list. Please generate or import the list before submitting."),
title=_("Empty To Delete List"),
)
tables = self.get_all_child_doctypes()
for docfield in docfields:
if docfield["parent"] != self.doctype:
no_of_docs = self.get_number_of_docs_linked_with_specified_company(
docfield["parent"], docfield["fieldname"]
for to_delete_item in self.doctypes_to_delete:
if to_delete_item.document_count > 0:
# Add parent DocType only - child tables are handled automatically
# by delete_child_tables() when the parent is deleted
# Use company_field directly from To Delete item
self.populate_doctypes_table(
tables, to_delete_item.doctype_name, to_delete_item.company_field, 0
)
if no_of_docs > 0:
# Initialize
self.populate_doctypes_table(tables, docfield["parent"], docfield["fieldname"], 0)
self.db_set("initialize_doctypes_table", 1)
self.db_set("initialize_doctypes_table_status", "Completed")
self.enqueue_task(task="Delete Transactions")
def delete_company_transactions(self):
self.validate_doc_status()
if not self.delete_transactions:
doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list()
self.get_doctypes_with_company_field(doctypes_to_be_ignored_list)
if self.delete_transactions_status == "Pending":
protected_doctypes = _get_protected_doctypes_internal()
self.get_all_child_doctypes()
for docfield in self.doctypes:
if docfield.doctype_name != self.doctype and not docfield.done:
no_of_docs = self.get_number_of_docs_linked_with_specified_company(
docfield.doctype_name, docfield.docfield_name
)
if no_of_docs > 0:
reference_docs = frappe.get_all(
docfield.doctype_name,
filters={docfield.docfield_name: self.company},
limit=self.batch_size,
if docfield.doctype_name in protected_doctypes:
error_msg = (
f"CRITICAL: Attempted to delete protected DocType: {docfield.doctype_name}"
)
frappe.log_error(error_msg, "Transaction Deletion Security")
frappe.throw(
_("Cannot delete protected core DocType: {0}").format(docfield.doctype_name),
title=_("Protected DocType"),
)
# Get company_field from stored value (could be any Company link field)
company_field = docfield.docfield_name
if company_field:
no_of_docs = self.get_number_of_docs_linked_with_specified_company(
docfield.doctype_name, company_field
)
else:
no_of_docs = frappe.db.count(docfield.doctype_name)
if no_of_docs > 0:
if company_field:
reference_docs = frappe.get_all(
docfield.doctype_name,
filters={company_field: self.company},
fields=["name"],
limit=self.batch_size,
)
else:
reference_docs = frappe.get_all(
docfield.doctype_name, fields=["name"], limit=self.batch_size
)
reference_doc_names = [r.name for r in reference_docs]
self.delete_version_log(docfield.doctype_name, reference_doc_names)
@@ -329,26 +815,38 @@ class TransactionDeletionRecord(Document):
processed = int(docfield.no_of_docs) + len(reference_doc_names)
frappe.db.set_value(docfield.doctype, docfield.name, "no_of_docs", processed)
else:
# reset naming series
naming_series = frappe.db.get_value("DocType", docfield.doctype_name, "autoname")
if naming_series:
if "#" in naming_series:
self.update_naming_series(naming_series, docfield.doctype_name)
frappe.db.set_value(docfield.doctype, docfield.name, "done", 1)
to_delete_row = frappe.db.get_value(
"Transaction Deletion Record To Delete",
{
"parent": self.name,
"doctype_name": docfield.doctype_name,
"company_field": company_field,
},
"name",
)
if to_delete_row:
frappe.db.set_value(
"Transaction Deletion Record To Delete", to_delete_row, "deleted", 1
)
pending_doctypes = frappe.db.get_all(
"Transaction Deletion Record Details",
filters={"parent": self.name, "done": 0},
pluck="doctype_name",
)
if pending_doctypes:
# as method is enqueued after commit, calling itself will not make validate_doc_status to throw
# recursively call this task to delete all transactions
self.enqueue_task(task="Delete Transactions")
else:
self.db_set("status", "Completed")
self.db_set("delete_transactions", 1)
self.db_set("delete_transactions_status", "Completed")
self.db_set("error_log", None)
self._clear_deletion_cache()
def get_doctypes_to_be_ignored_list(self):
doctypes_to_be_ignored_list = frappe.get_all(
@@ -378,18 +876,33 @@ class TransactionDeletionRecord(Document):
def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname):
return frappe.db.count(doctype, {company_fieldname: self.company})
def populate_doctypes_table(self, tables, doctype, fieldname, no_of_docs):
def get_company_field(self, doctype_name):
"""Get company field name for a DocType"""
return frappe.db.get_value(
"DocField",
{"parent": doctype_name, "fieldtype": "Link", "options": "Company"},
"fieldname",
)
def populate_doctypes_table(self, tables, doctype, company_field, no_of_docs):
"""Add doctype to processing tracker
Args:
tables: List of child table DocType names (to exclude)
doctype: DocType name to track
company_field: Company link field name (or None)
no_of_docs: Initial count
"""
self.flags.ignore_validate_update_after_submit = True
if doctype not in tables:
self.append(
"doctypes", {"doctype_name": doctype, "docfield_name": fieldname, "no_of_docs": no_of_docs}
"doctypes",
{"doctype_name": doctype, "docfield_name": company_field, "no_of_docs": no_of_docs},
)
self.save(ignore_permissions=True)
def delete_child_tables(self, doctype, reference_doc_names):
child_tables = frappe.get_all(
"DocField", filters={"fieldtype": "Table", "parent": doctype}, pluck="options"
)
child_tables = self._get_child_tables(doctype)
for table in child_tables:
frappe.db.delete(table, {"parent": ["in", reference_doc_names]})
@@ -397,22 +910,52 @@ class TransactionDeletionRecord(Document):
def delete_docs_linked_with_specified_company(self, doctype, reference_doc_names):
frappe.db.delete(doctype, {"name": ("in", reference_doc_names)})
def update_naming_series(self, naming_series, doctype_name):
@staticmethod
def get_naming_series_prefix(naming_series: str, doctype_name: str) -> str:
"""Extract the static prefix from an autoname pattern.
Args:
naming_series: The autoname pattern (e.g., "PREFIX.####", "format:PRE-{####}")
doctype_name: DocType name for error logging
Returns:
The static prefix before the counter placeholders
"""
if "." in naming_series:
prefix, hashes = naming_series.rsplit(".", 1)
prefix = naming_series.rsplit(".", 1)[0]
elif "{" in naming_series:
prefix = naming_series.rsplit("{", 1)[0]
else:
prefix, hashes = naming_series.rsplit("{", 1)
last = frappe.db.sql(
f"""select max(name) from `tab{doctype_name}`
where name like %s""",
prefix + "%",
# Fallback for unexpected patterns (shouldn't happen with valid Frappe naming series)
frappe.log_error(
title=_("Unexpected Naming Series Pattern"),
message=_(
"Naming series '{0}' for DocType '{1}' does not contain standard '.' or '{{' separator. Using fallback extraction."
).format(naming_series, doctype_name),
)
prefix = naming_series.split("#", 1)[0] if "#" in naming_series else naming_series
return prefix
def update_naming_series(self, naming_series, doctype_name):
# Derive a static prefix from the autoname pattern
prefix = self.get_naming_series_prefix(naming_series, doctype_name)
# Find the highest number used in the naming series to reset the counter
doctype_table = qb.DocType(doctype_name)
result = (
qb.from_(doctype_table)
.select(Max(doctype_table.name))
.where(doctype_table.name.like(prefix + "%"))
.run()
)
if last and last[0][0]:
last = cint(last[0][0].replace(prefix, ""))
if result and result[0][0]:
last = cint(result[0][0].replace(prefix, ""))
else:
last = 0
frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix))
frappe.db.set_value("Series", prefix, "current", last, update_modified=False)
def delete_version_log(self, doctype, docnames):
versions = qb.DocType("Version")
@@ -487,15 +1030,61 @@ def get_doctypes_to_be_ignored():
return doctypes_to_be_ignored
@frappe.whitelist()
def export_to_delete_template(name):
"""Export To Delete list as CSV via URL access"""
frappe.only_for("System Manager")
doc = frappe.get_doc("Transaction Deletion Record", name)
doc.check_permission("read")
return doc.export_to_delete_template_method()
@frappe.whitelist()
def process_import_template(transaction_deletion_record_name, file_url):
"""Import CSV template and populate To Delete list"""
import os
doc = frappe.get_doc("Transaction Deletion Record", transaction_deletion_record_name)
doc.check_permission("write")
if not file_url or ".." in file_url:
frappe.throw(_("Invalid file URL"))
try:
file_doc = frappe.get_doc("File", {"file_url": file_url})
except frappe.DoesNotExistError:
frappe.throw(_("File not found"))
if (
file_doc.attached_to_doctype != "Transaction Deletion Record"
or file_doc.attached_to_name != transaction_deletion_record_name
):
frappe.throw(_("File does not belong to this Transaction Deletion Record"))
if not file_doc.file_name or not file_doc.file_name.lower().endswith(".csv"):
frappe.throw(_("Only CSV files are allowed"))
file_path = file_doc.get_full_path()
if not os.path.isfile(file_path):
frappe.throw(_("File not found on server"))
with open(file_path, encoding="utf-8") as f:
csv_content = f.read()
return doc.import_to_delete_template_method(csv_content)
@frappe.whitelist()
@request_cache
def is_deletion_doc_running(company: str | None = None, err_msg: str | None = None):
if not company:
return
"""Check if any deletion is running globally
The company parameter is kept for backwards compatibility but is now ignored.
"""
running_deletion_job = frappe.db.get_value(
"Transaction Deletion Record",
{"docstatus": 1, "company": company, "status": "Running"},
{"docstatus": 1, "status": ("in", ["Running", "Queued"])},
"name",
)
@@ -504,17 +1093,28 @@ def is_deletion_doc_running(company: str | None = None, err_msg: str | None = No
frappe.throw(
title=_("Deletion in Progress!"),
msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format(
msg=_("Transaction Deletion Record {0} is already running. {1}").format(
get_link_to_form("Transaction Deletion Record", running_deletion_job), err_msg or ""
),
)
def check_for_running_deletion_job(doc, method=None):
# Check if DocType has 'company' field
if doc.doctype in LEDGER_ENTRY_DOCTYPES or not doc.meta.has_field("company"):
"""Hook function called on document validate - checks Redis cache for running deletions"""
if doc.doctype in LEDGER_ENTRY_DOCTYPES:
return
is_deletion_doc_running(
doc.company, _("Cannot make any transactions until the deletion job is completed")
)
if doc.doctype in PROTECTED_CORE_DOCTYPES:
return
deletion_name = frappe.cache.get_value(f"deletion_running_doctype:{doc.doctype}")
if deletion_name:
frappe.throw(
title=_("Deletion in Progress!"),
msg=_(
"Transaction Deletion Record {0} is currently deleting {1}. Cannot save documents until deletion completes."
).format(
get_link_to_form("Transaction Deletion Record", deletion_name), frappe.bold(doc.doctype)
),
)

View File

@@ -17,17 +17,19 @@
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:55.128861",
"modified": "2025-11-14 16:17:47.755531",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -0,0 +1,67 @@
{
"actions": [],
"creation": "2025-11-14 00:00:00",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"doctype_name",
"company_field",
"document_count",
"child_doctypes",
"deleted"
],
"fields": [
{
"fieldname": "doctype_name",
"fieldtype": "Link",
"in_list_view": 1,
"label": "DocType",
"options": "DocType"
},
{
"description": "Company link field name used for filtering (optional - leave empty to delete all records)",
"fieldname": "company_field",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Company Field"
},
{
"fieldname": "document_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Document Count",
"read_only": 1
},
{
"description": "Child tables that will also be deleted",
"fieldname": "child_doctypes",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Child DocTypes",
"read_only": 1
},
{
"default": "0",
"fieldname": "deleted",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Deleted",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-14 16:17:04.494126",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record To Delete",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -0,0 +1,27 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class TransactionDeletionRecordToDelete(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
child_doctypes: DF.SmallText | None
company_field: DF.Data | None
deleted: DF.Check
doctype_name: DF.Link | None
document_count: DF.Int
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

View File

@@ -6,14 +6,14 @@
}
},
"Algeria": {
"Algeria VAT 17%": {
"account_name": "VAT 17%",
"tax_rate": 17.00,
"Algeria TVA 19%": {
"account_name": "TVA 19%",
"tax_rate": 19.00,
"default": 1
},
"Algeria VAT 7%": {
"account_name": "VAT 7%",
"tax_rate": 7.00
"Algeria TVA 9%": {
"account_name": "TVA 9%",
"tax_rate": 9.00
}
},

View File

@@ -97,7 +97,6 @@ class DeprecatedBatchNoValuation:
for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value)
self.available_qty[ledger.batch_no] += flt(ledger.batch_qty)
self.total_qty[ledger.batch_no] += flt(ledger.batch_qty)
@deprecated(
"erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches",
@@ -269,7 +268,6 @@ class DeprecatedBatchNoValuation:
batch_data = query.run(as_dict=True)
for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty)
self.total_qty[d.batch_no] += flt(d.batch_qty)
for d in batch_data:
if self.available_qty.get(d.batch_no):
@@ -381,7 +379,6 @@ class DeprecatedBatchNoValuation:
batch_data = query.run(as_dict=True)
for d in batch_data:
self.available_qty[d.batch_no] += flt(d.batch_qty)
self.total_qty[d.batch_no] += flt(d.batch_qty)
if not self.last_sle:
return

View File

@@ -1426,14 +1426,15 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"icon": "fa fa-truck",
"idx": 146,
"is_submittable": 1,
"links": [],
"modified": "2025-12-02 23:55:25.415443",
"modified": "2026-01-29 21:24:11.781261",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note",

View File

@@ -245,8 +245,25 @@ class Item(Document):
cint(frappe.get_single_value("Stock Settings", "clean_description_html"))
and self.description != self.item_name # perf: Avoid cleaning up a fallback
):
old_desc = self.description
self.description = clean_html(self.description)
if (
old_desc
and self.description
and "<img src" in old_desc
and "<img src" not in self.description
):
frappe.msgprint(
_(
'Image in the description has been removed. To disable this behavior, uncheck "{0}" in {1}.'
).format(
frappe.get_meta("Stock Settings").get_label("clean_description_html"),
get_link_to_form("Stock Settings"),
),
alert=True,
)
def validate_customer_provided_part(self):
if self.is_customer_provided_item:
if self.is_purchase_item:

View File

@@ -1282,7 +1282,8 @@
"hidden": 1,
"label": "Item Wise Tax Details",
"no_copy": 1,
"options": "Item Wise Tax Detail"
"options": "Item Wise Tax Detail",
"print_hide": 1
}
],
"grid_page_length": 50,
@@ -1290,7 +1291,7 @@
"idx": 261,
"is_submittable": 1,
"links": [],
"modified": "2025-11-27 16:46:30.210628",
"modified": "2026-01-29 21:24:30.652933",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",

View File

@@ -4994,6 +4994,123 @@ class TestPurchaseReceipt(IntegrationTestCase):
self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]])
def test_negative_stock_error_for_purchase_return(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = make_item(
"Test Negative Stock for Purchase Return Item",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TNSFPRI.#####"},
).name
pr = make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -3),
qty=10,
rate=100,
warehouse="_Test Warehouse - _TC",
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
make_purchase_receipt(
item_code=item_code,
posting_date=add_days(today(), -4),
qty=10,
rate=100,
warehouse="_Test Warehouse - _TC",
)
make_stock_entry(
item_code=item_code,
qty=10,
source="_Test Warehouse - _TC",
target="_Test Warehouse 1 - _TC",
batch_no=batch_no,
use_serial_batch_fields=1,
)
return_pr = make_return_doc("Purchase Receipt", pr.name)
self.assertRaises(frappe.ValidationError, return_pr.submit)
def test_internal_purchase_receipt_incoming_rate_with_lcv(self):
"""
To test inter branch transaction incoming rate calculation with lcv after item reposting
"""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
item_doc = create_item("_Test Internal PR LCV Item")
lcv_expense_account = "Expenses Included In Valuation - TCP1"
from_warehouse = create_warehouse("_Test Internal From Warehouse LCV", company=company)
to_warehouse = create_warehouse("_Test Internal To Warehouse LCV", company=company)
# inward qty for internal transactions
make_purchase_receipt(
item_code=item_doc.item_code,
qty=5,
rate=100,
company="_Test Company with perpetual inventory",
warehouse=from_warehouse,
)
idn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=5,
rate=100,
warehouse=from_warehouse,
target_warehouse=to_warehouse,
)
self.assertEqual(idn.items[0].rate, 100)
ipr = make_inter_company_purchase_receipt(idn.name)
ipr.items[0].warehouse = from_warehouse
self.assertEqual(ipr.items[0].rate, 100)
ipr.submit()
self.create_lcv(ipr.doctype, ipr.name, company, lcv_expense_account, charges=100)
ipr.reload()
self.assertEqual(ipr.items[0].landed_cost_voucher_amount, 100)
self.assertEqual(ipr.items[0].valuation_rate, 120)
# repost the receipt and check the stock ledger values
repost_doc = frappe.new_doc("Repost Item Valuation")
repost_doc.update(
{
"based_on": "Transaction",
"voucher_type": ipr.doctype,
"voucher_no": ipr.name,
"posting_date": ipr.posting_date,
"posting_time": ipr.posting_time,
"company": ipr.company,
"allow_negative_stock": 1,
"via_landed_cost_voucher": 0,
"allow_zero_rate": 0,
}
)
repost_doc.save()
repost_doc.submit()
stk_ledger = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": ipr.name, "warehouse": from_warehouse},
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
# check the incoming rate and stock value change
self.assertEqual(stk_ledger.incoming_rate, 120)
self.assertEqual(stk_ledger.stock_value_difference, 600)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -575,14 +575,12 @@ class SerialandBatchBundle(Document):
d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no)))
precision = d.precision("qty")
for field in ["available_qty", "total_qty"]:
value = getattr(sn_obj, field)
available_qty = flt(value.get(d.batch_no), precision)
if self.docstatus == 1:
available_qty += flt(d.qty, precision)
available_qty = flt(sn_obj.available_qty.get(d.batch_no), precision)
if self.docstatus == 1:
available_qty += flt(d.qty, precision)
if not allow_negative_stock:
self.validate_negative_batch(d.batch_no, available_qty, field)
if not allow_negative_stock:
self.validate_negative_batch(d.batch_no, available_qty)
d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate)
@@ -595,8 +593,8 @@ class SerialandBatchBundle(Document):
}
)
def validate_negative_batch(self, batch_no, available_qty, field=None):
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty, field=field):
def validate_negative_batch(self, batch_no, available_qty):
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
msg = f"""Batch No {bold(batch_no)} of an Item {bold(self.item_code)}
has negative stock
of quantity {bold(available_qty)} in the
@@ -604,7 +602,7 @@ class SerialandBatchBundle(Document):
frappe.throw(_(msg), BatchNegativeStockError)
def is_stock_reco_for_valuation_adjustment(self, available_qty, field=None):
def is_stock_reco_for_valuation_adjustment(self, available_qty):
if (
self.voucher_type == "Stock Reconciliation"
and self.type_of_transaction == "Outward"
@@ -612,7 +610,6 @@ class SerialandBatchBundle(Document):
and (
abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty"))
== abs(available_qty)
or field == "total_qty"
)
):
return True
@@ -1343,6 +1340,7 @@ class SerialandBatchBundle(Document):
def on_submit(self):
self.validate_docstatus()
self.validate_serial_nos_inventory()
self.validate_batch_quantity()
def validate_docstatus(self):
for row in self.entries:
@@ -1436,6 +1434,106 @@ class SerialandBatchBundle(Document):
def on_cancel(self):
self.validate_voucher_no_docstatus()
self.validate_batch_quantity()
def validate_batch_quantity(self):
if not self.has_batch_no:
return
if self.type_of_transaction != "Outward" or (
self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward"
):
return
batch_wise_available_qty = self.get_batchwise_available_qty()
precision = frappe.get_precision("Serial and Batch Entry", "qty")
for d in self.entries:
available_qty = batch_wise_available_qty.get(d.batch_no, 0)
if flt(available_qty, precision) < 0:
frappe.throw(
_(
"""
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
).format(
bold(d.batch_no),
bold(self.item_code),
bold(self.warehouse),
bold(abs(flt(available_qty, precision))),
),
title=_("Negative Stock Error"),
)
def get_batchwise_available_qty(self):
available_qty = self.get_available_qty_from_sabb()
available_qty_from_ledger = self.get_available_qty_from_stock_ledger()
if not available_qty_from_ledger:
return available_qty
for batch_no, qty in available_qty_from_ledger.items():
if batch_no in available_qty:
available_qty[batch_no] += qty
else:
available_qty[batch_no] = qty
return available_qty
def get_available_qty_from_stock_ledger(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(
sle.batch_no,
Sum(sle.actual_qty).as_("available_qty"),
)
.where(
(sle.item_code == self.item_code)
& (sle.warehouse == self.warehouse)
& (sle.is_cancelled == 0)
& (sle.batch_no.isin(batches))
& (sle.docstatus == 1)
& (sle.serial_and_batch_bundle.isnull())
& (sle.batch_no.isnotnull())
)
.for_update()
.groupby(sle.batch_no)
)
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
def get_available_qty_from_sabb(self):
batches = [d.batch_no for d in self.entries if d.batch_no]
child = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(child)
.select(
child.batch_no,
Sum(child.qty).as_("available_qty"),
)
.where(
(child.item_code == self.item_code)
& (child.warehouse == self.warehouse)
& (child.is_cancelled == 0)
& (child.batch_no.isin(batches))
& (child.docstatus == 1)
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
query = query.where(child.voucher_type != "Pick List")
res = query.run(as_list=True)
return frappe._dict(res) if res else frappe._dict()
def validate_voucher_no_docstatus(self):
if self.voucher_type == "POS Invoice":

View File

@@ -261,9 +261,9 @@ frappe.ui.form.on("Shipment", {
frappe.db.get_value(
"User",
{ name: frappe.session.user },
["full_name", "last_name", "email", "phone", "mobile_no"],
["full_name", "email", "phone", "mobile_no"],
(r) => {
if (!(r.last_name && r.email && (r.phone || r.mobile_no))) {
if (!(r.full_name && r.email && (r.phone || r.mobile_no))) {
if (delivery_type == "Delivery") {
frm.set_value("delivery_company", "");
frm.set_value("delivery_contact", "");
@@ -272,9 +272,9 @@ frappe.ui.form.on("Shipment", {
frm.set_value("pickup_contact", "");
}
frappe.throw(
__("Last Name, Email or Phone/Mobile of the user are mandatory to continue.") +
__("Full Name, Email or Phone/Mobile of the user are mandatory to continue.") +
"</br>" +
__("Please first set Last Name, Email and Phone for the user") +
__("Please first set Full Name, Email and Phone for the user") +
` <a href="/app/user/${frappe.session.user}">${frappe.session.user}</a>`
);
}

View File

@@ -437,8 +437,10 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
if not ctx.uom:
if ctx.doctype in sales_doctypes:
ctx.uom = item.sales_uom if item.sales_uom else item.stock_uom
elif (ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]) or (
ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase"
elif (
(ctx.doctype in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"])
or (ctx.doctype == "Material Request" and ctx.material_request_type == "Purchase")
or (ctx.doctype == "Supplier Quotation")
):
ctx.uom = item.purchase_uom if item.purchase_uom else item.stock_uom
else:

View File

@@ -808,62 +808,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
self.available_qty[ledger.batch_no] += flt(ledger.qty)
self.total_qty[ledger.batch_no] += flt(ledger.qty)
entries = self.get_batch_stock_after_date()
for row in entries:
self.total_qty[row.batch_no] += flt(row.total_qty)
self.calculate_avg_rate_from_deprecarated_ledgers()
self.calculate_avg_rate_for_non_batchwise_valuation()
self.set_stock_value_difference()
def get_batch_stock_after_date(self) -> list[dict]:
# Get total qty of each batch no from Serial and Batch Bundle without checking time condition
if not self.batchwise_valuation_batches:
return []
child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = child.posting_datetime > self.sle.posting_datetime
if self.sle.creation:
timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & (
child.creation > self.sle.creation
)
query = (
frappe.qb.from_(child)
.select(
child.batch_no,
Sum(child.qty).as_("total_qty"),
)
.where(
(child.item_code == self.sle.item_code)
& (child.warehouse == self.sle.warehouse)
& (child.batch_no.isin(self.batchwise_valuation_batches))
& (child.docstatus == 1)
& (child.type_of_transaction.isin(["Inward", "Outward"]))
)
.for_update()
.groupby(child.batch_no)
)
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
if self.sle.voucher_detail_no:
query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no)
elif self.sle.voucher_no:
query = query.where(child.voucher_no != self.sle.voucher_no)
query = query.where(child.voucher_type != "Pick List")
if timestamp_condition:
query = query.where(timestamp_condition)
return query.run(as_dict=True)
def get_batch_stock_before_date(self) -> list[dict]:
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition
if not self.batchwise_valuation_batches:

View File

@@ -825,7 +825,6 @@ class update_entries_after:
if not self.validate_negative_stock(sle):
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
return
# Get dynamic incoming/outgoing rate
if not self.args.get("sle_id"):
self.get_dynamic_incoming_outgoing_rate(sle)
@@ -2326,6 +2325,7 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float:
For inter company transfer, incoming rate is the average of the outgoing rate
"""
rate = 0.0
lcv_rate = 0.0
field = "delivery_note_item" if sle.voucher_type == "Purchase Receipt" else "sales_invoice_item"
@@ -2340,7 +2340,15 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float:
"incoming_rate",
)
return rate
# add lcv amount in incoming_rate
lcv_amount = frappe.db.get_value(
f"{sle.voucher_type} Item", sle.voucher_detail_no, "landed_cost_voucher_amount"
)
if lcv_amount:
lcv_rate = flt(lcv_amount / abs(sle.actual_qty))
return rate + lcv_rate
def is_internal_transfer(sle):

View File

@@ -0,0 +1,287 @@
{
"app": "erpnext",
"creation": "2026-01-23 14:36:51.659571",
"docstatus": 0,
"doctype": "Workspace Sidebar",
"header_icon": "database",
"idx": 1,
"items": [
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 0,
"label": "Setup",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Cost Centers",
"link_to": "Cost Center",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Account Category",
"link_to": "Account Category",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency",
"link_to": "Currency",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange",
"link_to": "Currency Exchange",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Finance Book",
"link_to": "Finance Book",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Mode of Payment",
"link_to": "Mode of Payment",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Term",
"link_to": "Payment Term",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry Template",
"link_to": "Journal Entry Template",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Terms and Conditions",
"link_to": "Terms and Conditions",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Company",
"link_to": "Company",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fiscal Year",
"link_to": "Fiscal Year",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lock-keyhole-open",
"indent": 1,
"keep_closed": 0,
"label": "Opening & Closing",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "COA Importer",
"link_to": "Chart of Accounts Importer",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Opening Invoice Tool",
"link_to": "Opening Invoice Creation Tool",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Period",
"link_to": "Accounting Period",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "FX Revaluation",
"link_to": "Exchange Rate Revaluation",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Period Closing Voucher",
"link_to": "Period Closing Voucher",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 0,
"label": "Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounts Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange Settings",
"link_to": "Currency Exchange Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger Settings",
"link_to": "Repost Accounting Ledger Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-01-23 16:43:27.989825",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Setup",
"owner": "Administrator",
"standard": 1,
"title": "Accounts Setup"
}

View File

@@ -3,8 +3,9 @@
"creation": "2025-10-26 21:53:46.303793",
"docstatus": 0,
"doctype": "Workspace Sidebar",
"for_user": "",
"header_icon": "accounting",
"idx": 0,
"idx": 1,
"items": [
{
"child": 0,
@@ -13,7 +14,7 @@
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Accounting",
"link_to": "Invoicing",
"link_type": "Workspace",
"show_arrow": 0,
"type": "Link"
@@ -30,6 +31,18 @@
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
@@ -151,7 +164,7 @@
"collapsible": 1,
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 1,
"keep_closed": 0,
"label": "Payments",
"link_type": "DocType",
"show_arrow": 0,
@@ -261,7 +274,7 @@
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"keep_closed": 0,
"label": "Reports",
"link_type": "DocType",
"show_arrow": 0,
@@ -299,283 +312,13 @@
"link_type": "Workspace",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lock-keyhole-open",
"indent": 1,
"keep_closed": 1,
"label": "Opening & Closing",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "COA Importer",
"link_to": "Chart of Accounts Importer",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Opening Invoice Tool",
"link_to": "Opening Invoice Creation Tool",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Period",
"link_to": "Accounting Period",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "FX Revaluation",
"link_to": "Exchange Rate Revaluation",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Period Closing Voucher",
"link_to": "Period Closing Voucher",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Cost Centers",
"link_to": "Cost Center",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Account Category",
"link_to": "Account Category",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency",
"link_to": "Currency",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange",
"link_to": "Currency Exchange",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Finance Book",
"link_to": "Finance Book",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Mode of Payment",
"link_to": "Mode of Payment",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Term",
"link_to": "Payment Term",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry Template",
"link_to": "Journal Entry Template",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Terms and Conditions",
"link_to": "Terms and Conditions",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Company",
"link_to": "Company",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fiscal Year",
"link_to": "Fiscal Year",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 1,
"label": "Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounts Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange Settings",
"link_to": "Currency Exchange Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger Settings",
"link_to": "Repost Accounting Ledger Settings",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-01-10 00:06:13.234927",
"modified": "2026-01-26 21:23:15.665712",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounting",
"name": "Invoicing",
"owner": "Administrator",
"standard": 1,
"title": "Accounting"
"title": "Invoicing"
}

Some files were not shown because too many files have changed in this diff Show More