Merge pull request #58397 from frappe/version-16-hotfix

chore: release v16
This commit is contained in:
Diptanil Saha
2026-08-25 22:21:37 +05:30
committed by GitHub
159 changed files with 10788 additions and 3928 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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(

View File

@@ -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)

View File

@@ -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.

View File

@@ -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"]},
],
}

View File

@@ -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 () {

View File

@@ -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(

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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()

View File

@@ -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",

View File

@@ -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"]},
],
}

View File

@@ -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", []);
}
},
},
{

View File

@@ -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"):

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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 "#,###.##")

View File

@@ -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:

View File

@@ -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"):

View File

@@ -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)

View File

@@ -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",

View File

@@ -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):

View File

@@ -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):

View File

@@ -40,6 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
"options": "Supplier",
"reqd": 1
},

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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"))

View File

@@ -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")

View File

@@ -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")),

View File

@@ -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"):

View File

@@ -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(

View File

@@ -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)

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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":

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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
);

View File

@@ -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,

View File

@@ -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>

View File

@@ -45,7 +45,7 @@
"stock_qty": 1.0,
"rate": 2000.0,
"stock_uom": "_Test UOM",
"type": "Scrap",
"secondary_item_type": "Scrap",
"is_legacy": 1
}
],

View File

@@ -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",

View File

@@ -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

View File

@@ -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:

View File

@@ -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",
{

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View File

@@ -0,0 +1 @@

View File

@@ -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)

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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):

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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):

View File

@@ -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,
}
)

View File

@@ -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;

View File

@@ -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

View File

@@ -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"],
)

View 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)

View 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")

View 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)

View File

@@ -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">

View File

@@ -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":

View File

@@ -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"
}
]
}
}

View File

@@ -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",
},
]

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
{% } %}

View File

@@ -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">

View File

@@ -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",

View File

@@ -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>

View File

@@ -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",

View File

@@ -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