mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-27 05:45:19 +00:00
Merge pull request #58397 from frappe/version-16-hotfix
chore: release v16
This commit is contained in:
@@ -22,4 +22,4 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.17.0",
|
||||
"frappe-react-sdk": "^1.17.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.1",
|
||||
"jotai-family": "^1.0.2",
|
||||
"jotai": "^2.20.2",
|
||||
"jotai-family": "^1.1.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router": "^8.3.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-expr-eval": "^1.0.4",
|
||||
@@ -48,18 +48,18 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.62.1"
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -94,13 +94,14 @@
|
||||
"column_break_25",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"receivable_payable_remarks_length",
|
||||
"disable_include_dimensions",
|
||||
"column_break_lvjk",
|
||||
"general_ledger_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"receivable_payable_remarks_length",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"tab_break_dpet",
|
||||
@@ -198,10 +199,12 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
|
||||
"fieldname": "allow_stale",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Allow Stale Exchange Rates"
|
||||
"label": "Allow Stale Exchange Rates",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
@@ -277,10 +280,10 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
"label": "Prevent Sales Invoice when Customer is Overdue"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
@@ -474,7 +477,7 @@
|
||||
{
|
||||
"fieldname": "remarks_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Remarks Column Length"
|
||||
"label": "General Ledger Report"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -548,7 +551,7 @@
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounts Receivable / Payable Tuning"
|
||||
"label": "Accounts Receivable / Payable Report"
|
||||
},
|
||||
{
|
||||
"fieldname": "legacy_section",
|
||||
@@ -780,6 +783,12 @@
|
||||
"fieldname": "book_stock_expense_gl_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Stock Expense GL Entries"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_include_dimensions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable \"Consider Accounting Dimension\" Filter"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -788,7 +797,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-27 12:00:00.000000",
|
||||
"modified": "2026-08-14 15:26:49.070889",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -72,6 +72,7 @@ class AccountsSettings(Document):
|
||||
default_ageing_range: DF.Data | None
|
||||
delete_linked_ledger_entries: DF.Check
|
||||
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
|
||||
disable_include_dimensions: DF.Check
|
||||
enable_accounting_dimensions: DF.Check
|
||||
enable_common_party_accounting: DF.Check
|
||||
enable_discounts_and_margin: DF.Check
|
||||
|
||||
@@ -167,9 +167,10 @@ def get_transaction_reference(txn_data: dict) -> str:
|
||||
).strip()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
doc = frappe.get_doc("Bank Statement Import", data_import)
|
||||
doc.check_permission("write")
|
||||
|
||||
_file_doc, content = get_file(mt940_file_path)
|
||||
|
||||
@@ -234,26 +235,30 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
|
||||
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
|
||||
import_file, google_sheets_url
|
||||
)
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission()
|
||||
return bsi.get_preview_from_template(import_file, google_sheets_url)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def form_start_import(data_import):
|
||||
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
|
||||
return job_id is not None
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import)
|
||||
bsi.check_permission("write")
|
||||
return bsi.start_import()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_errored_template(data_import_name):
|
||||
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
data_import.check_permission()
|
||||
data_import.export_errored_rows()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_import_log(data_import_name):
|
||||
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
|
||||
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
|
||||
bsi.check_permission()
|
||||
return bsi.download_import_log()
|
||||
|
||||
|
||||
def is_mt940_format(content: str) -> bool:
|
||||
@@ -392,6 +397,7 @@ def get_import_status(docname):
|
||||
import_status = {}
|
||||
|
||||
data_import = frappe.get_doc("Bank Statement Import", docname)
|
||||
data_import.check_permission()
|
||||
import_status["status"] = data_import.status
|
||||
|
||||
logs = frappe.get_all(
|
||||
|
||||
@@ -275,6 +275,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
.join(overdue_payment)
|
||||
.on(overdue_payment.parent == dunning.name)
|
||||
.select(dunning.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(dunning.status == state)
|
||||
& (dunning.docstatus != 2)
|
||||
|
||||
@@ -122,6 +122,41 @@ class TestDunning(ERPNextTestSuite):
|
||||
self.assertEqual(sales_invoice.status, "Overdue")
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
|
||||
"""
|
||||
When an invoice has more than one overdue installment, its Dunning holds one
|
||||
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
|
||||
must resolve the Dunning without raising a TimestampMismatchError caused by the
|
||||
same Dunning being loaded and saved more than once.
|
||||
"""
|
||||
create_payment_terms_template_for_dunning()
|
||||
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
|
||||
sales_invoice = create_sales_invoice_against_cost_center(
|
||||
posting_date=add_days(today(), -15),
|
||||
qty=1,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
)
|
||||
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
|
||||
sales_invoice.submit()
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
|
||||
# Two overdue installments -> two overdue payment rows for the same invoice.
|
||||
self.assertEqual(len(dunning.overdue_payments), 2)
|
||||
dunning.submit()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
|
||||
pe.reference_no, pe.reference_date = "3", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
sales_invoice.reload()
|
||||
dunning.reload()
|
||||
self.assertEqual(sales_invoice.outstanding_amount, 0)
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_resolution_from_credit_note(self):
|
||||
"""
|
||||
Test that dunning is resolved when a credit note is issued against the original invoice.
|
||||
|
||||
@@ -8,6 +8,6 @@ def get_data():
|
||||
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
|
||||
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
|
||||
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
|
||||
{"label": _("Stock"), "items": ["Item Group", "Item"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
|
||||
frm.add_custom_button(
|
||||
__("Reverse Journal Entry"),
|
||||
function () {
|
||||
|
||||
@@ -1777,6 +1777,20 @@ def make_inter_company_journal_entry(name, voucher_type, company):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name, target_doc=None):
|
||||
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
|
||||
# reverses which, so read access has to be settled before they run
|
||||
if not frappe.has_permission("Journal Entry", doc=source_name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
|
||||
if reversal_of:
|
||||
frappe.throw(
|
||||
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
|
||||
get_link_to_form("Journal Entry", source_name),
|
||||
get_link_to_form("Journal Entry", reversal_of),
|
||||
)
|
||||
)
|
||||
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
|
||||
@@ -248,6 +248,27 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_disallow_reversal_of_a_reversal_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
|
||||
|
||||
rjv = make_reverse_journal_entry(jv.name)
|
||||
rjv.posting_date = nowdate()
|
||||
rjv.submit()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"is already a Reverse Journal Entry",
|
||||
make_reverse_journal_entry,
|
||||
rjv.name,
|
||||
)
|
||||
|
||||
# the guard must not disclose the reversal to a user who cannot read the entry
|
||||
frappe.set_user("Guest")
|
||||
self.addCleanup(frappe.set_user, "Administrator")
|
||||
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
|
||||
|
||||
def test_disallow_change_in_account_currency_for_a_party(self):
|
||||
# create jv in USD
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)
|
||||
|
||||
@@ -497,6 +497,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -1636,7 +1637,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -504,6 +504,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1703,7 +1704,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-05 15:40:16.519774",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -286,9 +286,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.check_conversion_rate()
|
||||
self.validate_credit_to_acc()
|
||||
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
self.validate_with_previous_doc()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
@@ -300,6 +298,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
self.validate_exchange_rate_with_purchase_receipt()
|
||||
validate_inter_company_party(
|
||||
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
|
||||
)
|
||||
@@ -323,6 +322,47 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_exchange_rate_with_purchase_receipt(self):
|
||||
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
receipts = {
|
||||
item.purchase_receipt
|
||||
for item in self.items
|
||||
if item.purchase_receipt and item.item_code in stock_items
|
||||
}
|
||||
if not receipts:
|
||||
return
|
||||
|
||||
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
|
||||
return
|
||||
|
||||
mismatched = [
|
||||
f"{frappe.bold(row.name)} ({row.conversion_rate})"
|
||||
for row in frappe.get_all(
|
||||
"Purchase Receipt",
|
||||
filters={"name": ("in", list(receipts))},
|
||||
fields=["name", "currency", "conversion_rate"],
|
||||
)
|
||||
if row.currency == self.currency
|
||||
and flt(row.conversion_rate)
|
||||
and flt(row.conversion_rate) != flt(self.conversion_rate)
|
||||
]
|
||||
if not mismatched:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
|
||||
).format(
|
||||
frappe.bold(self.conversion_rate),
|
||||
", ".join(mismatched),
|
||||
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
|
||||
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
@@ -1752,9 +1792,7 @@ class PurchaseInvoice(BuyingController):
|
||||
super().on_cancel()
|
||||
PurchaseTaxWithholding(self).on_cancel()
|
||||
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
|
||||
)
|
||||
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
|
||||
|
||||
if self.is_return and not self.update_billed_amount_in_purchase_order:
|
||||
# NOTE status updating bypassed for is_return
|
||||
|
||||
@@ -507,6 +507,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -518,25 +524,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.insert)
|
||||
|
||||
pi.conversion_rate = 70
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
@@ -544,7 +540,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
# Creating Purchase Invoice with USD currency
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.currency = "USD"
|
||||
pr.company = "_Test Company with perpetual inventory"
|
||||
@@ -558,34 +564,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
pr.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
|
||||
)
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# Createing purchase invoice against Purchase Receipt
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
pi.credit_to = "_Test Payable USD - TCP1"
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "credit"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[1].base_net_amount - pr.items[1].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
|
||||
|
||||
def test_purchase_invoice_change_naming_series(self):
|
||||
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
|
||||
pi.insert()
|
||||
|
||||
@@ -612,6 +612,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"depends_on": "customer",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -2364,7 +2365,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -7,10 +7,9 @@ def get_data():
|
||||
"non_standard_fieldnames": {
|
||||
"Tax Rule": "sales_tax_template",
|
||||
"Subscription": "sales_tax_template",
|
||||
"Restaurant": "default_tax_template",
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
|
||||
],
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: get_party_type_options(),
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("party", "");
|
||||
frappe.query_report.toggle_filter_display(
|
||||
"supplier_group",
|
||||
frappe.query_report.get_filter_value("party_type") !== "Supplier"
|
||||
);
|
||||
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
|
||||
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
|
||||
if (supplier_group_filter) {
|
||||
supplier_group_filter.df.hidden = !is_supplier;
|
||||
}
|
||||
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
|
||||
if (!is_supplier) {
|
||||
frappe.query_report.set_filter_value("supplier_group", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb, query_builder, scrub
|
||||
from frappe.database.schema import get_definition
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Date, Substring, Sum
|
||||
from frappe.utils import cint, cstr, flt, getdate, nowdate
|
||||
@@ -52,6 +53,7 @@ class ReceivablePayableReport:
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.qb_selection_filter = []
|
||||
self.ple = qb.DocType("Payment Ledger Entry")
|
||||
self.sales_person_records = None
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
@@ -92,6 +94,7 @@ class ReceivablePayableReport:
|
||||
self.party_type = get_party_types_from_account_type(self.account_type)
|
||||
self.party_details = {}
|
||||
self.invoices = set()
|
||||
self.sales_person_records = None
|
||||
self.skip_total_row = 0
|
||||
self.advance_payment_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
@@ -206,7 +209,7 @@ class ReceivablePayableReport:
|
||||
|
||||
def get_invoices(self, ple):
|
||||
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
if self.filters.get("sales_person"):
|
||||
if self.sales_person_records is not None:
|
||||
if ple.voucher_no in self.sales_person_records.get(
|
||||
"Sales Invoice", []
|
||||
) or ple.party in self.sales_person_records.get("Customer", []):
|
||||
@@ -237,7 +240,7 @@ class ReceivablePayableReport:
|
||||
]
|
||||
|
||||
def get_voucher_balance(self, ple):
|
||||
if self.filters.get("sales_person"):
|
||||
if self.sales_person_records is not None:
|
||||
if not (
|
||||
ple.party in self.sales_person_records.get("Customer", [])
|
||||
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
|
||||
@@ -896,25 +899,54 @@ class ReceivablePayableReport:
|
||||
|
||||
self.ple_query = query
|
||||
|
||||
def get_permitted_sales_persons(self, parenttype):
|
||||
if self.account_type != "Receivable":
|
||||
return None
|
||||
|
||||
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
|
||||
if not permissions:
|
||||
return None
|
||||
|
||||
return get_allowed_docs_for_doctype(permissions, parenttype)
|
||||
|
||||
def get_sales_invoices_or_customers_based_on_sales_person(self):
|
||||
parenttypes = ["Customer", "Sales Invoice"]
|
||||
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
|
||||
|
||||
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
|
||||
return
|
||||
|
||||
steam = frappe.qb.DocType("Sales Team")
|
||||
|
||||
scope = []
|
||||
for parenttype in parenttypes:
|
||||
criterion = steam.parenttype == parenttype
|
||||
if (allowed := permitted[parenttype]) is not None:
|
||||
criterion &= steam.sales_person.isin(allowed or [""])
|
||||
scope.append(criterion)
|
||||
|
||||
conditions = [Criterion.any(scope)]
|
||||
|
||||
if self.filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
|
||||
|
||||
# nosemgrep
|
||||
records = frappe.db.sql(
|
||||
"""
|
||||
select distinct parent, parenttype
|
||||
from `tabSales Team` steam
|
||||
where parenttype in ('Customer', 'Sales Invoice')
|
||||
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
|
||||
""",
|
||||
(lft, rgt),
|
||||
as_dict=1,
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
conditions.append(
|
||||
steam.sales_person.isin(
|
||||
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
|
||||
)
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
records = (
|
||||
frappe.qb.from_(steam)
|
||||
.select(steam.parent, steam.parenttype)
|
||||
.distinct()
|
||||
.where(Criterion.all(conditions))
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
self.sales_person_records = frappe._dict()
|
||||
for d in records:
|
||||
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
|
||||
|
||||
def get_invoices_based_on_sales_partner(self):
|
||||
if not self.filters.get("sales_partner"):
|
||||
|
||||
@@ -175,7 +175,7 @@ frappe.query_reports["General Ledger"] = {
|
||||
fieldname: "include_dimensions",
|
||||
label: __("Consider Accounting Dimensions"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
|
||||
},
|
||||
{
|
||||
fieldname: "disable_opening_balance_calculation",
|
||||
|
||||
@@ -510,6 +510,7 @@ class GrossProfitGenerator:
|
||||
self.average_buying_rate = {}
|
||||
self.filters = frappe._dict(filters)
|
||||
self.load_invoice_items()
|
||||
self.load_drop_ship_buying_rates()
|
||||
self.get_delivery_notes()
|
||||
|
||||
self.load_product_bundle()
|
||||
@@ -518,6 +519,7 @@ class GrossProfitGenerator:
|
||||
|
||||
self.load_non_stock_items()
|
||||
self.get_returned_invoice_items()
|
||||
self.allocate_legacy_return_items()
|
||||
self.process()
|
||||
|
||||
def process(self):
|
||||
@@ -534,6 +536,8 @@ class GrossProfitGenerator:
|
||||
base_amount = 0
|
||||
|
||||
for row in reversed(self.si_list):
|
||||
sales_invoice_item = row.item_row
|
||||
|
||||
if self.filters.get("group_by") == "Monthly":
|
||||
row.monthly = formatdate(row.posting_date, "MMM YYYY")
|
||||
|
||||
@@ -596,7 +600,7 @@ class GrossProfitGenerator:
|
||||
row.buying_rate, row.base_rate = 0.0, 0.0
|
||||
|
||||
if self.is_not_invoice_row(row):
|
||||
self.update_return_invoices(row)
|
||||
self.update_return_invoices(row, sales_invoice_item)
|
||||
|
||||
if grouped_by_invoice and row.indent == 1.0:
|
||||
buying_amount += row.buying_amount
|
||||
@@ -623,26 +627,32 @@ class GrossProfitGenerator:
|
||||
if self.grouped:
|
||||
self.get_average_rate_based_on_group_by()
|
||||
|
||||
def update_return_invoices(self, row):
|
||||
if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]:
|
||||
returned_item_rows = self.returned_invoices[row.parent][row.item_code]
|
||||
for returned_item_row in returned_item_rows:
|
||||
# returned_items 'qty' should be stateful
|
||||
if returned_item_row.qty != 0:
|
||||
if row.qty >= abs(returned_item_row.qty):
|
||||
row.qty += returned_item_row.qty
|
||||
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
|
||||
returned_item_row.qty = 0
|
||||
returned_item_row.base_amount = 0
|
||||
def update_return_invoices(self, row, sales_invoice_item):
|
||||
returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item)
|
||||
if not returned_item_rows:
|
||||
return
|
||||
|
||||
else:
|
||||
row.qty = 0
|
||||
row.base_amount = 0
|
||||
returned_item_row.qty += row.qty
|
||||
returned_item_row.base_amount += row.base_amount
|
||||
for returned_item_row in returned_item_rows:
|
||||
# returned_items 'qty' should be stateful
|
||||
if returned_item_row.qty != 0:
|
||||
if row.qty >= abs(returned_item_row.qty):
|
||||
row.qty += returned_item_row.qty
|
||||
row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
|
||||
returned_item_row.qty = 0
|
||||
returned_item_row.base_amount = 0
|
||||
|
||||
if not row.delivered_by_supplier:
|
||||
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
|
||||
else:
|
||||
returned_item_row.qty += row.qty
|
||||
returned_item_row.base_amount += row.base_amount
|
||||
row.qty = 0
|
||||
row.base_amount = 0
|
||||
|
||||
if row.delivered_by_supplier:
|
||||
buying_amount = self.get_drop_ship_buying_amount(row)
|
||||
if buying_amount is not None:
|
||||
row.buying_amount = flt(buying_amount, self.currency_precision)
|
||||
else:
|
||||
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
|
||||
|
||||
def get_average_rate_based_on_group_by(self):
|
||||
for key in list(self.grouped):
|
||||
@@ -723,7 +733,8 @@ class GrossProfitGenerator:
|
||||
returned_invoices = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
|
||||
si.name, si_item.item_code, si_item.sales_invoice_item, si_item.stock_qty as qty,
|
||||
si_item.base_net_amount as base_amount, si.return_against
|
||||
from
|
||||
`tabSales Invoice` si, `tabSales Invoice Item` si_item
|
||||
where
|
||||
@@ -737,17 +748,73 @@ class GrossProfitGenerator:
|
||||
)
|
||||
|
||||
self.returned_invoices = frappe._dict()
|
||||
self.legacy_returned_invoices = frappe._dict()
|
||||
for inv in returned_invoices:
|
||||
self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault(
|
||||
inv.item_code, []
|
||||
invoice_returns = (
|
||||
self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices
|
||||
)
|
||||
invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault(
|
||||
inv.sales_invoice_item or inv.item_code, []
|
||||
).append(inv)
|
||||
|
||||
def skip_row(self, row):
|
||||
if self.filters.get("group_by") != "Invoice":
|
||||
if not row.get(scrub(self.filters.get("group_by", ""))):
|
||||
return True
|
||||
def allocate_legacy_return_items(self):
|
||||
source_invoice_items = {}
|
||||
for row in reversed(self.si_list):
|
||||
if row.is_return or not row.parent or self.skip_row(row):
|
||||
continue
|
||||
|
||||
return False
|
||||
source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty)
|
||||
|
||||
for invoice, legacy_invoice_items in self.legacy_returned_invoices.items():
|
||||
returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict())
|
||||
for item_code, legacy_item_rows in legacy_invoice_items.items():
|
||||
targets = self.get_legacy_return_targets(
|
||||
source_invoice_items.get((invoice, item_code), {}), returned_invoice_items
|
||||
)
|
||||
for legacy_item_row in legacy_item_rows:
|
||||
self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items)
|
||||
|
||||
def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items):
|
||||
targets = []
|
||||
for item_row, qty in source_invoice_items.items():
|
||||
linked_return_qty = sum(
|
||||
flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, [])
|
||||
)
|
||||
if available_qty := max(flt(qty) + linked_return_qty, 0):
|
||||
targets.append(frappe._dict(item_row=item_row, available_qty=available_qty))
|
||||
|
||||
targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row)))
|
||||
return targets
|
||||
|
||||
def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items):
|
||||
remaining_qty = abs(flt(legacy_item_row.qty))
|
||||
remaining_base_amount = flt(legacy_item_row.base_amount)
|
||||
if not remaining_qty:
|
||||
return
|
||||
|
||||
qty_sign = -1 if legacy_item_row.qty < 0 else 1
|
||||
for target in targets:
|
||||
if not target.available_qty:
|
||||
continue
|
||||
|
||||
allocated_qty = min(target.available_qty, remaining_qty)
|
||||
allocated_item_row = frappe._dict(legacy_item_row.copy())
|
||||
allocated_item_row.qty = qty_sign * allocated_qty
|
||||
allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty
|
||||
returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row)
|
||||
|
||||
target.available_qty -= allocated_qty
|
||||
remaining_qty -= allocated_qty
|
||||
remaining_base_amount -= allocated_item_row.base_amount
|
||||
if not remaining_qty:
|
||||
break
|
||||
|
||||
def skip_row(self, row):
|
||||
group_by = self.filters.get("group_by")
|
||||
if group_by in {"Invoice", "Monthly"}:
|
||||
return False
|
||||
|
||||
return not row.get(scrub(group_by))
|
||||
|
||||
def get_buying_amount_from_product_bundle(self, row, product_bundle):
|
||||
buying_amount = 0.0
|
||||
@@ -781,28 +848,12 @@ class GrossProfitGenerator:
|
||||
# IMP NOTE
|
||||
# stock_ledger_entries should already be filtered by item_code and warehouse and
|
||||
# sorted by posting_date desc, posting_time desc
|
||||
if (
|
||||
row.delivered_by_supplier
|
||||
and row.so_detail
|
||||
and (
|
||||
po_details := frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={"sales_order_item": row.so_detail, "docstatus": 1},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
):
|
||||
from frappe.query_builder.functions import Sum
|
||||
if row.delivered_by_supplier:
|
||||
buying_amount = self.get_drop_ship_buying_amount(row)
|
||||
if buying_amount is not None:
|
||||
return buying_amount
|
||||
|
||||
table = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.qty * table.base_net_rate))
|
||||
.where((table.po_detail.isin(po_details)) & (table.docstatus == 1))
|
||||
)
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
elif item_code in self.non_stock_items and (row.project or row.cost_center):
|
||||
if item_code in self.non_stock_items and (row.project or row.cost_center):
|
||||
# Issue 6089-Get last purchasing rate for non-stock item
|
||||
item_rate = self.get_last_purchase_rate(item_code, row)
|
||||
return flt(row.qty) * item_rate
|
||||
@@ -833,6 +884,49 @@ class GrossProfitGenerator:
|
||||
|
||||
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
|
||||
|
||||
def load_drop_ship_buying_rates(self):
|
||||
self.drop_ship_buying_rates = {}
|
||||
sales_order_items = {
|
||||
row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail
|
||||
}
|
||||
if not sales_order_items:
|
||||
return
|
||||
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
purchase_order_item = frappe.qb.DocType("Purchase Order Item")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
buying_amounts = (
|
||||
frappe.qb.from_(purchase_order_item)
|
||||
.left_join(purchase_invoice_item)
|
||||
.on(
|
||||
(purchase_invoice_item.po_detail == purchase_order_item.name)
|
||||
& (purchase_invoice_item.docstatus == 1)
|
||||
)
|
||||
.select(
|
||||
purchase_order_item.sales_order_item,
|
||||
Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"),
|
||||
Sum(purchase_invoice_item.stock_qty).as_("stock_qty"),
|
||||
)
|
||||
.where(
|
||||
(purchase_order_item.sales_order_item.isin(sales_order_items))
|
||||
& (purchase_order_item.docstatus == 1)
|
||||
)
|
||||
.groupby(purchase_order_item.sales_order_item)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for row in buying_amounts:
|
||||
self.drop_ship_buying_rates[row.sales_order_item] = (
|
||||
flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0
|
||||
)
|
||||
|
||||
def get_drop_ship_buying_amount(self, row):
|
||||
if row.so_detail not in self.drop_ship_buying_rates:
|
||||
return
|
||||
|
||||
return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail]
|
||||
|
||||
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
|
||||
from frappe.query_builder.functions import Avg
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import execute
|
||||
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
@@ -676,19 +676,9 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertEqual(total[8], 0.0) # gross profit %
|
||||
|
||||
def test_drop_ship(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
|
||||
so = make_sales_order(item=item.name, qty=10, rate=100)
|
||||
po = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
|
||||
po.items[0].rate = 80
|
||||
po.supplier = "_Test Supplier"
|
||||
po.submit()
|
||||
make_purchase_invoice(po.name).submit()
|
||||
so = self.create_drop_ship_order()
|
||||
si = make_sales_invoice(so.name).submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
@@ -700,6 +690,357 @@ class TestGrossProfit(ERPNextTestSuite):
|
||||
self.assertIsNone(data[1].buying_rate)
|
||||
self.assertEqual(data[1]["gross_profit_%"], 20)
|
||||
|
||||
def test_drop_ship_partial_billing_and_return(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
|
||||
so = self.create_drop_ship_order()
|
||||
first_invoice = make_sales_invoice(so.name)
|
||||
first_invoice.items[0].qty = 4
|
||||
first_invoice.submit()
|
||||
second_invoice = make_sales_invoice(so.name).submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=first_invoice.company,
|
||||
from_date=first_invoice.posting_date,
|
||||
to_date=first_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = {
|
||||
row.parent_invoice: row
|
||||
for row in data
|
||||
if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1
|
||||
}
|
||||
self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320)
|
||||
self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480)
|
||||
|
||||
sales_return = make_sales_return(first_invoice.name)
|
||||
sales_return.items[0].qty = -2
|
||||
sales_return.submit()
|
||||
|
||||
_, data = execute(filters=filters)
|
||||
first_invoice_row = next(
|
||||
row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(first_invoice_row.qty, 2)
|
||||
self.assertEqual(first_invoice_row.buying_amount, 160)
|
||||
self.assertEqual(first_invoice_row.gross_profit, 40)
|
||||
|
||||
def test_drop_ship_return_matches_sales_invoice_item(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(
|
||||
"_Test Drop Ship Consolidated Return Item",
|
||||
properties={"is_stock_item": 1, "delivered_by_supplier": 1},
|
||||
)
|
||||
sales_orders = []
|
||||
for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]:
|
||||
sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
|
||||
sales_order.items[0].delivered_by_supplier = 1
|
||||
sales_order.items[0].supplier = "_Test Supplier"
|
||||
sales_order.submit()
|
||||
sales_orders.append(sales_order)
|
||||
|
||||
purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]])[0]
|
||||
purchase_order.items[0].rate = buying_rate
|
||||
purchase_order.supplier = "_Test Supplier"
|
||||
purchase_order.submit()
|
||||
make_purchase_invoice(purchase_order.name).submit()
|
||||
|
||||
sales_invoice = make_sales_invoice(sales_orders[0].name)
|
||||
sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit()
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.set("items", [sales_return.items[0]])
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
|
||||
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
|
||||
self.assertEqual([row.qty for row in invoice_rows], [3, 6])
|
||||
self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480])
|
||||
|
||||
def test_return_matches_sales_invoice_item_for_delivery_note(self):
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=self.item,
|
||||
target=self.warehouse,
|
||||
qty=4,
|
||||
basic_rate=50,
|
||||
)
|
||||
delivery_note = self.create_delivery_note(qty=4, rate=100)
|
||||
sales_invoice = make_sales_invoice(delivery_note.name).submit()
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 3)
|
||||
self.assertEqual(invoice_row.selling_amount, 300)
|
||||
|
||||
def test_return_combines_linked_and_legacy_item_buckets(self):
|
||||
sales_invoice = self.create_sales_invoice(qty=4, rate=100)
|
||||
linked_return = make_sales_return(sales_invoice.name)
|
||||
linked_return.items[0].qty = -1
|
||||
linked_return.submit()
|
||||
|
||||
legacy_return = make_sales_return(sales_invoice.name)
|
||||
legacy_return.items[0].qty = -1
|
||||
legacy_return.submit()
|
||||
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 2)
|
||||
self.assertEqual(invoice_row.selling_amount, 200)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
|
||||
def test_legacy_return_prefers_item_without_linked_return(self):
|
||||
sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True)
|
||||
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
|
||||
second_item.rate = 200
|
||||
sales_invoice.append("items", second_item)
|
||||
sales_invoice.submit()
|
||||
|
||||
linked_return = make_sales_return(sales_invoice.name)
|
||||
linked_return.set("items", [linked_return.items[0]])
|
||||
linked_return.items[0].qty = -1
|
||||
linked_return.submit()
|
||||
|
||||
legacy_return = make_sales_return(sales_invoice.name)
|
||||
legacy_return.set("items", [legacy_return.items[1]])
|
||||
legacy_return.items[0].qty = -1
|
||||
legacy_return.submit()
|
||||
frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
|
||||
invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
|
||||
self.assertEqual([row.qty for row in invoice_rows], [1, 1])
|
||||
self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200])
|
||||
|
||||
def test_legacy_return_remainder_spills_into_linked_item(self):
|
||||
invoice = "SINV-TEST-RETURN-ALLOCATION"
|
||||
linked_item = "SINV-ITEM-LINKED"
|
||||
unlinked_item = "SINV-ITEM-LEGACY"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Invoice")
|
||||
generator.returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})}
|
||||
)
|
||||
linked_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=linked_item,
|
||||
is_return=False,
|
||||
qty=3,
|
||||
base_amount=300,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
unlinked_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=unlinked_item,
|
||||
is_return=False,
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
|
||||
generator.si_list = [unlinked_row, linked_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(linked_row, linked_item)
|
||||
generator.update_return_invoices(unlinked_row, unlinked_item)
|
||||
|
||||
self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100))
|
||||
self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0))
|
||||
|
||||
def test_legacy_return_ignores_skipped_group_rows(self):
|
||||
invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION"
|
||||
visible_item = "SINV-ITEM-WITH-PROJECT"
|
||||
skipped_item = "SINV-ITEM-WITHOUT-PROJECT"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Project")
|
||||
generator.returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
visible_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=visible_item,
|
||||
is_return=False,
|
||||
project="_Test Project",
|
||||
qty=2,
|
||||
base_amount=200,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
skipped_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=skipped_item,
|
||||
is_return=False,
|
||||
project=None,
|
||||
qty=1,
|
||||
)
|
||||
|
||||
generator.si_list = [visible_row, skipped_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(visible_row, visible_item)
|
||||
|
||||
self.assertNotIn(skipped_item, generator.returned_invoices[invoice])
|
||||
self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0))
|
||||
|
||||
def test_monthly_group_allocates_legacy_return(self):
|
||||
invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION"
|
||||
item_row = "SINV-ITEM-MONTHLY-RETURN"
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.filters = frappe._dict(group_by="Monthly")
|
||||
generator.returned_invoices = frappe._dict()
|
||||
generator.legacy_returned_invoices = frappe._dict(
|
||||
{invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
|
||||
)
|
||||
invoice_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
item_row=item_row,
|
||||
is_return=False,
|
||||
posting_date=nowdate(),
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
|
||||
generator.si_list = [invoice_row]
|
||||
generator.allocate_legacy_return_items()
|
||||
generator.update_return_invoices(invoice_row, item_row)
|
||||
|
||||
self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0))
|
||||
|
||||
def test_return_remainder_stays_available_for_next_row(self):
|
||||
invoice = "SINV-TEST-RETURN-REMAINDER"
|
||||
item_row = "SINV-ITEM-RETURN-REMAINDER"
|
||||
returned_item = frappe._dict(qty=-2, base_amount=-200)
|
||||
generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
|
||||
generator.currency_precision = 3
|
||||
generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})})
|
||||
first_row = frappe._dict(
|
||||
parent=invoice,
|
||||
item_code=self.item,
|
||||
qty=1,
|
||||
base_amount=100,
|
||||
buying_rate=50,
|
||||
delivered_by_supplier=False,
|
||||
)
|
||||
second_row = first_row.copy()
|
||||
|
||||
generator.update_return_invoices(first_row, item_row)
|
||||
self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100))
|
||||
|
||||
generator.update_return_invoices(second_row, item_row)
|
||||
self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0))
|
||||
self.assertEqual((first_row.qty, second_row.qty), (0, 0))
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
|
||||
def test_return_keeps_buying_amount_of_unreturned_row(self):
|
||||
unreturned_item = create_item(
|
||||
"_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company
|
||||
)
|
||||
make_stock_entry(
|
||||
company=self.company,
|
||||
item_code=unreturned_item.name,
|
||||
target=self.warehouse,
|
||||
qty=40000,
|
||||
basic_rate=33.33333,
|
||||
)
|
||||
sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
|
||||
second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
|
||||
second_item.item_code = unreturned_item.name
|
||||
second_item.item_name = unreturned_item.name
|
||||
second_item.qty = 30000
|
||||
sales_invoice.append("items", second_item)
|
||||
sales_invoice.submit()
|
||||
|
||||
sales_return = make_sales_return(sales_invoice.name)
|
||||
sales_return.set("items", [sales_return.items[0]])
|
||||
sales_return.items[0].qty = -1
|
||||
sales_return.submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=sales_invoice.company,
|
||||
from_date=sales_invoice.posting_date,
|
||||
to_date=sales_invoice.posting_date,
|
||||
group_by="Invoice",
|
||||
)
|
||||
_, data = execute(filters=filters)
|
||||
invoice_row = next(
|
||||
row
|
||||
for row in data
|
||||
if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name
|
||||
)
|
||||
self.assertEqual(invoice_row.qty, 30000)
|
||||
self.assertEqual(invoice_row.buying_amount, 999999.9)
|
||||
|
||||
def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
|
||||
so = make_sales_order(item=item.name, qty=qty, rate=selling_rate)
|
||||
purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
|
||||
purchase_order.items[0].rate = buying_rate
|
||||
purchase_order.supplier = "_Test Supplier"
|
||||
purchase_order.submit()
|
||||
make_purchase_invoice(purchase_order.name).submit()
|
||||
|
||||
return so
|
||||
|
||||
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
|
||||
"""Create a rate adjustment debit note with no stock movement."""
|
||||
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.party import get_party_shipping_address
|
||||
from erpnext.accounts.utils import (
|
||||
get_currency_precision,
|
||||
get_future_stock_vouchers,
|
||||
get_voucherwise_gl_entries,
|
||||
get_zero_cutoff,
|
||||
@@ -155,3 +156,18 @@ class TestUtils(ERPNextTestSuite):
|
||||
self.assertEqual(get_zero_cutoff(None), 0.005)
|
||||
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
|
||||
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
|
||||
|
||||
def test_get_currency_precision_respects_zero_and_fallback(self):
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
number_format = frappe.db.get_default("number_format")
|
||||
|
||||
try:
|
||||
frappe.db.set_default("number_format", "#,###.##")
|
||||
frappe.db.set_default("currency_precision", "0")
|
||||
self.assertEqual(get_currency_precision(), 0)
|
||||
|
||||
frappe.db.set_default("currency_precision", "")
|
||||
self.assertEqual(get_currency_precision(), 2)
|
||||
finally:
|
||||
frappe.db.set_default("currency_precision", currency_precision or "")
|
||||
frappe.db.set_default("number_format", number_format or "#,###.##")
|
||||
|
||||
@@ -1189,12 +1189,12 @@ def fix_total_debit_credit():
|
||||
|
||||
|
||||
def get_currency_precision():
|
||||
precision = cint(frappe.db.get_default("currency_precision"))
|
||||
if not precision:
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
precision = get_number_format_info(number_format)[2]
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
if currency_precision not in (None, ""):
|
||||
return cint(currency_precision)
|
||||
|
||||
return precision
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
return get_number_format_info(number_format)[2]
|
||||
|
||||
|
||||
def get_fraction_units(currency: str) -> int:
|
||||
|
||||
@@ -158,6 +158,8 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
for d in self.asset_items:
|
||||
args = self.as_dict()
|
||||
args.update(d.as_dict())
|
||||
@@ -179,6 +181,30 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
def split_valuation_rate_for_grouped_stock_items(self):
|
||||
groups = {}
|
||||
for d in self.stock_items:
|
||||
if d.item_code and d.warehouse and not (d.serial_no or d.batch_no or d.serial_and_batch_bundle):
|
||||
groups.setdefault((d.item_code, d.warehouse), []).append(d)
|
||||
|
||||
for rows in groups.values():
|
||||
if len(rows) < 2:
|
||||
continue
|
||||
|
||||
cumulative_qty = 0.0
|
||||
prev_cumulative_value = 0.0
|
||||
for d in rows:
|
||||
cumulative_qty += flt(d.stock_qty)
|
||||
args = self.get_args_for_incoming_rate(d)
|
||||
args["qty"] = -1 * cumulative_qty
|
||||
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
|
||||
cumulative_value = cumulative_rate * cumulative_qty
|
||||
|
||||
row_value = cumulative_value - prev_cumulative_value
|
||||
d.valuation_rate = flt(row_value / d.stock_qty) if flt(d.stock_qty) else 0.0
|
||||
d.amount = flt(flt(d.stock_qty) * d.valuation_rate, d.precision("amount"))
|
||||
prev_cumulative_value = cumulative_value
|
||||
|
||||
def validate_target_item(self):
|
||||
target_item = frappe.get_cached_doc("Item", self.target_item_code)
|
||||
|
||||
@@ -312,6 +338,8 @@ class AssetCapitalization(StockController):
|
||||
warehouse_details = get_warehouse_details(args)
|
||||
d.update(warehouse_details)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_asset_values(self):
|
||||
for d in self.get("asset_items"):
|
||||
|
||||
@@ -400,6 +400,33 @@ class TestAssetCapitalization(ERPNextTestSuite):
|
||||
actual_gle = get_actual_gle_dict(asset_capitalization.name)
|
||||
self.assertEqual(actual_gle, {})
|
||||
|
||||
def test_grouped_stock_item_rows_split_fifo_rate(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
|
||||
item = create_item(
|
||||
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
|
||||
)
|
||||
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
|
||||
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
|
||||
|
||||
asset_capitalization = frappe.new_doc("Asset Capitalization")
|
||||
asset_capitalization.company = company
|
||||
asset_capitalization.target_item_code = target_item.name
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.insert()
|
||||
|
||||
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
|
||||
self.assertEqual(rates, [100, 200])
|
||||
|
||||
|
||||
def create_asset_capitalization_data():
|
||||
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)
|
||||
|
||||
@@ -388,6 +388,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1323,7 +1324,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:34:19.659621",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -9,13 +9,18 @@ from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Order
|
||||
from frappe.utils import get_url
|
||||
from frappe.utils.print_format import download_pdf
|
||||
from frappe.utils.user import get_user_fullname
|
||||
|
||||
from erpnext.accounts.party import _get_party_details, get_party_account_currency
|
||||
from erpnext.accounts.party import (
|
||||
_get_party_details,
|
||||
get_party_account_currency,
|
||||
validate_party_frozen_disabled,
|
||||
)
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.material_request.material_request import set_missing_values
|
||||
@@ -126,6 +131,8 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
def validate_supplier_list(self):
|
||||
for d in self.suppliers:
|
||||
validate_party_frozen_disabled(self.company, "Supplier", d.supplier)
|
||||
|
||||
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
|
||||
if prevent_rfqs:
|
||||
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")
|
||||
@@ -483,36 +490,73 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
|
||||
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc):
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
supplier = doc.get("supplier")
|
||||
|
||||
if frappe.session.user not in frappe.get_all(
|
||||
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
|
||||
):
|
||||
if frappe.session.user not in frappe.get_all("Portal User", {"parent": supplier}, pluck="user"):
|
||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||
|
||||
try:
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": doc.get("supplier"),
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
|
||||
}
|
||||
validate_existing_supplier_quotation(supplier, doc.get("items"))
|
||||
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": supplier,
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", supplier, doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
|
||||
}
|
||||
)
|
||||
add_items(sq_doc, supplier, doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
|
||||
|
||||
def validate_existing_supplier_quotation(supplier, items):
|
||||
request_for_quotations = {item.get("parent") for item in items if item.get("parent")}
|
||||
if not request_for_quotations:
|
||||
return
|
||||
|
||||
rfq = frappe.qb.DocType("Request for Quotation")
|
||||
(
|
||||
frappe.qb.from_(rfq)
|
||||
.select(rfq.name)
|
||||
.where(rfq.name.isin(request_for_quotations))
|
||||
.orderby(rfq.name)
|
||||
.for_update()
|
||||
).run()
|
||||
|
||||
sq = frappe.qb.DocType("Supplier Quotation")
|
||||
sqi = frappe.qb.DocType("Supplier Quotation Item")
|
||||
existing_quotation = (
|
||||
frappe.qb.from_(sq)
|
||||
.inner_join(sqi)
|
||||
.on(sq.name == sqi.parent)
|
||||
.select(sq.name, sqi.request_for_quotation)
|
||||
.where(
|
||||
(sq.docstatus < 2)
|
||||
& (sq.supplier == supplier)
|
||||
& (sqi.request_for_quotation.isin(request_for_quotations))
|
||||
)
|
||||
.limit(1)
|
||||
).run(as_dict=True)
|
||||
|
||||
if existing_quotation:
|
||||
existing_quotation = existing_quotation[0]
|
||||
frappe.throw(
|
||||
_("Supplier Quotation {0} already exists against Request for Quotation {1}").format(
|
||||
frappe.bold(existing_quotation.name),
|
||||
frappe.bold(existing_quotation.request_for_quotation),
|
||||
)
|
||||
)
|
||||
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def add_items(sq_doc, supplier, items):
|
||||
|
||||
@@ -17,6 +17,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -58,6 +59,17 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
|
||||
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
|
||||
|
||||
def test_rfq_blocked_for_disabled_supplier(self):
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
|
||||
rfq = make_request_for_quotation(
|
||||
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(PartyDisabled, rfq.save)
|
||||
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
|
||||
rfq.save()
|
||||
|
||||
def test_make_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation()
|
||||
|
||||
@@ -150,6 +162,18 @@ class TestRequestforQuotation(ERPNextTestSuite):
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].qty, 5)
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].amount, 500)
|
||||
|
||||
def test_make_duplicate_supplier_quotation_from_portal(self):
|
||||
rfq = make_request_for_quotation()
|
||||
rfq.supplier = rfq.suppliers[0].supplier
|
||||
supplier_quotation = frappe.get_doc("Supplier Quotation", create_supplier_quotation(rfq))
|
||||
supplier_quotation.submit()
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "already exists"):
|
||||
create_supplier_quotation(rfq)
|
||||
|
||||
supplier_quotation.cancel()
|
||||
self.assertTrue(create_supplier_quotation(rfq))
|
||||
|
||||
def test_make_multi_uom_supplier_quotation(self):
|
||||
item_code = "_Test Multi UOM RFQ Item"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Supplier",
|
||||
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
|
||||
"options": "Supplier",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -947,7 +948,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:29:37.509487",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -85,6 +85,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
|
||||
],
|
||||
default: __("Categorize by Supplier"),
|
||||
},
|
||||
{
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: "", value: "" },
|
||||
{ label: __("Draft"), value: "Draft" },
|
||||
{ label: __("Submitted"), value: "Submitted" },
|
||||
],
|
||||
default: "Submitted",
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: __("Include Expired"),
|
||||
|
||||
@@ -58,13 +58,20 @@ def get_data(filters):
|
||||
)
|
||||
.where(
|
||||
(sq_item.parent == sq.name)
|
||||
& (sq_item.docstatus < 2)
|
||||
& (sq.company == filters.get("company"))
|
||||
& (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
|
||||
)
|
||||
.orderby(sq.transaction_date, sq_item.item_code)
|
||||
)
|
||||
|
||||
# blank -> Draft + Submitted, else filter to the chosen docstatus
|
||||
if filters.get("status") == "Draft":
|
||||
query = query.where(sq_item.docstatus == 0)
|
||||
elif filters.get("status") == "Submitted":
|
||||
query = query.where(sq_item.docstatus == 1)
|
||||
else:
|
||||
query = query.where(sq_item.docstatus < 2)
|
||||
|
||||
if filters.get("item_code"):
|
||||
query = query.where(sq_item.item_code == filters.get("item_code"))
|
||||
|
||||
|
||||
@@ -738,6 +738,8 @@ class AccountsController(TransactionBase):
|
||||
self.validate_non_invoice_documents_schedule()
|
||||
|
||||
def before_print(self, settings=None):
|
||||
self.set_missing_terms()
|
||||
|
||||
if self.doctype in [
|
||||
"Purchase Order",
|
||||
"Sales Order",
|
||||
@@ -761,6 +763,16 @@ class AccountsController(TransactionBase):
|
||||
set_print_templates_for_item_table(self, settings)
|
||||
set_print_templates_for_taxes(self, settings)
|
||||
|
||||
def set_missing_terms(self):
|
||||
if not self.get("tc_name") or self.get("terms"):
|
||||
return
|
||||
|
||||
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
|
||||
get_terms_and_conditions,
|
||||
)
|
||||
|
||||
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
|
||||
|
||||
def calculate_paid_amount(self):
|
||||
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
|
||||
is_paid = self.get("is_pos") or self.get("is_paid")
|
||||
|
||||
@@ -85,18 +85,10 @@ class BuyingController(SubcontractingController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_buying_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_buying_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_buying_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
if self.get("company") and not self.get("terms"):
|
||||
if not self.get("tc_name"):
|
||||
self.tc_name = frappe.get_value("Company", self.company, "default_buying_terms")
|
||||
self.set_missing_terms()
|
||||
|
||||
def validate_posting_date_with_po(self):
|
||||
po_list = {x.purchase_order for x in self.items if x.purchase_order}
|
||||
@@ -981,6 +973,14 @@ class BuyingController(SubcontractingController):
|
||||
item.serial_and_batch_bundle, warehouse, type_of_transaction=type_of_transaction
|
||||
)
|
||||
|
||||
def check_purchase_order_on_hold_or_close(self, ref_fieldname, exclude_if_field=None):
|
||||
if self.get("is_return"):
|
||||
return
|
||||
|
||||
self.check_for_on_hold_or_closed_status(
|
||||
"Purchase Order", ref_fieldname, exclude_if_field=exclude_if_field
|
||||
)
|
||||
|
||||
def update_ordered_and_reserved_qty(self):
|
||||
po_map = {}
|
||||
for d in self.get("items"):
|
||||
@@ -994,7 +994,7 @@ class BuyingController(SubcontractingController):
|
||||
if po and po_item_rows:
|
||||
po_obj = frappe.get_lazy_doc("Purchase Order", po)
|
||||
|
||||
if po_obj.status in ["Closed", "Cancelled"]:
|
||||
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
|
||||
frappe.throw(
|
||||
_("{doctype} {name} is cancelled or closed.").format(
|
||||
doctype=frappe.bold(_("Purchase Order")),
|
||||
|
||||
@@ -214,7 +214,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
else 0
|
||||
)
|
||||
|
||||
if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"):
|
||||
if column in ("stock_qty", "qty") and not args.get("return_qty_from_rejected_warehouse"):
|
||||
reference_qty = ref.get(column)
|
||||
current_stock_qty = args.get(column)
|
||||
elif args.get("return_qty_from_rejected_warehouse"):
|
||||
|
||||
@@ -43,18 +43,10 @@ class SellingController(StockController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_selling_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_selling_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_selling_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
if self.get("company") and not self.get("terms"):
|
||||
if not self.get("tc_name"):
|
||||
self.tc_name = frappe.get_value("Company", self.company, "default_selling_terms")
|
||||
self.set_missing_terms()
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
@@ -573,12 +565,12 @@ class SellingController(StockController):
|
||||
reset_incoming_rate()
|
||||
|
||||
if (
|
||||
not d.incoming_rate
|
||||
(not d.incoming_rate or self.is_new())
|
||||
and not is_standalone
|
||||
or self.is_internal_transfer()
|
||||
or (
|
||||
get_valuation_method(d.item_code, self.company) == "Moving Average"
|
||||
and self.get("is_return")
|
||||
and not is_standalone
|
||||
)
|
||||
):
|
||||
d.incoming_rate = get_incoming_rate(
|
||||
|
||||
@@ -75,7 +75,7 @@ SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
|
||||
|
||||
def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
"""Whether the row is a secondary item on a document that produces secondary items."""
|
||||
if not (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
if not (row.get("secondary_item_type") or row.get("is_legacy_scrap_item")):
|
||||
return False
|
||||
|
||||
if doc.doctype == "Stock Entry":
|
||||
@@ -86,7 +86,9 @@ def is_inspection_exempt_secondary_row(doc, row) -> bool:
|
||||
|
||||
def stock_entry_row_requires_inspection(purpose, row):
|
||||
"""Check if this Stock Entry row need a Quality Inspection."""
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
|
||||
if purpose in SECONDARY_ITEM_PURPOSES and (
|
||||
row.get("secondary_item_type") or row.get("is_legacy_scrap_item")
|
||||
):
|
||||
return False
|
||||
if purpose == "Manufacture":
|
||||
return bool(row.is_finished_item)
|
||||
|
||||
@@ -161,7 +161,7 @@ class SubcontractingController(StockController):
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not is_sub_contracted_item:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
@@ -1288,10 +1288,10 @@ class SubcontractingController(StockController):
|
||||
total_amt = sum(
|
||||
flt(item.amount)
|
||||
for item in self.get("items")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = (
|
||||
(item.amount * self.total_additional_costs) / total_amt
|
||||
) / item.qty
|
||||
@@ -1299,15 +1299,15 @@ class SubcontractingController(StockController):
|
||||
total_qty = sum(
|
||||
flt(item.qty)
|
||||
for item in self.get("items")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
additional_cost_per_qty = self.total_additional_costs / total_qty
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = additional_cost_per_qty
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -241,7 +241,7 @@ class SubcontractingInwardController:
|
||||
item
|
||||
for item in self.get("items")
|
||||
if not item.is_finished_item
|
||||
and not item.type
|
||||
and not item.secondary_item_type
|
||||
and not item.is_legacy_scrap_item
|
||||
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
|
||||
]
|
||||
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
|
||||
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
|
||||
for item in self.items:
|
||||
if (
|
||||
item.is_finished_item or item.type or item.is_legacy_scrap_item
|
||||
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
|
||||
) and item.valuation_rate == 0:
|
||||
item.allow_zero_valuation_rate = 1
|
||||
|
||||
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
|
||||
self.validate_delivery_on_save()
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.type and not item.is_legacy_scrap_item:
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item:
|
||||
delivered_qty, returned_qty = frappe.get_value(
|
||||
"Subcontracting Inward Order Item",
|
||||
item.scio_detail,
|
||||
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
|
||||
bold(
|
||||
frappe.get_cached_value(
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item",
|
||||
item.scio_detail,
|
||||
"stock_uom",
|
||||
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
|
||||
)
|
||||
|
||||
for item in [item for item in self.items if not item.is_finished_item]:
|
||||
if item.type or item.is_legacy_scrap_item:
|
||||
if item.secondary_item_type or item.is_legacy_scrap_item:
|
||||
scio_secondary_item = frappe.get_value(
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
{
|
||||
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
|
||||
for item in self.items:
|
||||
doctype = (
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item"
|
||||
)
|
||||
qty_map[doctype][item.scio_detail] += (
|
||||
@@ -791,7 +791,7 @@ class SubcontractingInwardController:
|
||||
items = [
|
||||
item
|
||||
for item in self.items
|
||||
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
]
|
||||
item_code_wh = frappe._dict(
|
||||
{
|
||||
@@ -893,7 +893,9 @@ class SubcontractingInwardController:
|
||||
|
||||
def update_inward_order_secondary_items(self):
|
||||
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
|
||||
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
|
||||
secondary_items_list = [
|
||||
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
|
||||
]
|
||||
|
||||
secondary_items = defaultdict(float)
|
||||
for item in secondary_items_list:
|
||||
@@ -967,7 +969,7 @@ class SubcontractingInwardController:
|
||||
stock_uom=secondary_item.stock_uom,
|
||||
warehouse=secondary_item.t_warehouse,
|
||||
produced_qty=secondary_item.transfer_qty,
|
||||
type=secondary_item.type,
|
||||
secondary_item_type=secondary_item.secondary_item_type,
|
||||
delivered_qty=0,
|
||||
reference_name=frappe.get_value(
|
||||
"Work Order", self.work_order, "subcontracting_inward_order_item"
|
||||
|
||||
@@ -314,33 +314,32 @@ class calculate_taxes_and_totals:
|
||||
for item in self.doc.items:
|
||||
item._unrounded_net_amount = None
|
||||
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
|
||||
cumulated_tax_fraction = 0
|
||||
total_inclusive_tax_amount_per_qty = 0
|
||||
total_tax_slope = 0
|
||||
total_tax_intercept = 0
|
||||
for i, tax in enumerate(self.doc.get("taxes")):
|
||||
(
|
||||
tax.tax_fraction_for_current_item,
|
||||
inclusive_tax_amount_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map)
|
||||
tax_intercept_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map, item)
|
||||
tax.inclusive_amount_per_qty = tax_intercept_per_qty
|
||||
|
||||
if i == 0:
|
||||
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
|
||||
tax.grand_total_amount_per_qty = tax_intercept_per_qty
|
||||
else:
|
||||
prev = self.doc.get("taxes")[i - 1]
|
||||
tax.grand_total_fraction_for_current_item = (
|
||||
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
|
||||
+ tax.tax_fraction_for_current_item
|
||||
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
|
||||
)
|
||||
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
|
||||
|
||||
cumulated_tax_fraction += tax.tax_fraction_for_current_item
|
||||
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
|
||||
total_tax_slope += tax.tax_fraction_for_current_item
|
||||
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
|
||||
|
||||
if (
|
||||
not self.discount_amount_applied
|
||||
and item.qty
|
||||
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
|
||||
):
|
||||
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
|
||||
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
|
||||
amount = flt(item.amount) - total_tax_intercept
|
||||
|
||||
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction)
|
||||
item._unrounded_net_amount = amount / (1 + total_tax_slope)
|
||||
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
|
||||
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
|
||||
item.discount_percentage = flt(
|
||||
@@ -352,41 +351,48 @@ class calculate_taxes_and_totals:
|
||||
def _load_item_tax_rate(self, item_tax_rate):
|
||||
return json.loads(item_tax_rate) if item_tax_rate else {}
|
||||
|
||||
def get_current_tax_fraction(self, tax, item_tax_map):
|
||||
def get_current_tax_fraction(self, tax, item_tax_map, item):
|
||||
"""
|
||||
Get tax fraction for calculating tax exclusive amount
|
||||
from tax inclusive amount
|
||||
tax = slope * net + intercept.
|
||||
Returns (slope, intercept_per_qty)
|
||||
"""
|
||||
current_tax_fraction = 0
|
||||
inclusive_tax_amount_per_qty = 0
|
||||
tax_slope = 0
|
||||
tax_intercept = 0
|
||||
|
||||
if cint(tax.included_in_print_rate):
|
||||
tax_rate = self._get_tax_rate(tax, item_tax_map)
|
||||
|
||||
if tax_rate == NOT_APPLICABLE_TAX:
|
||||
return current_tax_fraction, inclusive_tax_amount_per_qty
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
if tax.charge_type == "On Net Total":
|
||||
current_tax_fraction = tax_rate / 100.0
|
||||
tax_slope = tax_rate / 100.0
|
||||
|
||||
elif tax.charge_type == "On Previous Row Amount":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].tax_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Previous Row Total":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].grand_total_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
inclusive_tax_amount_per_qty = flt(tax_rate)
|
||||
tax_intercept = flt(tax_rate)
|
||||
|
||||
else:
|
||||
# Custom charge_type: the rate applies to a resolved (fixed) base,
|
||||
# e.g. a tax on MRP included in the printed price.
|
||||
qty = flt(item.qty) or 1
|
||||
base = self.get_item_taxable_base(item, tax)
|
||||
tax_intercept = (tax_rate / 100.0) * base / qty
|
||||
|
||||
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
|
||||
current_tax_fraction *= -1.0
|
||||
inclusive_tax_amount_per_qty *= -1.0
|
||||
tax_slope *= -1.0
|
||||
tax_intercept *= -1.0
|
||||
|
||||
return current_tax_fraction, inclusive_tax_amount_per_qty
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
def _get_tax_rate(self, tax, item_tax_map):
|
||||
if tax.account_head in item_tax_map:
|
||||
@@ -612,7 +618,6 @@ class calculate_taxes_and_totals:
|
||||
elif tax.charge_type == "On Net Total":
|
||||
if tax.account_head in item_tax_map:
|
||||
current_net_amount = item.net_amount
|
||||
|
||||
# Use unrounded net for inclusive taxes to avoid double rounding
|
||||
if (
|
||||
cint(tax.included_in_print_rate)
|
||||
@@ -631,12 +636,46 @@ class calculate_taxes_and_totals:
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
# don't sum current net amount due to the field being a currency field
|
||||
current_tax_amount = tax_rate * item.qty
|
||||
else:
|
||||
# Custom charge_type: rate applies to the resolver-provided base.
|
||||
base = self.get_item_taxable_base(item, tax)
|
||||
current_net_amount = base
|
||||
current_tax_amount = (tax_rate / 100.0) * base
|
||||
|
||||
if not tax.get("dont_recompute_tax"):
|
||||
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount)
|
||||
|
||||
return current_net_amount, current_tax_amount
|
||||
|
||||
def get_item_taxable_base(self, item, tax):
|
||||
"""Per-item base a custom charge_type's rate is applied to.
|
||||
|
||||
Override the base (gross, MRP, net of other taxes, …) via the
|
||||
`erpnext_taxable_base_resolvers` hook
|
||||
|
||||
Register a resolver in `hooks.py`, keyed by charge_type:
|
||||
|
||||
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
|
||||
|
||||
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
|
||||
and returns the base (flt-coerced by the caller):
|
||||
|
||||
def gross_base(calc, item, tax):
|
||||
return item.custom_field_mrp * item.qty
|
||||
|
||||
A resolver may stamp transient attributes on `item`; it can be called more than once
|
||||
per item, so such stamping must be idempotent.
|
||||
"""
|
||||
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
|
||||
path = resolvers.get(tax.charge_type)
|
||||
|
||||
if path:
|
||||
method = path[-1] if isinstance(path, list | tuple) else path
|
||||
return flt(frappe.get_attr(method)(self, item, tax))
|
||||
|
||||
# fallback
|
||||
return flt(item.net_amount)
|
||||
|
||||
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
|
||||
# store tax breakup for each item
|
||||
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1
|
||||
|
||||
@@ -88,3 +88,35 @@ class TestSalesAndPurchaseReturn(ERPNextTestSuite):
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
|
||||
def test_sales_invoice_partial_return_with_different_stock_uom(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_properties = {"is_stock_item": 1, "stock_uom": "Kg"}
|
||||
if frappe.get_meta("Item").has_field("gst_hsn_code") and frappe.db.exists("GST HSN Code", "010121"):
|
||||
item_properties["gst_hsn_code"] = "010121"
|
||||
|
||||
item = make_item(
|
||||
"_Test SI Return Different Stock UOM",
|
||||
item_properties,
|
||||
uoms=[{"uom": "Nos", "conversion_factor": 0.013888889}],
|
||||
)
|
||||
|
||||
si = create_sales_invoice(item_code=item.name, qty=48, do_not_save=True)
|
||||
si.items[0].uom = "Nos"
|
||||
si.items[0].stock_uom = "Kg"
|
||||
si.items[0].conversion_factor = 0.013888889
|
||||
si.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
first_return = make_return_doc(si.doctype, si.name)
|
||||
first_return.items[0].qty = -24
|
||||
first_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", first_return.name)
|
||||
|
||||
second_return = make_return_doc(si.doctype, si.name)
|
||||
self.assertEqual(second_return.items[0].qty, -24)
|
||||
second_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", second_return.name)
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def resolve_on_gross(calc, item, tax):
|
||||
# base = gross printed line amount
|
||||
return flt(item.amount)
|
||||
|
||||
|
||||
def resolve_on_mrp(calc, item, tax):
|
||||
# base = MRP, not net
|
||||
return flt(item.price_list_rate) * flt(item.qty)
|
||||
|
||||
|
||||
class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
def test_regional_round_off_accounts(self):
|
||||
"""
|
||||
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
|
||||
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
|
||||
|
||||
def test_exclusive_custom_charge_on_resolved_base(self):
|
||||
"""Added (exclusive) custom charge_type whose base is resolved by the
|
||||
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
|
||||
-> tax 100, net 1000, grand 1100."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1000,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Gross Value",
|
||||
"account_head": "_Test Account Excise Duty - _TC",
|
||||
"description": "IPI 10% on gross product value",
|
||||
"rate": 10,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {
|
||||
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
|
||||
}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.net_total, 1000.0)
|
||||
self.assertEqual(so.taxes[0].tax_amount, 100.0)
|
||||
self.assertEqual(so.grand_total, 1100.0)
|
||||
|
||||
def test_inclusive_custom_charge_on_resolved_base(self):
|
||||
"""Inclusive custom charge on a resolved base backs out non-compounding
|
||||
(tax = rate x resolved base) — a resolved base is fixed, so it never
|
||||
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1200,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On MRP",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": "Tax 10% on MRP, inclusive",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.taxes[0].tax_amount, 120.0)
|
||||
self.assertEqual(so.net_total, 880.0)
|
||||
self.assertEqual(so.grand_total, 1000.0)
|
||||
|
||||
def test_disabling_rounded_total_resets_base_fields(self):
|
||||
"""Disabling rounded total should also clear base rounded values."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
|
||||
@@ -69,6 +69,13 @@ class CRMSettings(Document):
|
||||
self.allowed_users = []
|
||||
|
||||
def custom_fields_for_frappe_crm_data_sync(self):
|
||||
custom_fields = self.get_frappe_crm_custom_fields()
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization:
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
|
||||
@staticmethod
|
||||
def get_frappe_crm_custom_fields():
|
||||
custom_fields = {
|
||||
"Quotation": [
|
||||
{
|
||||
@@ -88,4 +95,4 @@ class CRMSettings(Document):
|
||||
],
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
return custom_fields
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from frappe.utils import flt, get_fullname
|
||||
|
||||
from erpnext.accounts.party import validate_party_frozen_disabled
|
||||
from erpnext.crm.utils import (
|
||||
CRMNote,
|
||||
copy_comments,
|
||||
@@ -133,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_item_details()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.validate_party()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
@@ -348,6 +350,10 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_party(self) -> None:
|
||||
if self.opportunity_from == "Customer":
|
||||
validate_party_frozen_disabled(self.company, "Customer", self.party_name)
|
||||
|
||||
def validate_cust_name(self):
|
||||
if self.party_name:
|
||||
if self.opportunity_from == "Customer":
|
||||
|
||||
@@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.lead import make_customer
|
||||
from erpnext.crm.doctype.lead.test_lead import make_lead
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
|
||||
from erpnext.crm.utils import get_linked_communication_list
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -71,6 +72,23 @@ class TestOpportunity(ERPNextTestSuite):
|
||||
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
|
||||
self.assertEqual(opportunity_doc.total, 2200)
|
||||
|
||||
def test_disabled_customer_not_allowed(self):
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
|
||||
|
||||
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
|
||||
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
|
||||
make_opportunity(with_items=0)
|
||||
|
||||
def test_disabled_lead_not_blocked(self):
|
||||
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
|
||||
# excludes Converted leads), so it shouldn't block Opportunity creation either.
|
||||
lead_doc = make_lead()
|
||||
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
|
||||
|
||||
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
|
||||
self.assertEqual(opp_doc.party_name, lead_doc.name)
|
||||
|
||||
def test_carry_forward_of_email_and_comments(self):
|
||||
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
|
||||
lead_doc = make_lead()
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_last_interaction(contact=None, lead=None):
|
||||
if not contact and not lead:
|
||||
return
|
||||
|
||||
last_communication = None
|
||||
last_issue = None
|
||||
if contact:
|
||||
query_condition = ""
|
||||
values = []
|
||||
contact = frappe.get_doc("Contact", contact)
|
||||
for link in contact.links:
|
||||
if link.link_doctype == "Customer":
|
||||
last_issue = get_last_issue_from_customer(link.link_name)
|
||||
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
|
||||
values += [link.link_doctype, link.link_name]
|
||||
|
||||
if query_condition:
|
||||
# remove extra appended 'OR'
|
||||
query_condition = query_condition[:-2]
|
||||
last_communication = frappe.db.sql(
|
||||
f"""
|
||||
SELECT `name`, `content`
|
||||
FROM `tabCommunication`
|
||||
WHERE `sent_or_received`='Received'
|
||||
AND ({query_condition})
|
||||
ORDER BY `creation`
|
||||
LIMIT 1
|
||||
""",
|
||||
values,
|
||||
as_dict=1,
|
||||
) # nosec
|
||||
|
||||
if lead:
|
||||
last_communication = frappe.get_all(
|
||||
"Communication",
|
||||
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
|
||||
fields=["name", "content"],
|
||||
order_by="`creation` DESC",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
last_communication = last_communication[0] if last_communication else None
|
||||
|
||||
return {"last_communication": last_communication, "last_issue": last_issue}
|
||||
|
||||
|
||||
def get_last_issue_from_customer(customer_name):
|
||||
issues = frappe.get_all(
|
||||
"Issue",
|
||||
|
||||
@@ -619,16 +619,16 @@ regional_overrides = {
|
||||
"erpnext.controllers.accounts_controller.validate_regional": "erpnext.regional.italy.utils.sales_invoice_validate",
|
||||
},
|
||||
}
|
||||
user_privacy_documents = [
|
||||
user_data_fields = [
|
||||
{
|
||||
"doctype": "Lead",
|
||||
"match_field": "email_id",
|
||||
"personal_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
|
||||
"filter_by": "email_id",
|
||||
"redact_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
|
||||
},
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"match_field": "contact_email",
|
||||
"personal_fields": ["contact_mobile", "contact_display", "customer_name"],
|
||||
"filter_by": "contact_email",
|
||||
"redact_fields": ["contact_mobile", "contact_display", "customer_name"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -309,7 +309,9 @@ frappe.ui.form.on("BOM", {
|
||||
frm.set_intro(
|
||||
__("This is a Template BOM and will be used to make the work order for {0} of the item {1}", [
|
||||
`<a class="variants-intro">variants</a>`,
|
||||
`<a href="/app/item/${frm.doc.item}">${frm.doc.item}</a>`,
|
||||
`<a href="${frappe.utils.get_form_link("Item", frm.doc.item)}">${frappe.utils.escape_html(
|
||||
frm.doc.item
|
||||
)}</a>`,
|
||||
]),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -372,7 +372,7 @@ class BOM(WebsiteGenerator):
|
||||
if item.process_loss_per >= 100:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Process Loss Percentage should be less than 100% for {1} Item {2}").format(
|
||||
item.idx, item.type, get_link_to_form("Item", item.item_code)
|
||||
item.idx, item.secondary_item_type, get_link_to_form("Item", item.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1297,7 +1297,9 @@ class BOM(WebsiteGenerator):
|
||||
frappe.throw(msg, title=_("Invalid Process Loss Configuration"))
|
||||
|
||||
def has_scrap_items(self):
|
||||
return any(d.get("type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items"))
|
||||
return any(
|
||||
d.get("secondary_item_type") == "Scrap" or d.get("is_legacy") for d in self.get("secondary_items")
|
||||
)
|
||||
|
||||
|
||||
def get_bom_item_rate(args, bom_doc):
|
||||
@@ -1465,7 +1467,7 @@ def get_bom_items_as_dict(
|
||||
query = query.format(
|
||||
table="BOM Secondary Item",
|
||||
where_conditions=")",
|
||||
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.type, bom_item.name, bom_item.is_legacy",
|
||||
select_columns=", item.description, bom_item.cost_allocation_per, bom_item.process_loss_per, bom_item.secondary_item_type, bom_item.name, bom_item.is_legacy",
|
||||
is_stock_item=is_stock_item,
|
||||
qty_field="stock_qty",
|
||||
group_by_cond=group_by_cond,
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
<hr style="margin: 15px -15px;">
|
||||
<p>
|
||||
{% if data.value && data.value != "BOM" %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ data.value }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ frappe.utils.escape_html(data.value) }}">
|
||||
{{ __("Open BOM {0}", [data.value.bold()]) }}</a>
|
||||
{% endif %}
|
||||
{% if data.item_code %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ data.item_code }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ frappe.utils.escape_html(data.item_code) }}">
|
||||
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"stock_qty": 1.0,
|
||||
"rate": 2000.0,
|
||||
"stock_uom": "_Test UOM",
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
"is_legacy": 1
|
||||
}
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"secondary_item_type",
|
||||
"rate",
|
||||
"column_break_gres",
|
||||
"is_legacy",
|
||||
@@ -35,7 +35,7 @@
|
||||
"fields": [
|
||||
{
|
||||
"depends_on": "eval:!doc.is_legacy",
|
||||
"fieldname": "type",
|
||||
"fieldname": "secondary_item_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
|
||||
@@ -32,7 +32,7 @@ class BOMSecondaryItem(Document):
|
||||
rate: DF.Currency
|
||||
stock_qty: DF.Float
|
||||
stock_uom: DF.Link | None
|
||||
type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
|
||||
secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
|
||||
uom: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -150,6 +150,8 @@ class JobCard(Document):
|
||||
self.set_onload("job_card_excess_transfer", excess_transfer)
|
||||
self.set_onload("work_order_closed", self.is_work_order_closed())
|
||||
self.set_onload("has_stock_entry", self.has_stock_entry())
|
||||
if self.docstatus == 0:
|
||||
self.set_onload("max_completable_qty", self.get_max_completable_qty())
|
||||
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
@@ -297,7 +299,7 @@ class JobCard(Document):
|
||||
"stock_qty": values.qty,
|
||||
"item_name": values.item_name,
|
||||
"stock_uom": values.stock_uom,
|
||||
"type": values.type,
|
||||
"secondary_item_type": values.secondary_item_type,
|
||||
"bom_secondary_item": values.name,
|
||||
}
|
||||
|
||||
@@ -1374,12 +1376,7 @@ class JobCard(Document):
|
||||
|
||||
current_operation_qty += flt(self.total_completed_qty)
|
||||
|
||||
previous_operations = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
|
||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
previous_operations = self.get_previous_operations()
|
||||
|
||||
message = "Job Card {}: As per the sequence of the operations in the work order {}".format(
|
||||
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
|
||||
@@ -1443,6 +1440,41 @@ class JobCard(Document):
|
||||
|
||||
return dict(data)
|
||||
|
||||
def get_previous_operations(self):
|
||||
return frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
|
||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
|
||||
def get_current_operation_completed_qty(self):
|
||||
current_operation_qty = 0.0
|
||||
data = self.get_current_operation_data()
|
||||
if data and len(data) > 0:
|
||||
current_operation_qty = flt(data[0].completed_qty)
|
||||
|
||||
return current_operation_qty + flt(self.total_completed_qty)
|
||||
|
||||
def get_max_completable_qty(self):
|
||||
if self.is_corrective_job_card or not (self.work_order and self.sequence_id):
|
||||
return None
|
||||
|
||||
previous_operations = self.get_previous_operations()
|
||||
if not previous_operations:
|
||||
return None
|
||||
|
||||
if self.track_semi_finished_goods:
|
||||
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
|
||||
for row in previous_operations:
|
||||
row.manufactured_qty = flt(totals.get(row.name))
|
||||
|
||||
qty_field = "manufactured_qty" if self.track_semi_finished_goods else "completed_qty"
|
||||
min_completed_qty = min(flt(row.get(qty_field)) for row in previous_operations)
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
return flt(min_completed_qty - self.get_current_operation_completed_qty(), precision)
|
||||
|
||||
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
||||
manufactured_qty = flt(row.manufactured_qty)
|
||||
|
||||
@@ -1725,7 +1757,7 @@ class JobCard(Document):
|
||||
ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
ste.stock_entry.set_secondary_items_from_job_card()
|
||||
for row in ste.stock_entry.items:
|
||||
if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse:
|
||||
if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse:
|
||||
row.t_warehouse = self.target_warehouse
|
||||
|
||||
if auto_submit:
|
||||
|
||||
@@ -915,6 +915,146 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def make_two_operation_work_order(self, qty=10):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
setup_bom,
|
||||
setup_operations,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
operations = [
|
||||
{"operation": "Test Operation A1", "workstation": "Test Workstation A", "time_in_mins": 30},
|
||||
{"operation": "Test Operation B1", "workstation": "Test Workstation A", "time_in_mins": 20},
|
||||
]
|
||||
|
||||
warehouse = create_warehouse("Test Warehouse 123 for Job Card")
|
||||
setup_operations(operations)
|
||||
|
||||
item_code = "Test Job Card Process Qty Item"
|
||||
for item in [item_code, item_code + "RM 1", item_code + "RM 2"]:
|
||||
if not frappe.db.exists("Item", item):
|
||||
make_item(item, {"item_name": item, "stock_uom": "Nos", "is_stock_item": 1})
|
||||
|
||||
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
|
||||
bom_doc = setup_bom(
|
||||
item_code=item_code,
|
||||
routing=routing_doc.name,
|
||||
raw_materials=[item_code + "RM 1", item_code + "RM 2"],
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
for row in bom_doc.items:
|
||||
make_stock_entry(item_code=row.item_code, target=row.source_warehouse, qty=qty, basic_rate=100)
|
||||
|
||||
return make_wo_order_test_record(
|
||||
production_item=item_code,
|
||||
bom_no=bom_doc.name,
|
||||
qty=qty,
|
||||
skip_transfer=1,
|
||||
wip_warehouse=warehouse,
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
def test_completion_qty_capped_by_previous_operation(self):
|
||||
wo_doc = self.make_two_operation_work_order()
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name},
|
||||
fields=["name", "sequence_id"],
|
||||
order_by="sequence_id",
|
||||
)
|
||||
|
||||
jc1 = frappe.get_doc("Job Card", job_cards[0].name)
|
||||
self.assertIsNone(jc1.get_max_completable_qty())
|
||||
|
||||
jc1.append(
|
||||
"time_logs",
|
||||
{"from_time": now(), "to_time": add_to_date(now(), minutes=30), "completed_qty": 8},
|
||||
)
|
||||
jc1.save()
|
||||
jc1.submit()
|
||||
self.assertEqual(jc1.process_loss_qty, 2)
|
||||
|
||||
jc2 = frappe.get_doc("Job Card", job_cards[1].name)
|
||||
self.assertEqual(jc2.get_max_completable_qty(), 8)
|
||||
|
||||
jc2.append("time_logs", {"from_time": add_to_date(now(), minutes=40)})
|
||||
jc2.save()
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc2.complete_job_card,
|
||||
qty=10,
|
||||
for_quantity=10,
|
||||
pending_qty=0,
|
||||
process_loss_qty=0,
|
||||
end_time=add_to_date(now(), minutes=70),
|
||||
)
|
||||
|
||||
self.complete_second_operation_and_finish(wo_doc, jc2.name)
|
||||
|
||||
def complete_second_operation_and_finish(self, wo_doc, job_card):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
|
||||
jc2 = frappe.get_doc("Job Card", job_card)
|
||||
jc2.time_logs[0].completed_qty = 7
|
||||
jc2.time_logs[0].to_time = add_to_date(now(), minutes=70)
|
||||
jc2.save()
|
||||
self.assertEqual(jc2.process_loss_qty, 3)
|
||||
jc2.submit()
|
||||
|
||||
se = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 10))
|
||||
se.submit()
|
||||
|
||||
self.assertEqual(se.process_loss_qty, 3)
|
||||
fg_qty = sum(d.qty for d in se.items if d.is_finished_item)
|
||||
self.assertEqual(flt(fg_qty), 7)
|
||||
|
||||
wo_doc.reload()
|
||||
self.assertEqual(wo_doc.produced_qty, 7)
|
||||
self.assertEqual(wo_doc.process_loss_qty, 3)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def test_process_loss_booked_once_across_partial_entries(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
|
||||
wo_doc = self.make_two_operation_work_order()
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card", filters={"work_order": wo_doc.name}, fields=["name"], order_by="sequence_id"
|
||||
)
|
||||
|
||||
for index, row in enumerate(job_cards):
|
||||
jc = frappe.get_doc("Job Card", row.name)
|
||||
from_time = add_to_date(now(), minutes=index * 40)
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{"from_time": from_time, "to_time": add_to_date(from_time, minutes=30), "completed_qty": 7},
|
||||
)
|
||||
jc.save()
|
||||
jc.submit()
|
||||
self.assertEqual(jc.process_loss_qty, 3)
|
||||
|
||||
se1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
|
||||
se1.submit()
|
||||
self.assertEqual(se1.process_loss_qty, 3)
|
||||
self.assertEqual(flt(sum(d.qty for d in se1.items if d.is_finished_item)), 2)
|
||||
|
||||
se2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 5))
|
||||
se2.submit()
|
||||
self.assertEqual(flt(se2.process_loss_qty), 0)
|
||||
self.assertEqual(flt(sum(d.qty for d in se2.items if d.is_finished_item)), 5)
|
||||
|
||||
wo_doc.reload()
|
||||
self.assertEqual(wo_doc.process_loss_qty, 3)
|
||||
self.assertEqual(wo_doc.produced_qty, 7)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def get_first_job_card(self, work_order):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
@@ -1163,7 +1303,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
"qty": 1,
|
||||
"process_loss_per": 10,
|
||||
"cost_allocation_per": 5,
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
},
|
||||
)
|
||||
if submit:
|
||||
@@ -1246,7 +1386,8 @@ class TestJobCard(ERPNextTestSuite):
|
||||
},
|
||||
)
|
||||
job_card.append(
|
||||
"secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"}
|
||||
"secondary_items",
|
||||
{"item_code": scrap_extra.name, "stock_qty": 5, "secondary_item_type": "Co-Product"},
|
||||
)
|
||||
job_card.submit()
|
||||
|
||||
@@ -1265,7 +1406,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(manufacturing_entry.items[2].qty, 9)
|
||||
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
|
||||
self.assertEqual(manufacturing_entry.items[3].item_code, scrap_extra.name)
|
||||
self.assertEqual(manufacturing_entry.items[3].type, "Co-Product")
|
||||
self.assertEqual(manufacturing_entry.items[3].secondary_item_type, "Co-Product")
|
||||
self.assertEqual(manufacturing_entry.items[3].qty, 5)
|
||||
self.assertEqual(manufacturing_entry.items[3].basic_rate, 0)
|
||||
|
||||
@@ -2429,7 +2570,9 @@ class TestJobCard(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
job_card.append("secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "type": "Scrap"})
|
||||
job_card.append(
|
||||
"secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "secondary_item_type": "Scrap"}
|
||||
)
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"secondary_item_type",
|
||||
"description",
|
||||
"column_break_3",
|
||||
"item_code",
|
||||
@@ -69,7 +69,7 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldname": "secondary_item_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
@@ -87,7 +87,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-06 13:51:00.492621",
|
||||
"modified": "2026-06-01 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Secondary Item",
|
||||
|
||||
@@ -22,7 +22,7 @@ class JobCardSecondaryItem(Document):
|
||||
parenttype: DF.Data
|
||||
stock_qty: DF.Float
|
||||
stock_uom: DF.Link | None
|
||||
type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
|
||||
secondary_item_type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -3226,7 +3226,7 @@ def make_bom(**args):
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
"item_code": item,
|
||||
"item_name": item,
|
||||
"uom": item_doc.stock_uom,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_minimum_material_coverage_fraction(
|
||||
required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int
|
||||
) -> float:
|
||||
"""Return the least-covered component ratio at the configured quantity precision."""
|
||||
coverage = []
|
||||
for item_code, required in required_qty.items():
|
||||
transferred = flt(transferred_qty.get(item_code))
|
||||
# Stored values can differ after the digits that the user can enter or see.
|
||||
if flt(transferred, precision) == flt(required, precision):
|
||||
coverage.append(1.0)
|
||||
else:
|
||||
coverage.append(transferred / required)
|
||||
|
||||
return min(coverage, default=0.0)
|
||||
@@ -1115,7 +1115,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
|
||||
for row in stock_entry.items:
|
||||
if row.type or row.is_legacy_scrap_item:
|
||||
if row.secondary_item_type or row.is_legacy_scrap_item:
|
||||
self.assertEqual(row.qty, 1)
|
||||
|
||||
# Partial Job Card 1 with qty 10
|
||||
@@ -1127,7 +1127,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
|
||||
for row in stock_entry.items:
|
||||
if row.type or row.is_legacy_scrap_item:
|
||||
if row.secondary_item_type or row.is_legacy_scrap_item:
|
||||
self.assertEqual(row.qty, 2)
|
||||
|
||||
# Partial Job Card 2 with qty 10
|
||||
@@ -1464,9 +1464,11 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
del transfer_entry.get("items")[0] # transfer only one RM
|
||||
transfer_entry.submit()
|
||||
|
||||
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
|
||||
# One required item is still missing, so no finished-good quantity is covered yet.
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 0)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
@@ -1486,6 +1488,47 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
def test_material_transfer_claim_follows_actual_coverage(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=20,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
|
||||
)
|
||||
for row in transfer_entry.items:
|
||||
if row.item_code == "_Test Item":
|
||||
row.qty = 1
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 1)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
|
||||
remainder_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
|
||||
)
|
||||
remainder_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(remainder_entry.fg_completed_qty, 3)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 4)
|
||||
|
||||
def test_material_coverage_cap_skips_manufacture_entry(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
|
||||
manufacture_entry.pro_doc = work_order
|
||||
manufacture_entry._action = "submit"
|
||||
|
||||
self.assertFalse(manufacture_entry._should_cap_completed_qty())
|
||||
|
||||
def test_material_transferred_min_fraction_on_partial_pick_list(self):
|
||||
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
|
||||
must reflect the least-transferred required item (the bottleneck), instead of being
|
||||
@@ -1548,6 +1591,97 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_material_transferred_ignores_hidden_precision_difference(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
precision = work_order.precision("required_qty", "required_items")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
row = work_order.required_items[0]
|
||||
row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
|
||||
work_order.reload()
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
for item in transfer_entry.items:
|
||||
item.qty = flt(required_qty[item.item_code], precision)
|
||||
item.transfer_qty = item.qty
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(
|
||||
flt(work_order.required_items[0].required_qty, precision),
|
||||
flt(work_order.required_items[0].transferred_qty, precision),
|
||||
)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
|
||||
def test_repair_material_transfer_precision_patch(self):
|
||||
from erpnext.patches.v16_0.repair_work_order_material_transfer import (
|
||||
execute,
|
||||
get_precision_affected_work_orders,
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for index, row in enumerate(work_order.required_items):
|
||||
required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
|
||||
row.db_set(
|
||||
{
|
||||
"required_qty": required_qty,
|
||||
"transferred_qty": flt(required_qty, precision),
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in partial_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
partial_row = partial_work_order.required_items[0]
|
||||
partial_row.db_set(
|
||||
"transferred_qty",
|
||||
flt(partial_row.required_qty, precision) - (1 / (10**precision)),
|
||||
update_modified=False,
|
||||
)
|
||||
partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
terminal_work_orders = []
|
||||
for status in ("Stopped", "Closed", "Completed"):
|
||||
terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in terminal_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
terminal_work_order.db_set(
|
||||
{"material_transferred_for_manufacturing": 1.99, "status": status},
|
||||
update_modified=False,
|
||||
)
|
||||
terminal_work_orders.append(terminal_work_order)
|
||||
|
||||
updates = get_precision_affected_work_orders()
|
||||
self.assertIn(work_order.name, updates)
|
||||
self.assertNotIn(partial_work_order.name, updates)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
self.assertNotIn(terminal_work_order.name, updates)
|
||||
|
||||
execute()
|
||||
work_order.reload()
|
||||
partial_work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
terminal_work_order.reload()
|
||||
self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
|
||||
def test_work_order_material_request_and_bom_details(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_stock_entry as mr_to_stock_entry,
|
||||
@@ -2367,7 +2501,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertTrue(se_doc.additional_costs)
|
||||
secondary_items = []
|
||||
for item in se_doc.items:
|
||||
if item.type or item.is_legacy_scrap_item:
|
||||
if item.secondary_item_type or item.is_legacy_scrap_item:
|
||||
secondary_items.append(item.item_code)
|
||||
|
||||
self.assertEqual(
|
||||
@@ -2832,7 +2966,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
# Secondary/Scrap item: should be taken from scrap warehouse in disassembly
|
||||
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
|
||||
self.assertIsNotNone(scrap_row)
|
||||
self.assertEqual(scrap_row.type, "Scrap")
|
||||
self.assertEqual(scrap_row.secondary_item_type, "Scrap")
|
||||
self.assertTrue(scrap_row.s_warehouse)
|
||||
self.assertFalse(scrap_row.t_warehouse)
|
||||
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
|
||||
@@ -4748,7 +4882,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
"item_code": scrap_item,
|
||||
"item_name": scrap_item,
|
||||
"qty": 3,
|
||||
@@ -4769,7 +4903,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
self.assertEqual(len(secondary_items), 1)
|
||||
row = secondary_items[0]
|
||||
self.assertEqual(row.item_code, scrap_item)
|
||||
self.assertEqual(row.type, "Scrap")
|
||||
self.assertEqual(row.secondary_item_type, "Scrap")
|
||||
# data is fetched from the BOM (carries bom_qty)
|
||||
self.assertEqual(flt(row.bom_qty), 8.0)
|
||||
# qty = (bom_secondary_qty / bom_qty) * wo_qty = (3 / 8) * 20 = 7.5
|
||||
@@ -4796,7 +4930,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
bom.append(
|
||||
"secondary_items",
|
||||
{
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
"item_code": scrap_item,
|
||||
"item_name": scrap_item,
|
||||
"qty": 3,
|
||||
@@ -4825,7 +4959,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 8))
|
||||
manufacture_entry.submit()
|
||||
|
||||
generated_row = next(row for row in manufacture_entry.items if row.type == "Scrap")
|
||||
generated_row = next(row for row in manufacture_entry.items if row.secondary_item_type == "Scrap")
|
||||
|
||||
wo_order.reload()
|
||||
secondary_items = wo_order.secondary_items
|
||||
|
||||
@@ -88,7 +88,7 @@ frappe.ui.form.on("Work Order", {
|
||||
return frm.doc.qty == doc.completed_qty ? "green" : "orange";
|
||||
});
|
||||
|
||||
frm.fields_dict["non_stock_items"].grid.set_column_disp_in_list_view("type", false);
|
||||
frm.fields_dict["non_stock_items"].grid.set_column_disp_in_list_view("secondary_item_type", false);
|
||||
frm.fields_dict["secondary_items"].grid.set_column_disp_in_list_view("rate", false);
|
||||
},
|
||||
|
||||
@@ -1021,6 +1021,15 @@ erpnext.work_order = {
|
||||
return flt(max, precision("qty"));
|
||||
},
|
||||
|
||||
get_pending_operation_process_loss: (frm) => {
|
||||
if (!(frm.doc.operations || []).length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total_loss = Math.max(...frm.doc.operations.map((row) => flt(row.process_loss_qty)));
|
||||
return flt(Math.max(total_loss - flt(frm.doc.process_loss_qty), 0), precision("qty"));
|
||||
},
|
||||
|
||||
show_disassembly_prompt: function (frm) {
|
||||
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
|
||||
|
||||
@@ -1077,6 +1086,11 @@ erpnext.work_order = {
|
||||
|
||||
show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) {
|
||||
let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty;
|
||||
if (purpose === "Manufacture") {
|
||||
max = flt(Math.max(max - flt(frm.doc.process_loss_qty), 0), precision("qty"));
|
||||
}
|
||||
const pending_process_loss =
|
||||
purpose === "Manufacture" ? this.get_pending_operation_process_loss(frm) : 0;
|
||||
|
||||
let fields = [
|
||||
{
|
||||
@@ -1085,23 +1099,36 @@ erpnext.work_order = {
|
||||
fieldname: "qty",
|
||||
description: __("Max: {0}", [max]),
|
||||
default: max,
|
||||
onchange: function () {
|
||||
if (pending_process_loss && frm.qty_prompt) {
|
||||
frm.qty_prompt.set_value(
|
||||
"finished_good_qty",
|
||||
flt(Math.max(flt(this.value) - pending_process_loss, 0), precision("qty"))
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!additional_transfer_entry) {
|
||||
fields.push({
|
||||
fieldtype: "Check",
|
||||
label: __("Consider Process Loss"),
|
||||
fieldname: "consider_process_loss",
|
||||
default: 0,
|
||||
onchange: function () {
|
||||
if (this.value) {
|
||||
frm.qty_prompt.set_value("qty", max - frm.doc.process_loss_qty);
|
||||
} else {
|
||||
frm.qty_prompt.set_value("qty", max);
|
||||
}
|
||||
if (pending_process_loss) {
|
||||
fields.push(
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Process Loss Qty"),
|
||||
fieldname: "process_loss_qty",
|
||||
default: pending_process_loss,
|
||||
read_only: 1,
|
||||
description: __("Process loss booked against the operations of this work order."),
|
||||
},
|
||||
});
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Finished Good Qty"),
|
||||
fieldname: "finished_good_qty",
|
||||
default: flt(Math.max(max - pending_process_loss, 0), precision("qty")),
|
||||
read_only: 1,
|
||||
description: __("Actual quantity of the finished good that will be manufactured."),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -34,6 +34,10 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||
get_mins_between_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
|
||||
@@ -183,11 +187,14 @@ class WorkOrder(Document):
|
||||
.where(
|
||||
(parent.work_order == self.name)
|
||||
& (parent.docstatus == 1)
|
||||
& ((child.type != "") | (child.is_legacy_scrap_item == 1))
|
||||
& ((child.secondary_item_type != "") | (child.is_legacy_scrap_item == 1))
|
||||
)
|
||||
.select(
|
||||
child.item_code,
|
||||
Case().when(child.is_legacy_scrap_item == 1, "Scrap (Legacy)").else_(child.type).as_("type"),
|
||||
Case()
|
||||
.when(child.is_legacy_scrap_item == 1, "Scrap (Legacy)")
|
||||
.else_(child.secondary_item_type)
|
||||
.as_("secondary_item_type"),
|
||||
child.qty,
|
||||
child.uom,
|
||||
child.amount,
|
||||
@@ -203,7 +210,7 @@ class WorkOrder(Document):
|
||||
filters={"name": self.bom_no},
|
||||
fields=[
|
||||
"secondary_items.item_code",
|
||||
"secondary_items.type",
|
||||
"secondary_items.secondary_item_type",
|
||||
"secondary_items.qty",
|
||||
"secondary_items.uom",
|
||||
"secondary_items.cost as amount",
|
||||
@@ -573,7 +580,18 @@ class WorkOrder(Document):
|
||||
if not self.wip_warehouse and not self.skip_transfer:
|
||||
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
|
||||
if not self.fg_warehouse:
|
||||
self.fg_warehouse = frappe.get_cached_value("Company", self.company, "default_fg_warehouse")
|
||||
self.fg_warehouse = (
|
||||
frappe.get_cached_value("Company", self.company, "default_fg_warehouse")
|
||||
or self.get_production_item_warehouse()
|
||||
)
|
||||
|
||||
def get_production_item_warehouse(self):
|
||||
if not self.production_item:
|
||||
return None
|
||||
|
||||
return get_item_defaults(self.production_item, self.company).get(
|
||||
"default_warehouse"
|
||||
) or get_item_group_defaults(self.production_item, self.company).get("default_warehouse")
|
||||
|
||||
def check_wip_warehouse_skip(self):
|
||||
if self.skip_transfer and not self.from_wip_warehouse:
|
||||
@@ -681,11 +699,7 @@ class WorkOrder(Document):
|
||||
elif self.docstatus == 1:
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if (
|
||||
flt(self.material_transferred_for_manufacturing) > 0
|
||||
or self.skip_transfer
|
||||
or self._has_transferred_material()
|
||||
):
|
||||
if flt(self.material_transferred_for_manufacturing) > 0 or self._has_transferred_material():
|
||||
status = "In Process"
|
||||
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
@@ -720,29 +734,9 @@ class WorkOrder(Document):
|
||||
return status
|
||||
|
||||
def _has_transferred_material(self):
|
||||
"""True if any raw material transferred against this work order via a pick list or a
|
||||
material request is still, net of returns, in WIP (these leave
|
||||
material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
|
||||
"""True if any raw material transferred against this work order is still in WIP."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
mr_ste = frappe.qb.DocType("Stock Entry")
|
||||
mr_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
# Stock Entry only carries `material_request` at the child-row level, so a Stock
|
||||
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request against
|
||||
# this work order; the join to mr_ste keeps this scoped to this work order's entries
|
||||
# instead of scanning every Material-Request-linked row in the system.
|
||||
mr_sourced_stock_entries = (
|
||||
frappe.qb.from_(mr_child)
|
||||
.inner_join(mr_ste)
|
||||
.on(mr_ste.name == mr_child.parent)
|
||||
.select(mr_child.parent)
|
||||
.where(
|
||||
(mr_child.material_request.isnotnull())
|
||||
& (mr_ste.work_order == self.name)
|
||||
& (mr_ste.docstatus == 1)
|
||||
& (mr_ste.purpose == "Material Transfer for Manufacture")
|
||||
)
|
||||
)
|
||||
common_filters = (
|
||||
(ste.work_order == self.name)
|
||||
& (ste.docstatus == 1)
|
||||
@@ -753,11 +747,7 @@ class WorkOrder(Document):
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(
|
||||
common_filters
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
|
||||
)
|
||||
.where(common_filters & (ste.is_return == 0))
|
||||
).run()[0][0]
|
||||
# Returns don't carry their own pick_list/material_request reference, so net every
|
||||
# return against this work order to correctly clear WIP after a full return.
|
||||
@@ -1735,7 +1725,12 @@ class WorkOrder(Document):
|
||||
"allow_alternative_item": item.allow_alternative_item,
|
||||
"required_qty": item.qty,
|
||||
"source_warehouse": (
|
||||
self.source_warehouse or item.source_warehouse or item.default_warehouse
|
||||
self.source_warehouse
|
||||
or item.source_warehouse
|
||||
or item.default_warehouse
|
||||
or get_item_group_defaults(item.item_code, self.company).get(
|
||||
"default_warehouse"
|
||||
)
|
||||
)
|
||||
if not reset_source_warehouse
|
||||
else self.source_warehouse,
|
||||
@@ -1808,22 +1803,15 @@ class WorkOrder(Document):
|
||||
return frappe._dict({d.original_item or d.item_code: d.qty for d in data})
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
"""Set transferred quantity from the raw materials that have actually moved."""
|
||||
# Job Card transfers use the minimum completed quantity across operations.
|
||||
if self.operations and self.transfer_material_against == "Job Card":
|
||||
return
|
||||
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty(
|
||||
claimed_qty = self.get_transferred_or_manufactured_qty(
|
||||
"Material Transfer for Manufacture", "material_transferred_for_manufacturing"
|
||||
)
|
||||
if sum_fg_completed_qty:
|
||||
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||
return
|
||||
|
||||
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||
required_by_item = {}
|
||||
for row in self.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
@@ -1833,12 +1821,13 @@ class WorkOrder(Document):
|
||||
if not required_by_item:
|
||||
return
|
||||
|
||||
min_fraction = min(
|
||||
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_by_item,
|
||||
transferred_items,
|
||||
self.precision("required_qty", "required_items"),
|
||||
)
|
||||
min_fraction = min(min_fraction, 1.0)
|
||||
material_transferred = min_fraction * flt(self.qty)
|
||||
covered_qty = min_fraction * flt(self.qty)
|
||||
material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty))
|
||||
self.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||
|
||||
def update_qty_in_stock_reservation(self, row, transferred_qty, row_wise_serial_batch):
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
<hr style="margin: 15px -15px;">
|
||||
<p>
|
||||
{% if data.value %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ data.value }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ frappe.utils.escape_html(data.value) }}">
|
||||
{{ __("Open Work Order {0}", [data.value.bold()]) }}</a>
|
||||
{% endif %}
|
||||
{% if data.item_code %}
|
||||
<a class="btn btn-default btn-xs" href="#Form/Item/{{ data.item_code }}">
|
||||
<a class="btn btn-default btn-xs" href="#Form/Item/{{ frappe.utils.escape_html(data.item_code) }}">
|
||||
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -507,7 +507,7 @@ def get_workstations(**kwargs):
|
||||
|
||||
for d in data:
|
||||
d.workstation_name = get_link_to_form("Workstation", d.name)
|
||||
d.status_image = d.on_status_image
|
||||
d.status_image = frappe.utils.escape_html(d.on_status_image)
|
||||
d.workstation_off = ""
|
||||
d.color = color_map.get(d.status, "red")
|
||||
d.workstation_link = get_url_to_form("Workstation", d.name)
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<div class="form-dashboard-section qrcode-fields col-sm-12 section-body" style="padding-left: 0px;padding-right: 25px;"></div>
|
||||
|
||||
{% $.each(data, (idx, d) => { %}
|
||||
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{d.name}}">
|
||||
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<div class="row form-section" style="width:100%;margin-top:10px">
|
||||
<div class="form-column col-sm-2">
|
||||
<div class="frappe-control bold indicator {{d.status_colour}}" data-doctype="Job Card" data-name="{{d.name}}" title="{{__(d.status)}}">
|
||||
<div class="frappe-control bold indicator {{d.status_colour}}" data-doctype="Job Card" data-name="{{ frappe.utils.escape_html(d.name) }}" title="{{__(d.status)}}">
|
||||
{{ d.job_card_link }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,23 +52,23 @@
|
||||
{% } %} -->
|
||||
|
||||
</div>
|
||||
<div class="form-column col-sm-1 btn-start" data-job-card="{{d.name}}">
|
||||
<div class="form-column col-sm-1 btn-start" data-job-card="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<button class="btn btn-default">
|
||||
<span class="menu-item-label" data-label="Start">{{ __('Start') }}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="form-column col-sm-1 btn-resume" data-job-card="{{d.name}}">
|
||||
<div class="form-column col-sm-1 btn-resume" data-job-card="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<button class="btn btn-default ">
|
||||
<span class="menu-item-label" data-label="Start">{{ __('Resume') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-column col-sm-1 btn-pause" data-job-card="{{d.name}}">
|
||||
<div class="form-column col-sm-1 btn-pause" data-job-card="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<button class="btn btn-default">
|
||||
<span class="menu-item-label" data-label="Start">{{ __('Pause') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-column col-sm-1 btn-complete" data-job-card="{{d.name}}" data-qty="{{d.for_quantity}}">
|
||||
<div class="form-column col-sm-1 btn-complete" data-job-card="{{ frappe.utils.escape_html(d.name) }}" data-qty="{{ d.for_quantity }}">
|
||||
<button class="btn btn-default">
|
||||
<span class="menu-item-label" data-label="Start">{{ __('Complete') }}</span>
|
||||
</button>
|
||||
@@ -86,7 +86,7 @@
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right menu-actions" role="menu" data-job-card="{{d.name}}">
|
||||
<ul class="dropdown-menu dropdown-menu-right menu-actions" role="menu" data-job-card="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<li>
|
||||
<a class="grey-link dropdown-item btn-transfer-materials" href="#" onclick="return false;">
|
||||
<span class="menu-item-label" data-label="Transfer Materials">{{ __('Transfer Materials') }}</span>
|
||||
|
||||
@@ -268,22 +268,17 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
def get_detailed_view_chart_data(self, data):
|
||||
chart_data = frappe._dict({})
|
||||
i = 0
|
||||
|
||||
sorted_data = sorted(data, key=lambda x: getdate(x.get("delivery_date")))
|
||||
for row in sorted_data:
|
||||
if getdate(row.deliver_date) < getdate(today()):
|
||||
continue
|
||||
|
||||
if not row.delivery_date:
|
||||
continue
|
||||
|
||||
if i == 10:
|
||||
break
|
||||
if getdate(row.delivery_date) < getdate(today()):
|
||||
continue
|
||||
|
||||
delivery_date = formatdate(row.delivery_date, "dd MMM")
|
||||
delivery_date = getdate(row.delivery_date)
|
||||
if delivery_date not in chart_data:
|
||||
i += 1
|
||||
chart_data[delivery_date] = frappe._dict(
|
||||
{
|
||||
"demand": 0.0,
|
||||
@@ -299,6 +294,7 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
demand_data = []
|
||||
supply_data = []
|
||||
delivery_dates = list(chart_data)
|
||||
for row in chart_data:
|
||||
value = chart_data[row]
|
||||
|
||||
@@ -307,7 +303,7 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"labels": list(chart_data.keys()),
|
||||
"labels": self.get_detailed_chart_labels(delivery_dates),
|
||||
"datasets": [
|
||||
{
|
||||
"name": _("Demand"),
|
||||
@@ -325,6 +321,10 @@ class MaterialRequirementsPlanningReport:
|
||||
"title": _("Demand vs Supply"),
|
||||
}
|
||||
|
||||
def get_detailed_chart_labels(self, delivery_dates):
|
||||
date_format = "dd MMM yyyy" if len({date.year for date in delivery_dates}) > 1 else "dd MMM"
|
||||
return [formatdate(date, date_format) for date in delivery_dates]
|
||||
|
||||
def get_bucket_view_chart_data(self, data):
|
||||
chart_data = frappe._dict({})
|
||||
labels = []
|
||||
@@ -1298,7 +1298,7 @@ def get_item_capacity(item_code, bucket_size):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_order(selected_rows, company, warehouse=None, mps=None):
|
||||
def make_order(selected_rows: str | list, company: str, warehouse: str | None = None, mps: str | None = None):
|
||||
if not frappe.has_permission("Purchase Order", "create"):
|
||||
frappe.throw(_("Not permitted to make Purchase Orders"), frappe.PermissionError)
|
||||
|
||||
@@ -1308,16 +1308,33 @@ def make_order(selected_rows, company, warehouse=None, mps=None):
|
||||
if not frappe.db.exists("Company", company):
|
||||
frappe.throw(_("Company {0} does not exist").format(company))
|
||||
|
||||
qty_precision = frappe.get_precision("Purchase Order Item", "qty")
|
||||
purchase_orders = {}
|
||||
work_orders = []
|
||||
covered_rows = 0
|
||||
for row in selected_rows:
|
||||
row = frappe._dict(row)
|
||||
# what is left to order once stock and the orders already placed are counted. rounding
|
||||
# to the precision an order is stored in, so what is left of a covered row after all the
|
||||
# subtracting does not become an order line of its own
|
||||
if flt(row.required_qty, qty_precision) <= 0:
|
||||
covered_rows += 1
|
||||
continue
|
||||
|
||||
if row.type_of_material == "Purchase":
|
||||
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
|
||||
|
||||
if row.type_of_material == "Manufacture" and row.bom_no:
|
||||
work_orders.append(row)
|
||||
|
||||
if not purchase_orders and not work_orders:
|
||||
frappe.msgprint(
|
||||
_("Nothing to order, the selected rows are already covered by stock or existing orders")
|
||||
if covered_rows
|
||||
else _("Nothing to order from the selected rows")
|
||||
)
|
||||
return
|
||||
|
||||
if purchase_orders:
|
||||
make_purchase_orders(purchase_orders, company, warehouse=warehouse, mps=mps)
|
||||
|
||||
@@ -1327,13 +1344,7 @@ def make_order(selected_rows, company, warehouse=None, mps=None):
|
||||
|
||||
def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
|
||||
for (supplier, release_date), items in purchase_orders.items():
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.supplier = supplier
|
||||
po.company = company
|
||||
po.mps = mps
|
||||
po.transaction_date = release_date
|
||||
po.set("items", [])
|
||||
|
||||
po_items = []
|
||||
for item in items:
|
||||
uom = item.purchase_uom or item.uom
|
||||
if not uom:
|
||||
@@ -1346,23 +1357,33 @@ def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
|
||||
if flt(item.required_qty) < flt(item.min_order_qty):
|
||||
item.required_qty = item.min_order_qty
|
||||
|
||||
po.append(
|
||||
"items",
|
||||
po_items.append(
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"qty": item.required_qty,
|
||||
"uom": uom,
|
||||
"schedule_date": item.delivery_date if item.delivery_date else today(),
|
||||
"warehouse": warehouse or item.default_warehouse,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if len(po.items) > 0:
|
||||
po.insert()
|
||||
frappe.msgprint(
|
||||
_("Purchase Order {0} created").format(frappe.bold(po.name)),
|
||||
alert=True,
|
||||
)
|
||||
if not po_items:
|
||||
continue
|
||||
|
||||
po = frappe.new_doc("Purchase Order")
|
||||
po.supplier = supplier
|
||||
po.company = company
|
||||
po.mps = mps
|
||||
po.transaction_date = release_date
|
||||
po.set("items", po_items)
|
||||
|
||||
po.run_method("set_missing_values")
|
||||
po.insert()
|
||||
|
||||
frappe.msgprint(
|
||||
_("Purchase Order {0} created").format(frappe.bold(po.name)),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
|
||||
def make_work_orders(work_orders, company, warehouse=None, mps=None):
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.classes.context_managers import freeze_time
|
||||
from frappe.utils import add_days, flt, formatdate, today
|
||||
|
||||
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report import (
|
||||
MaterialRequirementsPlanningReport,
|
||||
execute,
|
||||
get_item_lead_time,
|
||||
make_order,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
WAREHOUSE = "_Test Warehouse - _TC"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
TAX_TEMPLATE = "_Test Purchase Taxes and Charges Template - _TC"
|
||||
WIP_WAREHOUSE = "_Test Warehouse 1 - _TC"
|
||||
|
||||
|
||||
class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
def test_detailed_chart_includes_full_date_range(self):
|
||||
with freeze_time("2026-08-12"):
|
||||
start_date = add_days(today(), 1)
|
||||
delivery_dates = [add_days(start_date, offset) for offset in range(12)]
|
||||
rows = [make_chart_row(delivery_date) for delivery_date in delivery_dates]
|
||||
rows.append(make_chart_row(delivery_dates[-1], planned_qty=2))
|
||||
|
||||
chart = MaterialRequirementsPlanningReport(frappe._dict()).get_detailed_view_chart_data(rows)
|
||||
|
||||
self.assertEqual(
|
||||
chart["data"]["labels"],
|
||||
[formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates],
|
||||
)
|
||||
self.assertEqual(chart["data"]["datasets"][0]["values"], [1] * 11 + [3])
|
||||
|
||||
def test_detailed_chart_distinguishes_delivery_dates_across_years(self):
|
||||
with freeze_time("2026-08-12"):
|
||||
delivery_dates = ["2026-08-15", "2027-08-15"]
|
||||
rows = [
|
||||
make_chart_row(delivery_dates[0]),
|
||||
make_chart_row(delivery_dates[1], planned_qty=2),
|
||||
]
|
||||
|
||||
chart = MaterialRequirementsPlanningReport(frappe._dict()).get_detailed_view_chart_data(rows)
|
||||
|
||||
self.assertEqual(
|
||||
chart["data"]["labels"],
|
||||
[formatdate(delivery_date, "dd MMM yyyy") for delivery_date in delivery_dates],
|
||||
)
|
||||
self.assertEqual(chart["data"]["datasets"][0]["values"], [1, 2])
|
||||
|
||||
def test_detailed_chart_excludes_past_and_empty_delivery_dates(self):
|
||||
with freeze_time("2026-08-12"):
|
||||
delivery_dates = [today(), add_days(today(), 1)]
|
||||
rows = [
|
||||
make_chart_row(add_days(today(), -1)),
|
||||
make_chart_row(None),
|
||||
*[make_chart_row(delivery_date) for delivery_date in delivery_dates],
|
||||
]
|
||||
|
||||
chart = MaterialRequirementsPlanningReport(frappe._dict()).get_detailed_view_chart_data(rows)
|
||||
|
||||
self.assertEqual(
|
||||
chart["data"]["labels"],
|
||||
[formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates],
|
||||
)
|
||||
|
||||
def test_manufacture_lead_time_is_not_int_truncated(self):
|
||||
"""lead_time = 1440 / manufacturing_time_in_mins + buffer_time. Both columns are Int;
|
||||
integer/integer division truncates on Postgres (1440/7 -> 205) while MariaDB yields a
|
||||
decimal, so the computed lead time (and the derived release date) diverged by engine."""
|
||||
item = make_item("_Test MRP Lead Time Item", {"is_stock_item": 1}).name
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Lead Time",
|
||||
"item_code": item,
|
||||
"manufacturing_time_in_mins": 7,
|
||||
"buffer_time": 2,
|
||||
}
|
||||
).insert()
|
||||
|
||||
lead_time = get_item_lead_time(item, "Manufacture")
|
||||
# 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207.
|
||||
self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2)
|
||||
|
||||
def test_make_order_creates_draft_purchase_and_work_orders(self):
|
||||
plan = make_mrp_plan(self)
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual(purchase_order.docstatus, 0)
|
||||
self.assertEqual(purchase_order.supplier, SUPPLIER)
|
||||
self.assertEqual([d.item_code for d in purchase_order.items], [plan.rm_item])
|
||||
self.assertEqual(purchase_order.items[0].qty, plan.planned_qty * plan.rm_qty)
|
||||
|
||||
work_order = get_created_order(plan.mps, "Work Order")
|
||||
self.assertEqual(work_order.docstatus, 0)
|
||||
self.assertEqual(work_order.production_item, plan.fg_item)
|
||||
self.assertEqual(work_order.bom_no, plan.bom)
|
||||
self.assertEqual(work_order.qty, plan.planned_qty)
|
||||
|
||||
def test_make_order_skips_rows_that_are_already_covered(self):
|
||||
"""
|
||||
A row whose requirement is met by stock or by an order placed earlier has nothing left
|
||||
to order. It must be left out instead of failing, and the rows beside it must still be
|
||||
created.
|
||||
"""
|
||||
plan = make_mrp_plan(self)
|
||||
covered_row, remaining_rows = plan.rows[0], plan.rows[1:]
|
||||
self.assertTrue(remaining_rows, msg="the plan needs a second row to order")
|
||||
covered_row.required_qty = 0
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
ordered_items = [
|
||||
row.item_code
|
||||
for doctype in ("Purchase Order", "Work Order")
|
||||
for order in frappe.get_all(doctype, filters={"mps": plan.mps}, pluck="name")
|
||||
for row in get_ordered_items(doctype, order)
|
||||
]
|
||||
self.assertNotIn(covered_row.item_code, ordered_items)
|
||||
self.assertEqual(sorted(ordered_items), sorted([row.item_code for row in remaining_rows]))
|
||||
|
||||
def test_make_order_creates_nothing_when_every_row_is_covered(self):
|
||||
plan = make_mrp_plan(self)
|
||||
for row in plan.rows:
|
||||
row.required_qty = 0
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
for doctype in ("Purchase Order", "Work Order"):
|
||||
self.assertFalse(frappe.get_all(doctype, filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_ignores_a_requirement_left_over_by_rounding(self):
|
||||
"""What is left of a covered row after subtracting is not a quantity worth ordering."""
|
||||
plan = make_mrp_plan(self)
|
||||
for row in plan.rows:
|
||||
row.required_qty = 0.0000000001
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
for doctype in ("Purchase Order", "Work Order"):
|
||||
self.assertFalse(frappe.get_all(doctype, filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_work_order_keeps_the_company_wip_warehouse(self):
|
||||
"""
|
||||
The item's own warehouse is where the finished goods go, the work in progress warehouse
|
||||
stays the one the company keeps for it.
|
||||
"""
|
||||
plan = make_mrp_plan(self)
|
||||
frappe.db.set_value("Company", COMPANY, "default_wip_warehouse", WIP_WAREHOUSE)
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
work_order = get_created_order(plan.mps, "Work Order")
|
||||
self.assertEqual(work_order.wip_warehouse, WIP_WAREHOUSE)
|
||||
|
||||
def test_purchase_order_gets_defaults_from_set_missing_values(self):
|
||||
plan = make_mrp_plan(self)
|
||||
make_tax_rule(tax_type="Purchase", purchase_tax_template=TAX_TEMPLATE, priority=1, save=1)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"item_code": plan.rm_item,
|
||||
"price_list": "Standard Buying",
|
||||
"price_list_rate": 100,
|
||||
}
|
||||
).insert()
|
||||
|
||||
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual(purchase_order.buying_price_list, "Standard Buying")
|
||||
self.assertEqual(purchase_order.items[0].rate, 100)
|
||||
template = frappe.get_doc("Purchase Taxes and Charges Template", TAX_TEMPLATE)
|
||||
self.assertEqual(purchase_order.taxes_and_charges, TAX_TEMPLATE)
|
||||
self.assertEqual([d.rate for d in purchase_order.taxes], [d.rate for d in template.taxes])
|
||||
|
||||
net_total = flt(purchase_order.net_total)
|
||||
self.assertEqual(
|
||||
purchase_order.grand_total, net_total + net_total * flt(template.taxes[0].rate) / 100
|
||||
)
|
||||
|
||||
|
||||
def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
|
||||
"""Build a finished good with a submitted BOM and an MPS demanding it, then return the
|
||||
report's own output rows -- the same payload the report's client sends to `make_order`."""
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [
|
||||
{"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER}
|
||||
],
|
||||
}
|
||||
).name
|
||||
fg_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
}
|
||||
).name
|
||||
|
||||
# on_submit sets Item.default_bom, which is how the report finds the raw materials
|
||||
bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=rm_qty, rate=100).name
|
||||
|
||||
mps = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Master Production Schedule",
|
||||
"company": COMPANY,
|
||||
"posting_date": today(),
|
||||
"from_date": today(),
|
||||
"parent_warehouse": WAREHOUSE,
|
||||
"items": [
|
||||
{
|
||||
"item_code": fg_item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"delivery_date": add_days(today(), 30),
|
||||
"planned_qty": planned_qty,
|
||||
"uom": frappe.get_cached_value("Item", fg_item, "stock_uom"),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
# left in draft: on_submit enqueues MRP Log creation in a background job
|
||||
mps.insert()
|
||||
|
||||
_, data, _, _ = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"from_date": today(),
|
||||
"to_date": add_days(today(), 90),
|
||||
"warehouse": WAREHOUSE,
|
||||
"mps": mps.name,
|
||||
"type_of_material": "All",
|
||||
"add_safety_stock": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# the report separates each finished good with a blank row
|
||||
rows = [row for row in data if row.get("item_code")]
|
||||
test_case.assertTrue(rows, msg="the report returned no rows to create orders from")
|
||||
|
||||
return frappe._dict(
|
||||
rm_item=rm_item,
|
||||
fg_item=fg_item,
|
||||
bom=bom,
|
||||
mps=mps.name,
|
||||
planned_qty=planned_qty,
|
||||
rm_qty=rm_qty,
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
def get_ordered_items(doctype, order):
|
||||
child_doctype = "Purchase Order Item" if doctype == "Purchase Order" else None
|
||||
if not child_doctype:
|
||||
return frappe.get_all(doctype, filters={"name": order}, fields=["production_item as item_code"])
|
||||
|
||||
return frappe.get_all(child_doctype, filters={"parent": order}, fields=["item_code"])
|
||||
|
||||
|
||||
def get_created_order(mps, doctype):
|
||||
names = frappe.get_all(doctype, filters={"mps": mps}, pluck="name")
|
||||
if len(names) != 1:
|
||||
frappe.throw(f"Expected exactly one {doctype} for {mps}, got {names}")
|
||||
|
||||
return frappe.get_doc(doctype, names[0])
|
||||
|
||||
|
||||
def make_chart_row(delivery_date, planned_qty=1):
|
||||
return frappe._dict(
|
||||
{
|
||||
"delivery_date": delivery_date,
|
||||
"planned_qty": planned_qty,
|
||||
"in_hand_qty": 0,
|
||||
"po_ordered_qty": 0,
|
||||
"wo_ordered_qty": 0,
|
||||
}
|
||||
)
|
||||
@@ -23,7 +23,10 @@ frappe.query_reports["Production Plan Summary"] = {
|
||||
|
||||
if (column.fieldname == "item_code") {
|
||||
var color = data.pending_qty > 0 ? "red" : "green";
|
||||
value = `<a style='color:${color}' href="/app/item/${data["item_code"]}" data-doctype="Item">${data["item_code"]}</a>`;
|
||||
value = `<a style='color:${color}' href="${frappe.utils.get_form_link(
|
||||
"Item",
|
||||
data["item_code"]
|
||||
)}" data-doctype="Item">${frappe.utils.escape_html(data["item_code"])}</a>`;
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -499,3 +499,6 @@ erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
|
||||
erpnext.patches.v16_0.repair_work_order_material_transfer
|
||||
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
||||
erpnext.patches.v16_0.rename_secondary_item_type_field
|
||||
|
||||
@@ -41,7 +41,7 @@ def insert_into_bom():
|
||||
"conversion_factor": 1,
|
||||
"qty": item.stock_qty,
|
||||
"is_legacy": 1,
|
||||
"type": "Scrap",
|
||||
"secondary_item_type": "Scrap",
|
||||
}
|
||||
)
|
||||
secondary_item.insert()
|
||||
@@ -49,7 +49,14 @@ def insert_into_bom():
|
||||
|
||||
def insert_into_job_card():
|
||||
fields = ["item_code", "item_name", "description", "stock_qty", "stock_uom"]
|
||||
bulk_insert("Job Card", "Job Card Scrap Item", "Job Card Secondary Item", fields, ["type"], ["Scrap"])
|
||||
bulk_insert(
|
||||
"Job Card",
|
||||
"Job Card Scrap Item",
|
||||
"Job Card Secondary Item",
|
||||
fields,
|
||||
["secondary_item_type"],
|
||||
["Scrap"],
|
||||
)
|
||||
|
||||
|
||||
def insert_into_subcontracting_inward():
|
||||
@@ -67,7 +74,7 @@ def insert_into_subcontracting_inward():
|
||||
"Subcontracting Inward Order Scrap Item",
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
fields,
|
||||
["type"],
|
||||
["secondary_item_type"],
|
||||
["Scrap"],
|
||||
)
|
||||
|
||||
|
||||
27
erpnext/patches/v16_0/remove_frappe_crm_custom_fields.py
Normal file
27
erpnext/patches/v16_0/remove_frappe_crm_custom_fields.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import delete_custom_fields
|
||||
|
||||
from erpnext.crm.doctype.crm_settings.crm_settings import CRMSettings
|
||||
|
||||
|
||||
def execute():
|
||||
"""Delete the `crm_deal` fields on Quotation and Customer if Frappe CRM Data Synchronization is disabled and there's no data on those fields."""
|
||||
|
||||
crm_deal_exists_in_quotation = frappe.db.has_column("Quotation", "crm_deal") and frappe.get_all(
|
||||
"Quotation", filters={"crm_deal": ["is", "set"]}, limit=1
|
||||
)
|
||||
|
||||
crm_deal_exists_in_customer = frappe.db.has_column("Customer", "crm_deal") and frappe.get_all(
|
||||
"Customer", filters={"crm_deal": ["is", "set"]}, limit=1
|
||||
)
|
||||
|
||||
enable_frappe_crm_data_sync = frappe.get_single_value(
|
||||
"CRM Settings", "enable_frappe_crm_data_synchronization"
|
||||
)
|
||||
|
||||
if enable_frappe_crm_data_sync or crm_deal_exists_in_quotation or crm_deal_exists_in_customer:
|
||||
return
|
||||
|
||||
custom_fields = CRMSettings.get_frappe_crm_custom_fields()
|
||||
|
||||
delete_custom_fields(custom_fields)
|
||||
18
erpnext/patches/v16_0/rename_secondary_item_type_field.py
Normal file
18
erpnext/patches/v16_0/rename_secondary_item_type_field.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import frappe
|
||||
from frappe.model.utils.rename_field import rename_field
|
||||
|
||||
|
||||
def execute():
|
||||
doctypes = [
|
||||
"BOM Secondary Item",
|
||||
"Job Card Secondary Item",
|
||||
"Stock Entry Detail",
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
"Subcontracting Receipt Item",
|
||||
]
|
||||
|
||||
for doctype in doctypes:
|
||||
if not frappe.db.has_column(doctype, "type"):
|
||||
continue
|
||||
|
||||
rename_field(doctype, "type", "secondary_item_type")
|
||||
66
erpnext/patches/v16_0/repair_work_order_material_transfer.py
Normal file
66
erpnext/patches/v16_0/repair_work_order_material_transfer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
updates = get_precision_affected_work_orders()
|
||||
frappe.db.bulk_update("Work Order", updates, update_modified=False)
|
||||
|
||||
|
||||
def get_precision_affected_work_orders():
|
||||
"""Return Work Orders whose components cover the plan at quantity precision."""
|
||||
work_orders = {}
|
||||
for row in _get_candidate_rows():
|
||||
work_order = work_orders.setdefault(
|
||||
row.work_order,
|
||||
{"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}},
|
||||
)
|
||||
item_code = row.item_code
|
||||
work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt(
|
||||
row.required_qty
|
||||
)
|
||||
work_order["transferred_qty"][item_code] = max(
|
||||
work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
return {
|
||||
name: {"material_transferred_for_manufacturing": values["qty"]}
|
||||
for name, values in work_orders.items()
|
||||
if get_minimum_material_coverage_fraction(
|
||||
values["required_qty"], values["transferred_qty"], precision
|
||||
)
|
||||
>= 1.0
|
||||
}
|
||||
|
||||
|
||||
def _get_candidate_rows():
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
required_item = frappe.qb.DocType("Work Order Item")
|
||||
return (
|
||||
frappe.qb.from_(work_order)
|
||||
.inner_join(required_item)
|
||||
.on(required_item.parent == work_order.name)
|
||||
.select(
|
||||
work_order.name.as_("work_order"),
|
||||
work_order.qty,
|
||||
required_item.item_code,
|
||||
required_item.required_qty,
|
||||
required_item.transferred_qty,
|
||||
)
|
||||
.where(
|
||||
(work_order.docstatus == 1)
|
||||
& (work_order.status.notin(["Stopped", "Closed", "Completed"]))
|
||||
& (fn.Coalesce(work_order.skip_transfer, 0) == 0)
|
||||
& (fn.Coalesce(work_order.track_semi_finished_goods, 0) == 0)
|
||||
& (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty)
|
||||
& (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card")
|
||||
& (required_item.include_item_in_manufacturing == 1)
|
||||
& (required_item.required_qty > 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
@@ -3,7 +3,7 @@
|
||||
{% for d in data %}
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<a class="small time-sheet-link" data-activity_type="{{ d.activity_type || "" }}">
|
||||
<a class="small time-sheet-link" data-activity_type="{{ frappe.utils.escape_html(d.activity_type || "") }}">
|
||||
{{ d.activity_type || __("Unknown") }}</a>
|
||||
</div>
|
||||
<div class="col-xs-8">
|
||||
|
||||
@@ -122,19 +122,35 @@ class Task(NestedSet):
|
||||
if not self.project or frappe.in_test:
|
||||
return
|
||||
|
||||
if project_end_date := frappe.db.get_value("Project", self.project, "expected_end_date"):
|
||||
project_end_date = getdate(project_end_date)
|
||||
for fieldname in ("exp_start_date", "exp_end_date", "act_start_date", "act_end_date"):
|
||||
task_date = self.get(fieldname)
|
||||
if task_date and date_diff(project_end_date, getdate(task_date)) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be after {2}'s Expected End Date.").format(
|
||||
frappe.bold(frappe.get_desk_link("Task", self.name)),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
frappe.bold(frappe.get_desk_link("Project", self.project)),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
project_start_date, project_end_date = frappe.db.get_value(
|
||||
"Project", self.project, ["expected_start_date", "expected_end_date"]
|
||||
)
|
||||
|
||||
for fieldname in ("exp_start_date", "exp_end_date", "act_start_date", "act_end_date"):
|
||||
task_date = self.get(fieldname)
|
||||
if not task_date:
|
||||
continue
|
||||
task_date = getdate(task_date)
|
||||
|
||||
if project_end_date and date_diff(getdate(project_end_date), task_date) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be after {2}'s Expected End Date.").format(
|
||||
get_link_to_form("Task", self.name),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
get_link_to_form("Project", self.project),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
|
||||
if project_start_date and date_diff(task_date, getdate(project_start_date)) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be before {2}'s Expected Start Date.").format(
|
||||
get_link_to_form("Task", self.name),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
get_link_to_form("Project", self.project),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
|
||||
def validate_status(self):
|
||||
if self.is_template and self.status != "Template":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"add_total_row": 1,
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2023-10-10 23:53:43.692067",
|
||||
"disabled": 0,
|
||||
@@ -10,7 +10,7 @@
|
||||
"is_standard": "Yes",
|
||||
"letter_head": "ALYF GmbH",
|
||||
"letterhead": null,
|
||||
"modified": "2023-10-11 00:58:30.639078",
|
||||
"modified": "2026-08-20 00:55:31.635014",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Timesheet Billing Summary",
|
||||
@@ -39,4 +39,4 @@
|
||||
"role": "Employee Self Service"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.query_report import get_filtered_data
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
VALUE_FIELDNAMES = ("hours", "billing_hours", "billing_amount")
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -9,8 +13,14 @@ def execute(filters=None):
|
||||
filters = frappe._dict(filters or {})
|
||||
columns = get_columns(filters, group_fieldname)
|
||||
|
||||
data = get_data(filters, group_fieldname)
|
||||
return columns, data
|
||||
data = get_data(filters)
|
||||
data = get_filtered_data("Timesheet", columns, data, frappe.session.user)
|
||||
report_summary = get_report_summary(data)
|
||||
|
||||
if group_fieldname:
|
||||
data = group_by(data, group_fieldname)
|
||||
|
||||
return columns, data, None, None, report_summary, 1
|
||||
|
||||
|
||||
def get_columns(filters, group_fieldname=None):
|
||||
@@ -39,13 +49,12 @@ def get_columns(filters, group_fieldname=None):
|
||||
},
|
||||
}
|
||||
columns = []
|
||||
if group_fieldname:
|
||||
columns.append(group_columns.get(group_fieldname))
|
||||
columns.extend(
|
||||
column for column in group_columns.values() if column.get("fieldname") != group_fieldname
|
||||
)
|
||||
else:
|
||||
columns.extend(group_columns.values())
|
||||
if group_fieldname in group_columns:
|
||||
# the grouped column labels the group rows: keep it visible even when it is filtered too
|
||||
group_columns[group_fieldname]["hidden"] = 0
|
||||
columns.append(group_columns.pop(group_fieldname))
|
||||
|
||||
columns.extend(group_columns.values())
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
@@ -81,7 +90,7 @@ def get_columns(filters, group_fieldname=None):
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(filters, group_fieldname=None):
|
||||
def get_data(filters):
|
||||
_filters = []
|
||||
if filters.get("employee"):
|
||||
_filters.append(("employee", "=", filters.get("employee")))
|
||||
@@ -90,7 +99,7 @@ def get_data(filters, group_fieldname=None):
|
||||
if filters.get("from_date"):
|
||||
_filters.append(("Timesheet Detail", "from_time", ">=", filters.get("from_date")))
|
||||
if filters.get("to_date"):
|
||||
_filters.append(("Timesheet Detail", "to_time", "<=", filters.get("to_date")))
|
||||
_filters.append(("Timesheet Detail", "from_time", "<", add_days(getdate(filters.get("to_date")), 1)))
|
||||
if not filters.get("include_draft_timesheets"):
|
||||
_filters.append(("docstatus", "=", DocStatus.submitted()))
|
||||
else:
|
||||
@@ -112,13 +121,13 @@ def get_data(filters, group_fieldname=None):
|
||||
order_by="`tabTimesheet Detail`.from_time",
|
||||
)
|
||||
|
||||
return group_by(data, group_fieldname) if group_fieldname else data
|
||||
return data
|
||||
|
||||
|
||||
def group_by(data, fieldname):
|
||||
groups = {}
|
||||
for row in data:
|
||||
groups.setdefault(row.get(fieldname), []).append(row)
|
||||
groups.setdefault(get_group_value(row, fieldname), []).append(row)
|
||||
|
||||
grouped_data = []
|
||||
for group in sorted(groups, key=lambda g: (g is None, g)):
|
||||
@@ -150,3 +159,40 @@ def group_by(data, fieldname):
|
||||
grouped_data.extend(child_rows)
|
||||
|
||||
return grouped_data
|
||||
|
||||
|
||||
def get_group_value(row, fieldname):
|
||||
value = row.get(fieldname)
|
||||
# `date` is `Timesheet Detail.from_time`, a datetime: everything logged on a day is one group
|
||||
return getdate(value) if fieldname == "date" and value else value
|
||||
|
||||
|
||||
def get_report_summary(data):
|
||||
if not data:
|
||||
return None
|
||||
|
||||
totals = dict.fromkeys(VALUE_FIELDNAMES, 0.0)
|
||||
for row in data:
|
||||
for value_fieldname in VALUE_FIELDNAMES:
|
||||
totals[value_fieldname] += row.get(value_fieldname) or 0
|
||||
|
||||
return [
|
||||
{
|
||||
"value": totals["hours"],
|
||||
"indicator": "Blue",
|
||||
"label": _("Total Working Hours"),
|
||||
"datatype": "Float",
|
||||
},
|
||||
{
|
||||
"value": totals["billing_hours"],
|
||||
"indicator": "Blue",
|
||||
"label": _("Total Billing Hours"),
|
||||
"datatype": "Float",
|
||||
},
|
||||
{
|
||||
"value": totals["billing_amount"],
|
||||
"indicator": "Green",
|
||||
"label": _("Total Billing Amount"),
|
||||
"datatype": "Currency",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
const NOT_APPLICABLE_TAX = "N/A";
|
||||
|
||||
// Per-charge_type base resolvers, mirror of the `erpnext_taxable_base_resolvers`
|
||||
// server hook. A localization registers `fn(calc, item, tax)` returning the per-item
|
||||
// base, so the client preview matches the server for custom charge types.
|
||||
erpnext.taxable_base_resolvers = erpnext.taxable_base_resolvers || {};
|
||||
|
||||
erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
setup() {
|
||||
this.fetch_round_off_accounts();
|
||||
@@ -278,32 +283,32 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
$.each(this.frm.doc.items || [], function (n, item) {
|
||||
item._unrounded_net_amount = null;
|
||||
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
|
||||
var cumulated_tax_fraction = 0.0;
|
||||
var total_inclusive_tax_amount_per_qty = 0;
|
||||
var total_tax_slope = 0.0;
|
||||
var total_tax_intercept = 0;
|
||||
$.each(me.frm.doc["taxes"] || [], function (i, tax) {
|
||||
var current_tax_fraction = me.get_current_tax_fraction(tax, item_tax_map);
|
||||
tax.tax_fraction_for_current_item = current_tax_fraction[0];
|
||||
var inclusive_tax_amount_per_qty = current_tax_fraction[1];
|
||||
var tax_contribution = me.get_current_tax_fraction(tax, item_tax_map, item);
|
||||
tax.tax_fraction_for_current_item = tax_contribution[0];
|
||||
var tax_intercept_per_qty = tax_contribution[1];
|
||||
tax.inclusive_amount_per_qty = tax_intercept_per_qty;
|
||||
|
||||
if (i == 0) {
|
||||
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item;
|
||||
tax.grand_total_amount_per_qty = tax_intercept_per_qty;
|
||||
} else {
|
||||
var prev = me.frm.doc["taxes"][i - 1];
|
||||
tax.grand_total_fraction_for_current_item =
|
||||
me.frm.doc["taxes"][i - 1].grand_total_fraction_for_current_item +
|
||||
tax.tax_fraction_for_current_item;
|
||||
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item;
|
||||
tax.grand_total_amount_per_qty =
|
||||
flt(prev.grand_total_amount_per_qty) + tax_intercept_per_qty;
|
||||
}
|
||||
|
||||
cumulated_tax_fraction += tax.tax_fraction_for_current_item;
|
||||
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty);
|
||||
total_tax_slope += tax.tax_fraction_for_current_item;
|
||||
total_tax_intercept += tax_intercept_per_qty * flt(item.qty);
|
||||
});
|
||||
|
||||
if (
|
||||
!me.discount_amount_applied &&
|
||||
item.qty &&
|
||||
(total_inclusive_tax_amount_per_qty || cumulated_tax_fraction)
|
||||
) {
|
||||
var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty;
|
||||
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction);
|
||||
if (!me.discount_amount_applied && item.qty && (total_tax_intercept || total_tax_slope)) {
|
||||
var amount = flt(item.amount) - total_tax_intercept;
|
||||
item._unrounded_net_amount = amount / (1 + total_tax_slope);
|
||||
item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item));
|
||||
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
|
||||
|
||||
@@ -312,39 +317,53 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
});
|
||||
}
|
||||
|
||||
get_current_tax_fraction(tax, item_tax_map) {
|
||||
// Get tax fraction for calculating tax exclusive amount
|
||||
// from tax inclusive amount
|
||||
var current_tax_fraction = 0.0;
|
||||
var inclusive_tax_amount_per_qty = 0;
|
||||
get_current_tax_fraction(tax, item_tax_map, item) {
|
||||
// tax = slope * net + intercept.
|
||||
// Returns [slope, intercept_per_qty]
|
||||
var tax_slope = 0.0;
|
||||
var tax_intercept = 0;
|
||||
|
||||
if (cint(tax.included_in_print_rate)) {
|
||||
var tax_rate = this._get_tax_rate(tax, item_tax_map);
|
||||
|
||||
if (tax_rate === NOT_APPLICABLE_TAX) {
|
||||
return [current_tax_fraction, inclusive_tax_amount_per_qty];
|
||||
return [tax_slope, tax_intercept];
|
||||
}
|
||||
|
||||
if (tax.charge_type == "On Net Total") {
|
||||
current_tax_fraction = tax_rate / 100.0;
|
||||
tax_slope = tax_rate / 100.0;
|
||||
} else if (tax.charge_type == "On Previous Row Amount") {
|
||||
current_tax_fraction =
|
||||
(tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_fraction_for_current_item;
|
||||
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
|
||||
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item;
|
||||
tax_intercept = (tax_rate / 100.0) * flt(row.inclusive_amount_per_qty);
|
||||
} else if (tax.charge_type == "On Previous Row Total") {
|
||||
current_tax_fraction =
|
||||
(tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_fraction_for_current_item;
|
||||
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
|
||||
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item;
|
||||
tax_intercept = (tax_rate / 100.0) * flt(row.grand_total_amount_per_qty);
|
||||
} else if (tax.charge_type == "On Item Quantity") {
|
||||
inclusive_tax_amount_per_qty = flt(tax_rate);
|
||||
tax_intercept = flt(tax_rate);
|
||||
} else {
|
||||
// Custom charge_type: the rate applies to a resolved (fixed) base,
|
||||
// e.g. a tax on MRP included in the printed price.
|
||||
const qty = flt(item.qty) || 1;
|
||||
const base = this.get_item_taxable_base(item, tax);
|
||||
tax_intercept = ((tax_rate / 100.0) * base) / qty;
|
||||
}
|
||||
}
|
||||
|
||||
if (tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") {
|
||||
current_tax_fraction *= -1;
|
||||
inclusive_tax_amount_per_qty *= -1;
|
||||
tax_slope *= -1;
|
||||
tax_intercept *= -1;
|
||||
}
|
||||
return [current_tax_fraction, inclusive_tax_amount_per_qty];
|
||||
return [tax_slope, tax_intercept];
|
||||
}
|
||||
|
||||
get_item_taxable_base(item, tax) {
|
||||
// Mirror of the server get_item_taxable_base: a custom charge_type's resolver
|
||||
// overrides the base value; otherwise the net amount.
|
||||
const resolver = erpnext.taxable_base_resolvers[tax.charge_type];
|
||||
if (resolver) return flt(resolver(this, item, tax));
|
||||
return flt(item.net_amount);
|
||||
}
|
||||
|
||||
_get_tax_rate(tax, item_tax_map) {
|
||||
@@ -606,6 +625,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
} else if (tax.charge_type == "On Item Quantity") {
|
||||
// don't sum current net amount due to the field being a currency field
|
||||
current_tax_amount = tax_rate * item.qty;
|
||||
} else {
|
||||
// Custom charge_type: rate applies to the resolver-provided base.
|
||||
var resolved_base = this.get_item_taxable_base(item, tax);
|
||||
current_net_amount = resolved_base;
|
||||
current_tax_amount = (tax_rate / 100.0) * resolved_base;
|
||||
}
|
||||
|
||||
return [current_net_amount, current_tax_amount];
|
||||
|
||||
@@ -22,7 +22,10 @@ erpnext.stock.is_incoming_qi_purpose = (purpose) =>
|
||||
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
|
||||
erpnext.stock.secondary_item_purposes = ["Manufacture", "Repack", "Disassemble"];
|
||||
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
|
||||
if (erpnext.stock.secondary_item_purposes.includes(purpose) && (row.type || row.is_legacy_scrap_item))
|
||||
if (
|
||||
erpnext.stock.secondary_item_purposes.includes(purpose) &&
|
||||
(row.secondary_item_type || row.is_legacy_scrap_item)
|
||||
)
|
||||
return false;
|
||||
if (purpose === "Manufacture") return !!row.is_finished_item;
|
||||
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
|
||||
|
||||
@@ -12,6 +12,10 @@ $.extend(erpnext.queries, {
|
||||
return { query: "erpnext.controllers.queries.lead_query" };
|
||||
},
|
||||
|
||||
customer: function () {
|
||||
return { filters: { disabled: 0 } };
|
||||
},
|
||||
|
||||
item: function (filters) {
|
||||
var args = { query: "erpnext.controllers.queries.item_query" };
|
||||
if (filters) args["filters"] = filters;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="text-muted"> • {{ comment_when(creation) }}</span>
|
||||
</div>
|
||||
<span>
|
||||
<a class="action-btn" href="/app/call-log/{{ name }}" title="{{ __("Open Call Log") }}">
|
||||
<a class="action-btn" href="/app/call-log/{{ frappe.utils.escape_html(name) }}" title="{{ __("Open Call Log") }}">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-link-url" class="like-icon"></use>
|
||||
</svg>
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="margin-top">
|
||||
<audio
|
||||
controls
|
||||
src="{{ recording_url }}">
|
||||
src="{{ frappe.utils.escape_html(recording_url) }}">
|
||||
</audio>
|
||||
</div>
|
||||
{% } %}
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
<use href="#icon-small-message"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/todo/{{ tasks[i].name }}" title="{{ __('Open Task') }}">
|
||||
<a href="/app/todo/{{ frappe.utils.escape_html(tasks[i].name) }}" title="{{ __('Open Task') }}">
|
||||
{%= tasks[i].description %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" class="completion-checkbox"
|
||||
name="{{tasks[i].name}}" title="{{ __('Mark As Closed') }}">
|
||||
name="{{ frappe.utils.escape_html(tasks[i].name) }}" title="{{ __('Mark As Closed') }}">
|
||||
</div>
|
||||
</div>
|
||||
{% if(tasks[i].date) { %}
|
||||
@@ -89,7 +89,7 @@
|
||||
{% } %}
|
||||
<div class="pl-2 mb-2 mt-2">
|
||||
<div class="label-area font-md">
|
||||
<a href="/app/todo/{{ t.name }}" title="{{ __('Open Task') }}">
|
||||
<a href="/app/todo/{{ frappe.utils.escape_html(t.name) }}" title="{{ __('Open Task') }}">
|
||||
{%= t.description %}
|
||||
</a>
|
||||
</div>
|
||||
@@ -114,13 +114,13 @@
|
||||
<use href="#icon-{{ icon_set[events[i].event_category] || 'calendar' }}"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/event/{{ events[i].name }}" title="{{ __('Open Event') }}">
|
||||
{%= events[i].subject %}
|
||||
<a href="/app/event/{{ frappe.utils.escape_html(events[i].name) }}" title="{{ __('Open Event') }}">
|
||||
{%= frappe.utils.escape_html(events[i].subject) %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" class="completion-checkbox"
|
||||
name="{{ events[i].name }}" title="{{ __('Mark As Closed') }}">
|
||||
name="{{ frappe.utils.escape_html(events[i].name) }}" title="{{ __('Mark As Closed') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted ml-1">
|
||||
@@ -161,8 +161,8 @@
|
||||
<use href="#icon-{{ icon_set[event.event_category] || 'calendar' }}"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/event/{{ event.name }}" title="{{ __('Open Event') }}">
|
||||
{%= event.subject %}
|
||||
<a href="/app/event/{{ frappe.utils.escape_html(event.name) }}" title="{{ __('Open Event') }}">
|
||||
{%= frappe.utils.escape_html(event.subject) %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
|
||||
@@ -8,6 +8,16 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
? this.item.rejected_serial_and_batch_bundle
|
||||
: this.item.serial_and_batch_bundle;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.based_on = await erpnext.stock.get_pick_serial_batch_based_on();
|
||||
} catch (e) {
|
||||
this.based_on = "FIFO";
|
||||
}
|
||||
|
||||
this.make();
|
||||
this.render_data();
|
||||
}
|
||||
@@ -390,7 +400,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
{
|
||||
fieldtype: "Select",
|
||||
options: ["FIFO", "LIFO", "Expiry"],
|
||||
default: "FIFO",
|
||||
default: this.based_on,
|
||||
fieldname: "based_on",
|
||||
label: __("Fetch Based On"),
|
||||
onchange: () => this.get_auto_data(),
|
||||
@@ -536,7 +546,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
}
|
||||
|
||||
if (!based_on) {
|
||||
based_on = "FIFO";
|
||||
based_on = this.based_on;
|
||||
}
|
||||
|
||||
let warehouse = this.item.warehouse || this.item.s_warehouse;
|
||||
@@ -670,6 +680,27 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
frappe.throw(__("Rejected Warehouse and Accepted Warehouse cannot be same."));
|
||||
}
|
||||
|
||||
let qty_to_fetch = flt(this.dialog.get_value("qty"));
|
||||
let total_qty = entries.reduce((total, row) => total + (flt(row.qty) || 1.0), 0);
|
||||
|
||||
if (flt(total_qty, 6) !== flt(qty_to_fetch, 6)) {
|
||||
const confirm_dialog = frappe.confirm(
|
||||
__(
|
||||
"<strong>Total qty</strong> of the rows (<strong>{0}</strong>) does not match the <strong>Qty to Fetch</strong> (<strong>{1}</strong>). Qty of the item will be changed to <strong>{0}</strong>. Are you sure want to proceed?",
|
||||
[format_number(total_qty), format_number(qty_to_fetch)]
|
||||
),
|
||||
() => this.create_bundle_entries(entries, warehouse)
|
||||
);
|
||||
confirm_dialog.indicator = "blue";
|
||||
confirm_dialog.set_indicator();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.create_bundle_entries(entries, warehouse);
|
||||
}
|
||||
|
||||
create_bundle_entries(entries, warehouse) {
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers",
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
<Descrizione>{{ html2text(item.description or '') or item.item_name }}</Descrizione>
|
||||
<Quantita>{{ format_float(item.qty) }}</Quantita>
|
||||
<UnitaMisura>{{ item.stock_uom }}</UnitaMisura>
|
||||
{%- set item_unit_net_price = (item.price_list_rate / tax_divisor) or (item.net_rate) or (item.rate / tax_divisor) %}
|
||||
{%- set item_unit_net_price = ((item.price_list_rate or 0) / tax_divisor) or (item.net_rate) or (item.rate / tax_divisor) %}
|
||||
<PrezzoUnitario>{{ format_float(item_unit_net_price, item_meta.get_field("rate").precision) }}</PrezzoUnitario>
|
||||
{{ render_discount_or_margin(item, tax_divisor) }}
|
||||
<PrezzoTotale>{{ format_float(item.net_amount, item_meta.get_field("amount").precision) }}</PrezzoTotale>
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Currency and Price List",
|
||||
@@ -1136,7 +1137,7 @@
|
||||
"idx": 82,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 17:40:02.667637",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Quotation",
|
||||
|
||||
@@ -478,6 +478,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
|
||||
"fieldname": "currency_and_price_list",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_days": 1,
|
||||
@@ -1762,7 +1763,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-24 12:00:00.000000",
|
||||
"modified": "2026-08-12 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user