Compare commits
95 Commits
coderabbit
...
Fix-53445
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53e9d6386 | ||
|
|
a084feba96 | ||
|
|
10fe8580d5 | ||
|
|
fc8647d1da | ||
|
|
f82f1da706 | ||
|
|
6e17ccf499 | ||
|
|
40bfd08866 | ||
|
|
89f6f0f46f | ||
|
|
838d245215 | ||
|
|
b565dd3da8 | ||
|
|
e1b6ec340c | ||
|
|
c38f884095 | ||
|
|
464560a949 | ||
|
|
f7f2e73f79 | ||
|
|
646688c291 | ||
|
|
dd4fd89ef8 | ||
|
|
f0dccc3cd7 | ||
|
|
91b1df49a4 | ||
|
|
7f6f39f5e7 | ||
|
|
cdcf3fa593 | ||
|
|
e11ba21b42 | ||
|
|
1a4ecba742 | ||
|
|
4e19c7e8bd | ||
|
|
578b06e027 | ||
|
|
3d65db2ac3 | ||
|
|
fabc26bb69 | ||
|
|
27226b1d82 | ||
|
|
0dc804f9b4 | ||
|
|
3192f3f011 | ||
|
|
3c6eb9a531 | ||
|
|
8dae178728 | ||
|
|
6f9cd8c261 | ||
|
|
d6189b8101 | ||
|
|
48f4a44fb5 | ||
|
|
f0332c4dc7 | ||
|
|
ec41f1b0f5 | ||
|
|
7e9647f3f0 | ||
|
|
fb9656b975 | ||
|
|
1b6fe8498d | ||
|
|
5eeebbde7f | ||
|
|
f7abf9c1da | ||
|
|
99406ccc15 | ||
|
|
1295d7aa30 | ||
|
|
5a680d5037 | ||
|
|
7528d42187 | ||
|
|
cbdc945287 | ||
|
|
faf0dcb102 | ||
|
|
5e02b4009e | ||
|
|
8125f9035c | ||
|
|
efa3973b77 | ||
|
|
71371b0ba5 | ||
|
|
543b6e51c0 | ||
|
|
3460a7efb5 | ||
|
|
e78c750b4e | ||
|
|
d82c92a237 | ||
|
|
826cf66af8 | ||
|
|
b49c679a50 | ||
|
|
5f05714e9d | ||
|
|
37cdae2f34 | ||
|
|
3b27f49d79 | ||
|
|
525b3960e1 | ||
|
|
04cdf88715 | ||
|
|
f8f626975f | ||
|
|
31c536e33f | ||
|
|
c1fef8269a | ||
|
|
e5ba0e6401 | ||
|
|
696ea68f86 | ||
|
|
71d00f5290 | ||
|
|
0fb37ad792 | ||
|
|
88069779b2 | ||
|
|
c5a4164a6b | ||
|
|
334e8ada30 | ||
|
|
ba8eadda52 | ||
|
|
297a2ea259 | ||
|
|
e129e1438e | ||
|
|
e810cd8440 | ||
|
|
50b3396064 | ||
|
|
9322095786 | ||
|
|
8a1b8259bd | ||
|
|
d905f78984 | ||
|
|
7250ee4429 | ||
|
|
f5378b6573 | ||
|
|
c4e35f1284 | ||
|
|
6a9c0e22de | ||
|
|
809b29fe90 | ||
|
|
d68a04ad16 | ||
|
|
6954811c55 | ||
|
|
8cdc21c264 | ||
|
|
466668c6b8 | ||
|
|
e7e22c809e | ||
|
|
2606ca6fa9 | ||
|
|
54acaa2aec | ||
|
|
3f8a0a4833 | ||
|
|
9e51701e2a | ||
|
|
7c7ba0154a |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
50
erpnext/accounts/accounts_dashboard/payments/payments.json
Normal 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"
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "due_date",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
21
erpnext/desktop_icon/accounts_setup.json
Normal 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
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"modified_by": "Administrator",
|
||||
"name": "Banking",
|
||||
"owner": "Administrator",
|
||||
"parent_icon": "Accounts",
|
||||
"parent_icon": "Accounting",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
21
erpnext/desktop_icon/invoicing.json
Normal 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
|
||||
}
|
||||
22
erpnext/desktop_icon/payments.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
13087
erpnext/locale/ar.po
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
4
erpnext/public/icons/desktop_icons/solid/invoicing.svg
Normal 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 |
4
erpnext/public/icons/desktop_icons/solid/payments.svg
Normal 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 |
@@ -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 |
@@ -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 |
4
erpnext/public/icons/desktop_icons/subtle/invoicing.svg
Normal 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 |
4
erpnext/public/icons/desktop_icons/subtle/payments.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
4
erpnext/regional/address_template/templates/sweden.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
287
erpnext/workspace_sidebar/accounts_setup.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||