Merge branch 'develop' into party-import-tool-integrated-with-data-import-tool

This commit is contained in:
Sumit Jain
2026-08-09 20:51:08 +05:30
committed by GitHub
146 changed files with 4849 additions and 1471 deletions

View File

@@ -71,4 +71,6 @@ def get_shipping_address(company: str, address: str | None = None):
if address:
address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
return address_as_dict.get("name"), frappe.render_template(
address_template, address_as_dict, restrict_globals=True
)

View File

@@ -730,6 +730,8 @@ def get_company_default_account_fields():
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
"exchange_gain_account": "Exchange Gain Account",
"exchange_loss_account": "Exchange Loss Account",
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
"round_off_account": "Round Off Account",
"default_deferred_revenue_account": "Default Deferred Revenue Account",

View File

@@ -179,6 +179,9 @@
},
"Impairment": {
"account_category": "Operating Expenses"
},
"Exchange Loss": {
"account_category": "Operating Expenses"
}
},
"root_type": "Expense"
@@ -196,6 +199,10 @@
"account_type": "Income Account"
},
"Indirect Income": {
"Exchange Gain": {
"account_type": "Income Account",
"account_category": "Other Operating Income"
},
"account_type": "Income Account",
"is_group": 1
},

View File

@@ -138,6 +138,7 @@ def get():
_("Gain/Loss on Asset Disposal"): {"account_category": "Other Operating Income"},
_("Impairment"): {"account_category": "Operating Expenses"},
_("Tax Expense"): {"account_category": "Tax Expense"},
_("Exchange Loss"): {"account_category": "Operating Expenses"},
},
"root_type": "Expense",
},
@@ -149,6 +150,7 @@ def get():
_("Indirect Income"): {
_("Interest Income"): {"account_category": "Investment Income"},
_("Interest on Fixed Deposits"): {"account_category": "Investment Income"},
_("Exchange Gain"): {"account_category": "Other Operating Income"},
"is_group": 1,
},
"root_type": "Income",

View File

@@ -233,6 +233,7 @@ def get():
},
_("Impairment"): {"account_number": "5224", "account_category": "Operating Expenses"},
_("Tax Expense"): {"account_number": "5225", "account_category": "Tax Expense"},
_("Exchange Loss"): {"account_number": "5226", "account_category": "Operating Expenses"},
"account_number": "5200",
},
"root_type": "Expense",
@@ -250,6 +251,10 @@ def get():
"account_number": "4220",
"account_category": "Investment Income",
},
_("Exchange Gain"): {
"account_number": "4230",
"account_category": "Other Operating Income",
},
"is_group": 1,
"account_number": "4200",
},

View File

@@ -184,6 +184,7 @@ class JournalEntryReferenceValidator:
continue
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
self._validate_block_invoice(invoice)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
@@ -197,3 +198,15 @@ class JournalEntryReferenceValidator:
reference_type, reference_name, invoice.outstanding_amount
)
)
def _validate_block_invoice(self, invoice):
"""Payment cannnot be booked against blocked Purchase Invoices"""
if invoice.doctype != "Purchase Invoice":
return
if invoice.invoice_is_blocked():
frappe.throw(
_("{0} {1} is blocked and on hold until {2}.").format(
invoice.doctype, invoice.name, invoice.release_date
)
)

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
@@ -748,6 +748,69 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def make_jv_against_purchase_invoice(self, invoice, amount=100):
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
jv.accounts[0].party_type = "Supplier"
jv.accounts[0].party = invoice.supplier
jv.accounts[0].reference_type = "Purchase Invoice"
jv.accounts[0].reference_name = invoice.name
return jv
def test_jv_against_purchase_invoice_respects_hold_state(self):
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
release_date = add_days(nowdate(), 10)
def never_held():
return make_purchase_invoice()
def held_until_a_future_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
return invoice
def held_without_a_release_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Under dispute")
return invoice
def held_until_a_date_that_has_passed():
invoice = held_until_a_future_date()
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
return invoice
def unblocked_again():
invoice = held_until_a_future_date()
invoice.unblock_invoice()
return invoice
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
with self.subTest(build_invoice.__name__):
jv = self.make_jv_against_purchase_invoice(build_invoice())
self.assertRaisesRegex(frappe.ValidationError, "is blocked and on hold until", jv.insert)
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
with self.subTest(build_invoice.__name__):
invoice = build_invoice()
jv = self.make_jv_against_purchase_invoice(invoice)
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")

View File

@@ -950,6 +950,61 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_exchange_gain_loss_split_accounts(self):
gain_account = create_account(
account_name="_Test Exchange Gain",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
loss_account = create_account(
account_name="_Test Exchange Loss",
parent_account="Indirect Expenses - _TC",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_account", gain_account)
frappe.db.set_value("Company", "_Test Company", "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "exchange_loss_account", "")
si_gain = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=50,
)
pe_gain = get_payment_entry("Sales Invoice", si_gain.name, bank_account="_Test Bank USD - _TC")
pe_gain.reference_no = "1"
pe_gain.reference_date = "2016-01-01"
pe_gain.source_exchange_rate = 55
pe_gain.save()
self.assertEqual(pe_gain.references[0].exchange_gain_loss, 500)
pe_gain.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_gain.name), gain_account)
si_loss = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=55,
)
pe_loss = get_payment_entry("Sales Invoice", si_loss.name, bank_account="_Test Bank USD - _TC")
pe_loss.reference_no = "2"
pe_loss.reference_date = "2016-01-01"
pe_loss.source_exchange_rate = 50
pe_loss.save()
self.assertEqual(pe_loss.references[0].exchange_gain_loss, -500)
pe_loss.submit()
self.assertEqual(self.get_gain_loss_journal_account(pe_loss.name), loss_account)
def get_gain_loss_journal_account(self, payment_entry_name: str) -> str | None:
return frappe.db.get_value(
"Journal Entry Account",
{"reference_type": "Payment Entry", "reference_name": payment_entry_name, "docstatus": 1},
"account",
)
def test_payment_entry_against_sales_invoice_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center

View File

@@ -18,6 +18,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
is_any_doc_running,
)
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
@@ -485,9 +486,6 @@ class PaymentReconciliation(Document):
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
)
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
default_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
entries = []
for pay in args.get("payments"):
@@ -507,7 +505,10 @@ class PaymentReconciliation(Document):
pay["exchange_rate"] = invoice_exchange_map.get(pay.get("reference_name"))
res.difference_amount = self.get_difference_amount(pay, inv, res["allocated_amount"])
res.difference_account = default_exchange_gain_loss_account
is_gain = (
res.difference_amount > 0 if self.party_type == "Customer" else res.difference_amount < 0
)
res.difference_account = get_exchange_gain_loss_account(self.company, is_gain)
res.exchange_rate = inv.get("exchange_rate")
res.update({"gain_loss_posting_date": pay.get("posting_date")})
if not pay.get("is_advance"):

View File

@@ -6,6 +6,7 @@ import frappe
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -187,6 +188,53 @@ class TestPaymentReconciliation(ERPNextTestSuite):
)
return je
def setup_split_exchange_accounts(self):
gain_account = create_account(
account_name="_Test PR Split Exchange Gain",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
loss_account = create_account(
account_name="_Test PR Split Exchange Loss",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
frappe.db.set_value("Company", self.company, "exchange_gain_account", gain_account)
frappe.db.set_value("Company", self.company, "exchange_loss_account", loss_account)
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_gain_account", "")
self.addCleanup(frappe.db.set_value, "Company", self.company, "exchange_loss_account", "")
return gain_account, loss_account
def create_foreign_currency_sales_invoice(self, conversion_rate):
si = self.create_sales_invoice(
qty=1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
si.customer = self.customer_usd
si.currency = "USD"
si.conversion_rate = conversion_rate
si.debit_to = self.debtors_usd
si.save().submit()
return si
def create_foreign_currency_journal_payment(self, debtors_account, exchange_rate):
je = self.create_journal_entry(self.bank, debtors_account, 100, nowdate())
je.multi_currency = 1
je.accounts[0].exchange_rate = 1
je.accounts[0].credit_in_account_currency = 0
je.accounts[0].credit = 0
je.accounts[0].debit_in_account_currency = 100 * exchange_rate
je.accounts[0].debit = 100 * exchange_rate
je.accounts[1].party_type = "Customer"
je.accounts[1].party = self.customer_usd
je.accounts[1].exchange_rate = exchange_rate
je.accounts[1].credit_in_account_currency = 100
je.accounts[1].credit = 100 * exchange_rate
je.accounts[1].debit_in_account_currency = 0
je.accounts[1].debit = 0
je.save()
je.submit()
return je
def test_voucher_outstanding_metadata_comes_from_one_ledger_entry(self):
"""cost_center and remarks must describe the same Payment Ledger Entry.
@@ -956,6 +1004,85 @@ class TestPaymentReconciliation(ERPNextTestSuite):
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
def test_exchange_gain_loss_split_default_account(self):
gain_account, loss_account = self.setup_split_exchange_accounts()
self.create_foreign_currency_sales_invoice(conversion_rate=80)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=85)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, 500)
self.assertEqual(pr.allocation[0].difference_account, gain_account)
pr.reconcile()
self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
def test_payment_reconciliation_difference_account_override(self):
_, loss_account = self.setup_split_exchange_accounts()
override_account = create_account(
account_name="_Test PR Override Exchange Account",
parent_account="Indirect Expenses - _TC",
company=self.company,
)
si = self.create_foreign_currency_sales_invoice(conversion_rate=85)
self.create_foreign_currency_journal_payment(self.debtors_usd, exchange_rate=80)
pr = self.create_payment_reconciliation()
pr.party = self.customer_usd
pr.receivable_payable_account = self.debtors_usd
pr.get_unreconciled_entries()
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Default, computed from the split company fields, is pre-filled onto the row...
self.assertEqual(pr.allocation[0].difference_amount, -500)
self.assertEqual(pr.allocation[0].difference_account, loss_account)
# ...but the user can override it in the "Select Difference Account" dialog before reconciling,
# and that explicit choice must be what actually gets booked, not the computed default.
pr.allocation[0].difference_account = override_account
pr.reconcile()
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_usd, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
gain_loss_line_account = frappe.db.get_value(
"Journal Entry Account",
{"parent": jea_parent.parent, "account": ["!=", self.debtors_usd]},
"account",
)
self.assertEqual(gain_loss_line_account, override_account)
def test_difference_amount_via_negative_debit_or_credit_journal_entry(self):
# Make Sale Invoice
si = self.create_sales_invoice(

View File

@@ -640,7 +640,7 @@ class PaymentRequest(Document):
}
if self.message:
return frappe.render_template(self.message, context)
return frappe.render_template(self.message, context, restrict_globals=True)
def set_failed(self):
pass

View File

@@ -259,6 +259,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -888,7 +889,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -240,10 +240,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
unblock_invoice() {
const me = this;
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
args: { name: me.frm.doc.name },
callback: (r) => me.frm.reload_doc(),
me.frm.call("unblock_invoice", null, () => {
me.frm.reload_doc();
});
}
@@ -294,15 +292,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.dialog.set_primary_action(__("Save"), function () {
const dialog_data = me.dialog.get_values();
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
args: {
name: me.frm.doc.name,
me.frm.call(
"block_invoice",
{
hold_comment: dialog_data.hold_comment,
release_date: dialog_data.release_date,
},
callback: (r) => me.frm.reload_doc(),
});
() => {
me.frm.reload_doc();
}
);
me.dialog.hide();
});
@@ -341,10 +340,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
set_release_date(data) {
return frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
args: data,
callback: (r) => this.frm.reload_doc(),
const me = this;
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
me.frm.reload_doc();
});
}

View File

@@ -360,6 +360,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.on_hold",
"depends_on": "eval:doc.on_hold",
"fieldname": "sb_14",
"fieldtype": "Section Break",
"label": "Hold Invoice"
@@ -1694,7 +1695,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-07-12 23:54:21.263951",
"modified": "2026-08-05 15:40:16.519774",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
@@ -306,6 +306,9 @@ class PurchaseInvoice(BuyingController):
PurchaseTaxWithholding(self).on_validate()
self.set_percentage_received()
if self.on_hold:
self.validate_invoice_hold()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
@@ -317,6 +320,13 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
if self.docstatus < 1:
frappe.throw(_("Purchase Invoice can be held after submitting."))
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
frappe.throw(_("Release date must be in the future"))
@@ -820,14 +830,38 @@ class PurchaseInvoice(BuyingController):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
def block_invoice(self, hold_comment=None, release_date=None):
self.db_set("on_hold", 1)
self.db_set("hold_comment", cstr(hold_comment))
@frappe.whitelist(methods=["POST"])
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
self.on_hold = 1
self.release_date = release_date
self.validate_block_invoice()
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
@frappe.whitelist(methods=["POST"])
def unblock_invoice(self):
self.check_permission("write")
self.db_set({"on_hold": 0, "release_date": None})
@frappe.whitelist(methods=["POST"])
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
if not self.on_hold:
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
self.release_date = release_date
self.validate_block_invoice()
self.db_set("release_date", release_date)
def unblock_invoice(self):
self.db_set("on_hold", 0)
self.db_set("release_date", None)
def validate_block_invoice(self):
self.validate_invoice_hold()
if self.outstanding_amount <= 0:
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
self.validate_release_date()
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
@@ -925,24 +959,3 @@ def get_list_context(context=None):
@erpnext.allow_regional
def make_regional_gl_entries(gl_entries, doc):
return gl_entries
@frappe.whitelist()
def change_release_date(name: str, release_date: str | None = None):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()
def unblock_invoice(name: str):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.unblock_invoice()
@frappe.whitelist()
def block_invoice(name: str, release_date: str, hold_comment: str | None = None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)

View File

@@ -278,14 +278,166 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_purchase_invoice_explicit_block(self):
pi = make_purchase_invoice()
pi.block_invoice()
release_date = add_days(nowdate(), 10)
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
self.assertEqual(pi.on_hold, 1)
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
)
self.assertEqual(on_hold, 1)
self.assertEqual(hold_comment, "Waiting for the goods")
self.assertEqual(getdate(saved_release_date), getdate(release_date))
pi.unblock_invoice()
self.assertEqual(pi.on_hold, 0)
on_hold, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
)
self.assertEqual(on_hold, 0)
self.assertIsNone(saved_release_date)
def test_purchase_invoice_cannot_be_held_before_submission(self):
pi = make_purchase_invoice(do_not_save=True)
pi.on_hold = 1
self.assertRaises(frappe.ValidationError, pi.save)
pi.on_hold = 0
pi.save()
pi.submit()
pi.block_invoice()
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
def test_return_purchase_invoice_cannot_be_held(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.on_hold = 1
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
return_pi.on_hold = 0
return_pi.save()
return_pi.submit()
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
# a return has a negative outstanding amount, which must not be mistaken
# for an invalid hold on a document that was never held
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save()
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
self.assertEqual(return_pi.on_hold, 0)
self.assertLess(return_pi.outstanding_amount, 0)
def test_settled_purchase_invoice_cannot_be_held(self):
pi = make_purchase_invoice()
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.save()
pe.submit()
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
self.assertRaises(frappe.ValidationError, pi.block_invoice)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_release_date_of_held_invoice_must_be_in_future(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
def test_rejected_hold_does_not_partially_update_invoice(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
pi.reload()
self.assertEqual(pi.on_hold, 0)
self.assertIsNone(pi.release_date)
def test_change_release_date_of_held_invoice(self):
pi = make_purchase_invoice()
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
new_release_date = add_days(nowdate(), 20)
pi.change_release_date(new_release_date)
self.assertEqual(
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
getdate(new_release_date),
)
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
pi = make_purchase_invoice()
self.assertRaisesRegex(
frappe.ValidationError,
"Invoice is not blocked",
pi.change_release_date,
add_days(nowdate(), 10),
)
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
def test_hold_methods_are_whitelisted_document_methods(self):
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
pi = frappe.new_doc("Purchase Invoice")
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
# raises if the method is not whitelisted for client side calls
pi.is_whitelisted(method)
self.assertFalse(
hasattr(purchase_invoice_module, method),
f"{method} should only be exposed as a document method",
)
def test_hold_methods_require_write_permission(self):
pi = make_purchase_invoice()
user = "test_pi_hold_permission@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Test PI Hold",
"roles": [{"role": "Employee"}],
}
).insert(ignore_permissions=True)
frappe.set_user(user)
try:
self.assertRaises(frappe.PermissionError, pi.block_invoice)
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
finally:
frappe.set_user("Administrator")
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",

View File

@@ -241,6 +241,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1032,7 +1033,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -114,6 +114,14 @@ class TestSalesInvoice(ERPNextTestSuite):
si.save()
self.assertEqual(si.items[0].qty, 1)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
invoice, since invoices post to the GL (unlike Sales Order)."""
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
self.assertRaises(frappe.ValidationError, si.save)
def test_timestamp_change(self):
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
w.docstatus = 0

View File

@@ -249,6 +249,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -1066,7 +1067,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 16:13:49.623613",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account (Standard)",
"name": "Party Account - Accounts",
"owner": "Administrator"
}

View File

@@ -27,6 +27,6 @@
"modified": "2026-07-10 11:26:57.841200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry (Standard)",
"name": "Payment Entry - Accounts",
"owner": "Administrator"
}

View File

@@ -71,6 +71,6 @@
"modified": "2026-07-20 15:56:46.025286",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice (Standard)",
"name": "Purchase Invoice - Accounts",
"owner": "Administrator"
}

View File

@@ -63,6 +63,6 @@
"modified": "2026-07-20 15:32:43.080034",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice (Standard)",
"name": "Sales Invoice - Accounts",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-09 15:08:57.487184",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription (Standard)",
"name": "Subscription - Accounts",
"owner": "Administrator"
}

View File

@@ -24,6 +24,11 @@ class TestAccountBalance(ERPNextTestSuite):
"currency": "EUR",
"balance": -100.0,
},
{
"account": "Exchange Gain - _TC2",
"currency": "EUR",
"balance": 0.0,
},
{
"account": "Income - _TC2",
"currency": "EUR",

View File

@@ -16,6 +16,11 @@ from erpnext.stock.get_item_details import (
get_conversion_factor,
get_item_warehouse_,
)
from erpnext.stock.utils import (
is_group_warehouse,
validate_disabled_warehouse,
validate_warehouse_company,
)
class ChildItemUpdater:
@@ -340,7 +345,7 @@ def set_order_defaults(
child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
child_item.stock_uom = item.stock_uom
child_item.uom = trans_item.get("uom") or item.stock_uom
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype)
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
@@ -349,20 +354,44 @@ def set_order_defaults(
child_item.base_rate = 1
child_item.base_amount = 1
if child_doctype == "Sales Order Item":
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not child_item.warehouse:
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
).format(frappe.bold(item.item_code))
)
set_child_tax_template_and_map(item, child_item, p_doc)
add_taxes_from_tax_template(child_item, p_doc)
return child_item
def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None:
"""Return the warehouse picked in the Update Items dialog, else the configured default.
Validates whichever warehouse was resolved, since a submitted parent skips validate().
"""
warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not warehouse:
if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item):
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company."
).format(frappe.bold(item.item_code))
)
return None
validate_warehouse_company(warehouse, p_doc.company)
validate_disabled_warehouse(warehouse)
is_group_warehouse(warehouse)
return warehouse
def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool:
"""Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse."""
if child_doctype == "Sales Order Item":
return True
if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier)
return False
def validate_child_on_delete(row, parent, ordered_item=None) -> None:
"""Raise if a partially transacted child item is being deleted."""
if parent.doctype == "Sales Order":

View File

@@ -11,6 +11,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision
def get_exchange_gain_loss_account(company: str, is_gain: bool) -> str | None:
fieldname = "exchange_gain_account" if is_gain else "exchange_loss_account"
return frappe.get_cached_value("Company", company, fieldname) or frappe.get_cached_value(
"Company", company, "exchange_gain_loss_account"
)
def gain_loss_journal_already_booked(
gain_loss_account: str,
exc_gain_loss: float,
@@ -163,9 +170,7 @@ def make_exchange_gain_loss_journal(
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gain_loss_account = frappe.get_cached_value(
"Company", doc.company, "exchange_gain_loss_account"
)
gain_loss_account = get_exchange_gain_loss_account(doc.company, reverse_dr_or_cr == "credit")
je = create_gain_loss_journal(
doc.company,
args.get("difference_posting_date") if args else doc.posting_date,

View File

@@ -310,12 +310,45 @@ class PurchaseOrder(BuyingController):
itemwise_qty.setdefault(d.item_code, 0)
itemwise_qty[d.item_code] += flt(d.stock_qty)
precision = self.items[0].precision("stock_qty")
for item_code, qty in itemwise_qty.items():
if flt(qty) < flt(itemwise_min_order_qty.get(item_code)):
if flt(qty, precision) < flt(itemwise_min_order_qty.get(item_code), precision):
frappe.throw(
_(
"Item {0}: Ordered qty {1} cannot be less than minimum order qty {2} (defined in Item)."
).format(item_code, qty, itemwise_min_order_qty.get(item_code))
).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code))
)
self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty)
def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty):
"""Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding."""
if not self.is_new():
return
precision = self.items[0].precision("stock_qty")
itemwise_step = frappe._dict()
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
min_order_qty = flt(itemwise_min_order_qty.get(item_code))
overage = flt(qty) - min_order_qty
if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]:
frappe.toast(
_(
"Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding."
).format(
item_code,
flt(qty, precision),
itemwise_stock_uom[item_code],
min_order_qty,
flt(overage, precision),
),
indicator="orange",
)
def get_schedule_dates(self):

View File

@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.save()
self.assertEqual(po.items[1].qty, 1)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
self.assertRaises(frappe.ValidationError, po.save)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
po.save()
po.submit()
self.assertEqual(po.docstatus, 1)
self.assertTrue(po.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
self.assertRaises(frappe.ValidationError, po.save)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
@@ -320,6 +342,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db()
existing_ordered_qty = get_ordered_qty()
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
first_item_of_po = po.get("items")[0]
trans_item = json.dumps(
@@ -330,16 +353,62 @@ class TestPurchaseOrder(ERPNextTestSuite):
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": "_Test Item", "rate": 200, "qty": 7},
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
po.reload()
self.assertEqual(len(po.get("items")), 2)
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
self.assertEqual(po.status, "To Receive and Bill")
# ordered qty should increase on row addition
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
# ordered qty should increase on row addition, in the warehouse passed for the new row
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
self.assertEqual(
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
existing_ordered_qty_in_new_warehouse + 7,
)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
po = create_purchase_order(do_not_save=1)
po.save()
po.submit()
first_item_of_po = po.get("items")[0]
company_default = frappe.db.get_value("Company", po.company, "default_warehouse")
frappe.db.set_value("Company", po.company, "default_warehouse", None)
self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default)
def get_trans_items(item_code):
return json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": item_code, "rate": 200, "qty": 7},
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Purchase Order",
get_trans_items(stock_item),
po.name,
)
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
po.reload()
self.assertEqual(po.get("items")[-1].item_code, service_item)
self.assertFalse(po.get("items")[-1].warehouse)
def test_update_child_removing_item(self):
po = create_purchase_order(do_not_save=1)
@@ -707,6 +776,66 @@ class TestPurchaseOrder(ERPNextTestSuite):
po = create_purchase_order(qty=3.4, do_not_save=True)
self.assertRaises(UOMMustBeIntegerError, po.insert)
def test_min_order_qty_with_uom_conversion_dust(self):
item_doc = make_item(properties={"min_order_qty": 2000, "stock_uom": "Kg"})
item_doc.append("uoms", {"uom": "Litre", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Litre"
po.items[0].conversion_factor = 0.6
po.insert()
below_minimum = create_purchase_order(item_code=item, qty=3000, do_not_save=1)
below_minimum.items[0].uom = "Litre"
below_minimum.items[0].conversion_factor = 0.6
self.assertRaises(frappe.ValidationError, below_minimum.insert)
def test_marginal_min_order_qty_overage_toast(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
if not frappe.db.exists("UOM", "Gram"):
frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert()
item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"})
item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197})
item_doc.save()
item = item_doc.name
def insert_po(qty):
po = create_purchase_order(item_code=item, qty=qty, do_not_save=1)
po.items[0].uom = "Pound"
po.items[0].conversion_factor = 453.592292197
frappe.clear_messages()
po.insert()
return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log())
self.assertTrue(insert_po(110.232))
self.assertFalse(insert_po(150))
def test_uom_integer_check_tolerates_conversion_dust(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
item_doc = make_item(properties={"stock_uom": "Nos"})
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
item_doc.save()
item = item_doc.name
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
po.items[0].uom = "Kg"
po.items[0].conversion_factor = 0.6
po.insert()
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
fractional.items[0].uom = "Kg"
fractional.items[0].conversion_factor = 0.6
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
def test_ordered_qty_for_closing_po(self):
bin = frappe.get_all(
"Bin",

View File

@@ -260,6 +260,7 @@
"label": "UOM Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"print_width": "100px",
"reqd": 1,
@@ -943,7 +944,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:30:04.600510",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -132,6 +132,7 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"read_only": 1
},
{
@@ -207,7 +208,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:26.235916",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Receipt Item Supplied",

View File

@@ -324,14 +324,14 @@ class RequestforQuotation(BuyingController):
message_template = self.mfs_html if self.use_html else self.message_for_supplier
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
rendered_message = frappe.render_template(message_template, doc_args)
rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation")
)
rendered_subject = frappe.render_template(subject_source, doc_args)
rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True)
if preview:
return {
"message": rendered_message,

View File

@@ -241,6 +241,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -274,7 +275,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-15 00:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -217,6 +217,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -614,7 +615,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:33:24.855979",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -47,6 +47,6 @@
"modified": "2026-07-20 15:54:26.047600",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order (Standard)",
"name": "Purchase Order - Buying",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-03 17:18:03.006829",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation (Standard)",
"name": "Request for Quotation - Buying",
"owner": "Administrator"
}

View File

@@ -15,6 +15,6 @@
"modified": "2026-07-03 17:14:32.891939",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation (Standard)",
"name": "Supplier Quotation - Buying",
"owner": "Administrator"
}

View File

@@ -210,6 +210,23 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
def is_negative_grand_total_allowed(self) -> bool:
"""Return True if this document may save with a negative grand total.
Sales Order and Purchase Order never post to the GL, so a negative
total is safe there whenever the user has explicitly opted into
negative rates via Selling/Buying Settings. Every other
AccountsController doctype (invoices, delivery notes, receipts,
quotations, ...) keeps relying on the `is_return` escape hatch only.
"""
if self.doctype == "Sales Order":
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
if self.doctype == "Purchase Order":
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
return False
def validate(self):
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -262,7 +279,8 @@ class AccountsController(TransactionBase):
self.calculate_taxes_and_totals()
if not self.meta.get_field("is_return") or not self.is_return:
self.validate_value("base_grand_total", ">=", 0)
if not self.is_negative_grand_total_allowed():
self.validate_value("base_grand_total", ">=", 0)
validate_return(self)
@@ -1039,9 +1057,16 @@ class AccountsController(TransactionBase):
party_account = self.credit_to
dr_or_cr = "debit_in_account_currency"
from erpnext.accounts.services.exchange_gain_loss import get_exchange_gain_loss_account
lst = []
for d in self.get("advances"):
if flt(d.allocated_amount) > 0:
is_gain = (
flt(d.get("exchange_gain_loss")) > 0
if party_type == "Customer"
else flt(d.get("exchange_gain_loss")) < 0
)
args = frappe._dict(
{
"voucher_type": d.reference_type,
@@ -1068,9 +1093,7 @@ class AccountsController(TransactionBase):
else self.grand_total
),
"outstanding_amount": self.outstanding_amount,
"difference_account": frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
),
"difference_account": get_exchange_gain_loss_account(self.company, is_gain),
"exchange_gain_loss": flt(d.get("exchange_gain_loss")),
"difference_posting_date": d.get("difference_posting_date"),
}

View File

@@ -254,7 +254,7 @@ class SellingController(StockController):
total += sales_person.allocated_percentage
if sales_team and total != 100.0:
if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0:
throw(_("Total allocated percentage for sales team should be 100"))
def validate_sales_team(self, sales_team):

View File

@@ -265,6 +265,9 @@ class StatusUpdater(Document):
def validate_qty(self):
"""Validates qty at row level"""
selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note")
buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt")
for args in self.status_updater:
if "target_ref_field" not in args or args.get("validate_qty") is False:
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
@@ -292,11 +295,8 @@ class StatusUpdater(Document):
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be a negative number").format(d.item_code))
if (
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
) or (
not buying_negative_rate_allowed
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or (
not buying_negative_rate_allowed and self.doctype in buying_doctypes
):
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
frappe.throw(
@@ -307,7 +307,7 @@ class StatusUpdater(Document):
frappe.bold(_("`Allow Negative rates for Items`")),
get_link_to_form(
"Selling Settings"
if self.doctype in ["Sales Invoice", "Delivery Note"]
if self.doctype in selling_doctypes
else "Buying Settings"
),
),

View File

@@ -888,7 +888,7 @@ def make_bundle_for_material_transfer(**kwargs):
row.stock_value_difference = abs(row.stock_value_difference)
if kwargs.type_of_transaction == "Outward":
row.qty *= -1
row.stock_value_difference *= row.stock_value_difference
row.stock_value_difference *= -1
row.is_outward = 1
row.warehouse = kwargs.warehouse

View File

@@ -30,7 +30,7 @@ class ContractTemplate(Document):
def validate(self):
if self.contract_terms:
validate_template(self.contract_terms)
validate_template(self.contract_terms, restrict_globals=True)
@frappe.whitelist()
@@ -41,6 +41,6 @@ def get_contract_template(template_name: str, doc: str | dict | Document):
contract_terms = None
if contract_template.contract_terms:
contract_terms = frappe.render_template(contract_template.contract_terms, doc)
contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True)
return {"contract_template": contract_template, "contract_terms": contract_terms}

View File

@@ -171,8 +171,8 @@ def send_mail(entry, email_campaign):
context = {"doc": frappe.get_doc("Email Group", recipient)}
# Render template
subject = frappe.render_template(email_template.get("subject"), context)
content = frappe.render_template(email_template.response_, context)
subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True)
content = frappe.render_template(email_template.response_, context, restrict_globals=True)
frappe.db.savepoint("email_campaign_send")
try:

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-02 10:09+0000\n"
"PO-Revision-Date: 2026-08-05 10:02\n"
"PO-Revision-Date: 2026-08-06 10:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Bosnian\n"
"MIME-Version: 1.0\n"
@@ -789,9 +789,9 @@ msgstr "<h4>Primjer Predloška Ugovora</h4>\n\n"
"-Važi do: {{ end_date }}\n"
"</pre>\n\n"
"<h4>Kako dobiti imena polja</h4>\n\n"
"<p>Nazivi polja koje možete koristiti u svom predlošku ugovora su polja u ugovoru za koje izradi predložak. Možete saznati polja bilo kojeg dokumenta putem Podešavanja &gt; Prilagodi prikaz obrasca i odabir vrste dokumenta (npr. Ugovor)</p>\n\n"
"<p>Nazivi polja koje možete koristiti u svom predlošku ugovora su polja u ugovoru za koje izradi predložak. Možete saznati polja bilo kojeg dokumenta putem Podešavanja &gt; Prilagodi prikaz obrasca i odabir tipa dokumenta (npr. Ugovor)</p>\n\n"
"<h4>Predložak</h4>\n\n"
"<p>Predložci se kompajliraju koristeći Jinja Templating Language. Da saznate više o Jinji, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">pročitajte ovu dokumentaciju.</a></p>"
"<p>Predložci se kompajliraju koristeći Jinja Templating Language. Da saznate više o Jinji, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">pročitaj ovu dokumentaciju.</a></p>"
#. Content of the 'Terms and Conditions Help' (HTML) field in DocType 'Terms
#. and Conditions'
@@ -2926,11 +2926,11 @@ msgstr "Dodaj Bilješku"
#: banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx:879
msgid "Add a charge to the payment entry with the difference amount"
msgstr "Dodajte naplatu u unos plaćanja s iznosom razlike"
msgstr "Dodaj naplatu u unos plaćanja s iznosom razlike"
#: banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx:863
msgid "Add a charge to the payment entry with the unallocated amount"
msgstr "Dodajte naplatu u unos plaćanja s nedodjeljnim iznosom"
msgstr "Dodaj naplatu u unos plaćanja s nedodjeljnim iznosom"
#: banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx:776
msgid "Add a row with the difference amount"
@@ -2942,7 +2942,7 @@ msgstr "Dodaj sve račune na koje želite podijeliti transakciju."
#: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:92
msgid "Add atleast one voucher to repost."
msgstr "Dodajte barem jedan verifikat za ponovno knjiženje."
msgstr "Dodaj barem jedan verifikat za ponovno knjiženje."
#: erpnext/www/book_appointment/index.html:42
msgid "Add details"
@@ -3405,7 +3405,7 @@ msgstr "Adresa & Kontakt"
#: erpnext/accounts/custom/address.py:35
msgid "Address needs to be linked to a Company. Please add a row for Company in the Links table."
msgstr "Adresa mora biti povezana s firmom. Dodajte red za firmu u tabeli Veze."
msgstr "Adresa mora biti povezana s firmom. Dodaj red za firmu u tabeli Veze."
#. Description of the 'Determine Address Tax Category from' (Select) field in
#. DocType 'Accounts Settings'
@@ -3966,7 +3966,7 @@ msgstr "Sve Prodajno Osoblje"
#. Description of a DocType
#: erpnext/setup/doctype/sales_person/sales_person.json
msgid "All Sales Transactions can be tagged against multiple Sales Persons so that you can set and monitor targets."
msgstr "Sve prodajne transakcije mogu se označiti naspram više prodajnih osoba kako biste mogli postaviti i nadzirati ciljeve."
msgstr "Sve prodajne transakcije mogu se odabrati naspram više prodajnih osoba kako biste mogli postaviti i nadzirati ciljeve."
#. Option for the 'Send To' (Select) field in DocType 'SMS Center'
#: erpnext/selling/doctype/sms_center/sms_center.json
@@ -4055,7 +4055,7 @@ msgstr "Sve odabrani artikli su već preneseni na ovu listu odabira"
#. in DocType 'CRM Settings'
#: erpnext/crm/doctype/crm_settings/crm_settings.json
msgid "All the Comments and Emails will be copied from one document to another newly created document(Lead -> Opportunity -> Quotation) throughout the CRM documents."
msgstr "Svi komentari i e-pošta kopirat će se iz jednog dokumenta u drugi novostvoreni dokument (Potencijalni Klijent -> Prilika-> Ponuda) kroz dokumente Prodajne Podrške."
msgstr "Svi komentari i e-pošta kopirat će se iz jednog dokumenta u drugi novoizrađeni dokument (Potencijalni Klijent -> Prilika-> Ponuda) kroz dokumente Prodajne Podrške."
#: erpnext/selling/page/point_of_sale/pos_past_order_summary.js:204
msgid "All the items have already been returned."
@@ -5598,7 +5598,7 @@ msgstr "Termin se može zakazati samo do {0} dana unaprijed."
#: erpnext/crm/doctype/appointment/appointment.py:79
msgid "Appointment cannot be scheduled for a past time."
msgstr "Termin se ne može zakazati za prošlu vrijeme."
msgstr "Termin se ne može zakazati za prošlo vrijeme."
#: erpnext/crm/doctype/appointment/appointment.py:98
msgid "Appointment cannot be scheduled on a holiday."
@@ -5664,11 +5664,11 @@ msgstr "Jeste li sigurni da želite izbrisati sve demo podatke?"
#: erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.js:51
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.js:100
msgid "Are you sure you want to create Reposting Entries?"
msgstr "Jeste li sigurni da želite stvoriti ponovno knjiženje unosa?"
msgstr "Jeste li sigurni da želite izraditi ponovno knjiženje unosa?"
#: erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js:66
msgid "Are you sure you want to create a Reposting Entry?"
msgstr "Jeste li sigurni da želite stvoriti ponovno knjiženje unosa?"
msgstr "Jeste li sigurni da želite izraditi ponovno knjiženje unosa?"
#: erpnext/public/js/bom_configurator/bom_configurator.bundle.js:499
msgid "Are you sure you want to delete this Item?"
@@ -6455,7 +6455,7 @@ msgstr "Red {0}: Serijski Broj je obavezan za Artikal {1}"
#: erpnext/stock/services/serial_batch_bundle_service.py:504
msgid "At row {0}: Serial and Batch Bundle {1} has already been created. Please remove the values from the serial no or batch no fields."
msgstr "U Redu {0}: Serijski i Šaržni Paket {1} je već stvoren. Uklonite vrijednosti iz polja za serijski ili šaržni broj."
msgstr "U Redu {0}: Serijski i Šaržni Paket {1} je već izrađen. Uklonite vrijednosti iz polja za serijski ili šaržni broj."
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:123
msgid "At row {0}: set Parent Row No for item {1}"
@@ -6729,7 +6729,7 @@ msgstr "Automatska izrada Podizvođačkom Naloga"
#. Label of the auto_create_assets (Check) field in DocType 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Auto create assets on purchase"
msgstr "Automatski stvori sredstava pri nabavi"
msgstr "Automatski izradi sredstava pri nabavi"
#. Label of the auto_insert_price_list_rate_if_missing (Check) field in DocType
#. 'Stock Settings'
@@ -6796,7 +6796,7 @@ msgstr "Automatski Izradi Novi Šaržu"
#. 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Automatically add Taxes and Charges from Item Tax Template"
msgstr "Automatski dodajte PDV i Naknade iz Predloška za PDV na Artikal"
msgstr "Automatski dodaj PDV i Naknade iz Predloška za PDV na Artikal"
#. Label of the add_taxes_from_taxes_and_charges_template (Check) field in
#. DocType 'Accounts Settings'
@@ -7013,7 +7013,7 @@ msgstr "Prosječna Cjena"
#. Label of the avg_response_time (Duration) field in DocType 'Issue'
#: erpnext/support/doctype/issue/issue.json
msgid "Average Response Time"
msgstr "Prosječno Vreme Odziva"
msgstr "Prosječno Vreme Odgovora"
#. Description of the 'Lead Time in days' (Int) field in DocType 'Item'
#: erpnext/stock/doctype/item/item.json
@@ -7770,7 +7770,7 @@ msgstr "Bankovni Nacrt"
#: banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx:98
msgid "Bank Entries Created"
msgstr "Bankovni Unosi Stvoreni"
msgstr "Bankovni Unosi Izrađeni"
#. Option for the 'Classify As' (Select) field in DocType 'Bank Transaction
#. Rule'
@@ -7792,7 +7792,7 @@ msgstr "Bankovni Unos"
#: banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx:295
msgid "Bank Entry Created"
msgstr "Bankovni Unos Stvoren"
msgstr "Bankovni Unos Izrađen"
#. Label of the bank_entry_type (Select) field in DocType 'Bank Transaction
#. Rule'
@@ -8362,12 +8362,12 @@ msgstr "Šarža nije izrađena za artikal {0} jer nema Broj Šarže."
#. 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Batch number will be auto-created in format AAAA.00001 if not specified in transactions. Leave blank to always enter batch numbers manually."
msgstr "Broj šarže bit će automatski stvoren u formatu AAAA.00001 ako nije naveden u transakcijama. Ostavite prazno da biste uvijek ručno unosili brojeve šarže."
msgstr "Broj šarže bit će automatski izrađen u formatu AAAA.00001 ako nije naveden u transakcijama. Ostavite prazno da biste uvijek ručno unosili brojeve šarže."
#. Description of the 'Has Expiry Date' (Check) field in DocType 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Batch number will be created based on expiry date. Expiry dates can be set in the Batch master."
msgstr "Broj šarže bit će stvoren na temelju datuma isteka. Datumi isteka mogu se postaviti u Postavkama Šarže."
msgstr "Broj šarže bit će izrađen na temelju datuma isteka. Datumi isteka mogu se postaviti u Postavkama Šarže."
#: erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py:384
msgid "Batch {0} and Warehouse"
@@ -9949,7 +9949,7 @@ msgstr "Nije moguće izraditi knjigovodstvene unose naspram onemogućenih račun
#: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py:146
msgid "Cannot create more Subcontracting Orders against the Purchase Order {0}."
msgstr "Ne može se stvoriti više Podugovornih Naloga na osnovu Naloga Nabave {0}."
msgstr "Ne može se izraditi više Podugovornih Naloga na osnovu Naloga Nabave {0}."
#: erpnext/controllers/sales_and_purchase_return.py:444
msgid "Cannot create return for consolidated invoice {0}."
@@ -10998,7 +10998,7 @@ msgstr "Zatvorite Predmet nakon (dana)"
#: erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js:69
msgid "Close Loan"
msgstr "Zatvori Zajam"
msgstr "Zatvori Kredit"
#. Label of the close_opportunity_after_days (Int) field in DocType 'CRM
#. Settings'
@@ -12182,7 +12182,7 @@ msgstr "Proizvedena Količina"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1737
msgid "Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
msgstr "Izvršena Količina ({0}), Količina na Čekanju ({1}) i Količina Gubitka u Procesu ({2}) moraju se zbrajati do Količine za Proizvodnju ({3})."
msgstr "Završena Količina ({0}), Količina na Čekanju ({1}) i Količina Gubitka u Procesu ({2}) moraju se zbrajati do Količine za Proizvodnju ({3})."
#: erpnext/manufacturing/doctype/job_card/job_card.js:280
#: erpnext/public/js/shop_floor/shop_floor.js:825
@@ -14068,7 +14068,7 @@ msgstr "Stvarajednu grupisanu imovinu umjesto pojedinačnih kada se nabavlja na
#. 'Item'
#: erpnext/stock/doctype/item/item.json
msgid "Creates an Item Price automatically when the item is saved"
msgstr "Automatski stvori cjenu artikla kada se artikal spremi"
msgstr "Automatski izradi cjenu artikla kada se artikal spremi"
#: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js:140
msgid "Creating Accounts..."
@@ -17662,7 +17662,7 @@ msgstr "Rastavljena Količina"
#: erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js:64
msgid "Disburse Loan"
msgstr "Isplati Zajam"
msgstr "Isplati Kredit"
#. Option for the 'Status' (Select) field in DocType 'Invoice Discounting'
#: erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json
@@ -21632,7 +21632,7 @@ msgstr "Za individualnog Dobavljača"
#: erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py:379
msgid "For item <b>{0}</b>, only <b>{1}</b> assets have been created or linked to <b>{2}</b>. Please create or link <b>{3}</b> more assets with the respective document."
msgstr "Za artikal <b>{0}</b>, samo <b>{1}</b> imovina je stvorena ili povezana s <b>{2}</b>. Stvori ili poveži još <b>{3}</b> imovine s odgovarajućim dokumentom."
msgstr "Za artikal <b>{0}</b>, samo <b>{1}</b> imovina je izrađena ili povezana s <b>{2}</b>. Izradi ili poveži još <b>{3}</b> imovine s odgovarajućim dokumentom."
#: erpnext/controllers/status_updater.py:303
msgid "For item {0}, rate must be a positive number. To allow negative rates, enable {1} in {2}"
@@ -21646,7 +21646,7 @@ msgstr "Za stare serijske brojeve, nemojte preuzimati nabvnu cjenu iz serijskog
#: erpnext/manufacturing/doctype/bom/bom.py:400
msgid "For operation {0} at row {1}, please add raw materials or set a BOM against it."
msgstr "Za radnju {0} u redu {1}, molimo dodajte sirovine ili postavi Sastavnicu naspram nje."
msgstr "Za radnju {0} u redu {1}, molimo dodaj sirovine ili postavi Sastavnicu naspram nje."
#: erpnext/manufacturing/doctype/work_order/mapper.py:383
msgid "For operation {0}: Quantity ({1}) can not be greater than pending quantity ({2})"
@@ -23856,7 +23856,7 @@ msgstr "Ako je <b>Omogućeno</b> - Usaglašavanje se dešava na <b>Datum Knjiže
#: erpnext/accounts/doctype/loyalty_program/loyalty_program.js:34
msgid "If Auto Opt In is checked, then the customers will be automatically linked with the concerned Loyalty Program (on save)"
msgstr "Ako je automatska registracija označena, tada će klijenti biti automatski povezani sa dotičnim Programom Lojalnosti (prilikom spremanja)"
msgstr "Ako je automatska registracija odabrana, tada će klijenti biti automatski povezani sa dotičnim Programom Lojalnosti (prilikom spremanja)"
#. Description of the 'Cost Center' (Link) field in DocType 'Journal Entry
#. Account'
@@ -24118,7 +24118,7 @@ msgstr "Ako je omogućeno, sistem će dozvoliti korisnicima da uređuju sirovine
#. in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
msgid "If enabled, the system will generate an accounting entry for materials rejected in the Purchase Receipt."
msgstr "Ako je omogućeno, sistem će stvoriti knjigovodstveni unos za odbijene materijale u Nabavnom Računu."
msgstr "Ako je omogućeno, sistem će izraditi knjigovodstveni unos za odbijene materijale u Nabavnom Računu."
#. Description of the 'Enable Item-wise Inventory Account' (Check) field in
#. DocType 'Company'
@@ -28000,7 +28000,7 @@ msgstr "Cjena Artikla se pojavljuje više puta na osnovu Cjenovnika, Dobavljača
#: erpnext/stock/doctype/item/item.py:186
msgid "Item Price created at rate {0}"
msgstr "Cjena Artikla stvorena po stopi {0}"
msgstr "Cjena Artikla izrađena po stopi {0}"
#: erpnext/stock/get_item_details.py:1160
msgid "Item Price updated for {0} in Price List {1}"
@@ -28341,7 +28341,7 @@ msgstr "Artikal Radnji"
#: erpnext/stock/doctype/stock_entry/stock_entry.py:676
msgid "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}"
msgstr "Cjena Artikla je ažurirana na nulu jer je Dozvoli Nultu Stopu Vrednovanja označena za artikal {0}"
msgstr "Cjena Artikla je ažurirana na nulu jer je Dozvoli Nultu Stopu Vrednovanja odabrana za artikal {0}"
#: erpnext/stock/doctype/material_request/material_request.py:231
msgid "Item rates have been updated based on the selected Buying Price List {0}"
@@ -30955,7 +30955,7 @@ msgstr "Uporedi i Uskladi"
#: banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx:62
msgid "Match or Create"
msgstr "Uskladi ili Stvori"
msgstr "Uskladi ili Izradi"
#. Label of the transfer_match_days (Int) field in DocType 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -32253,7 +32253,7 @@ msgstr "Za datum {0} postoji više fiskalnih godina. Postavi poduzeće u Fiskaln
#: erpnext/stock/doctype/stock_entry/stock_entry.py:957
msgid "Multiple items cannot be marked as finished item"
msgstr "Više artikala se ne mogu označiti kao gotov proizvod"
msgstr "Više artikala se ne mogu odabrati kao gotov proizvod"
#: erpnext/setup/setup_wizard/data/industry_type.txt:33
msgid "Music"
@@ -32900,7 +32900,7 @@ msgstr "Nove fakture će se izraditi prema rasporedu čak i ako su trenutne fakt
#: erpnext/support/doctype/issue/issue.js:126
msgid "New issue created: {0}"
msgstr "Novi zahtjev stvoren: {0}"
msgstr "Novi zahtjev izrađen: {0}"
#: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js:261
msgid "New release date should be in the future"
@@ -35664,7 +35664,7 @@ msgstr "Kasa Faktura nije podnešena"
#: erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py:130
msgid "POS Invoice isn't created by user {0}"
msgstr "Korisnik {0} nije stvorio Kasa Fakturu"
msgstr "Korisnik {0} nije izradio Kasa Fakturu"
#: erpnext/accounts/doctype/pos_invoice/pos_invoice.py:208
msgid "POS Invoice should have the field {0} checked."
@@ -37339,7 +37339,7 @@ msgstr "Platni Zahtjevi ne mogu se izraditi naspram: {0}"
#. in DocType 'Accounts Settings'
#: erpnext/accounts/doctype/accounts_settings/accounts_settings.json
msgid "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly"
msgstr "Zahtjevi Plaćanja stvoren iz Prodajne / Nabavne Fakture bit će eksplicitno stavljeni u Nacrt"
msgstr "Zahtjevi Plaćanja izrađen iz Prodajne / Nabavne Fakture bit će eksplicitno stavljeni u Nacrt"
#. Label of the payment_schedule (Data) field in DocType 'Overdue Payment'
#. Label of the payment_schedule (Link) field in DocType 'Payment Reference'
@@ -47634,7 +47634,7 @@ msgstr "Red #{0}: Šarža {1} je već istekla."
#: erpnext/stock/doctype/stock_entry/stock_entry.py:417
msgid "Row #{0}: The job card item reference is missing. Kindly create the stock entry from the job card. If you have added the row manually then you won't be able to add job card item reference."
msgstr "Red #{0}: Nedostaje referenca artikla na radnoj kartici. Stvori unos zaliha iz radne kartice. Ako ste red dodali ručno, nećete moći dodati referencu artikla na radnu karticu."
msgstr "Red #{0}: Nedostaje referenca artikla na radnoj kartici. Izradi unos zaliha iz radne kartice. Ako ste red dodali ručno, nećete moći dodati referencu artikla na radnu karticu."
#: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:103
msgid "Row #{0}: The original Invoice {1} of return invoice {2} is not consolidated."
@@ -50129,7 +50129,7 @@ msgstr "Odabrani Početni Unos Kase bi trebao biti otvoren."
#: erpnext/accounts/doctype/sales_invoice/mapper.py:158
msgid "Selected Price List should have buying and selling fields checked."
msgstr "Odabrani Cjenovnik treba da ima označena polja za Nabavu i Prodaju."
msgstr "Odabrani Cjenovnik treba da ima odabrana polja za Nabavu i Prodaju."
#: erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py:123
msgid "Selected Print Format does not exist."
@@ -53118,7 +53118,7 @@ msgstr "Unos Zaliha {0} je izrađen"
#: erpnext/manufacturing/doctype/job_card/job_card.py:1785
msgid "Stock Entry {0} has been created"
msgstr "Unos Zaliha {0} je stvoren"
msgstr "Unos Zaliha {0} je izrađen"
#: erpnext/accounts/doctype/journal_entry/journal_entry.py:997
msgid "Stock Entry {0} is not submitted"
@@ -56619,7 +56619,7 @@ msgstr "Faktura nije u potpunosti dodijeljena jer postoji razlika od {0}."
#: erpnext/controllers/buying_controller.py:1263
msgid "The item {item} is not marked as {type_of} item. You can enable it as {type_of} item from its Item master."
msgstr "Artikal {item} nije označen kao {type_of} artikal. Možete ga omogućiti kao {type_of} Artikal u Postavkama Artikla."
msgstr "Artikal {item} nije odabran kao {type_of} artikal. Možete ga omogućiti kao {type_of} Artikal u Postavkama Artikla."
#: erpnext/stock/doctype/item/item.py:682
msgid "The items {0} and {1} are present in the following {2} :"
@@ -56627,7 +56627,7 @@ msgstr "Artikli {0} i {1} se nalaze u sljedećem {2} :"
#: erpnext/controllers/buying_controller.py:1256
msgid "The items {items} are not marked as {type_of} item. You can enable them as {type_of} item from their Item masters."
msgstr "Artikli {items} nisu označeni kao {type_of} artikli. Možete ih omogućiti kao {type_of} artikle u Postavkama Artikala."
msgstr "Artikli {items} nisu odabrani kao {type_of} artikli. Možete ih omogućiti kao {type_of} artikle u Postavkama Artikala."
#: erpnext/manufacturing/doctype/workstation/workstation.py:527
msgid "The job card {0} is in {1} state and you cannot complete it."
@@ -57098,7 +57098,7 @@ msgstr "Ovo omogućava izradu prodajnih naloga iz ponuda kojima je istekao rok v
#: erpnext/assets/doctype/asset/asset.py:438
msgid "This asset category is marked as non-depreciable. Please disable depreciation calculation or choose a different category."
msgstr "Ova kategorija imovine je označena kao neamortizujuća. Onemogući obračun amortizacije ili odaberi drugu kategoriju."
msgstr "Ova kategorija imovine je odabrana kao neamortizujuća. Onemogući obračun amortizacije ili odaberi drugu kategoriju."
#. Description of the 'Allow negative stock' (Check) field in DocType 'Stock
#. Settings'
@@ -57288,7 +57288,7 @@ msgstr "Ova radnja zahtijeva Kontrolu Kvalitete, ali nije konfiguriran predloža
#: erpnext/stock/doctype/delivery_note/delivery_note.js:509
msgid "This option can be checked to edit the 'Posting Date' and 'Posting Time' fields."
msgstr "Ova opcija se može označiti za uređivanje polja 'Datum Knjiženja' i 'Vrijeme Knjiženja'."
msgstr "Ova opcija se može odabrati za uređivanje polja 'Datum Knjiženja' i 'Vrijeme Knjiženja'."
#. Description of the 'Raise Material Request when stock reaches re-order
#. level' (Check) field in DocType 'Stock Settings'
@@ -59238,7 +59238,7 @@ msgstr "Transakcije naspram Poduzeća već postoje! Kontni Plan se može uvesti
#. 'Customer'
#: erpnext/selling/doctype/customer/customer.json
msgid "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit."
msgstr "Transakcije se blokiraju kada preostali dug premaši kreditni limit. Kada je omogućena opcija Ograniči Prekomjerno Fakturisanja Klijenta, nove fakture se također blokiraju kada iznos dospjelih obaveza klijenta premaši granicu za dospjele obaveze."
msgstr "Transakcije se blokiraju kada preostali dug premaši kreditnu granicu. Kada je omogućena opcija Ograniči Prekomjerno Fakturisanja Klijenta, nove fakture se također blokiraju kada iznos dospjelih obaveza klijenta premaši granicu za dospjele obaveze."
#: banking/src/components/features/BankStatementImporter/CSV/StatementDetails.tsx:239
msgid "Transactions to be imported into the system"
@@ -61129,11 +61129,11 @@ msgstr "Stopa Vrednovanja artikla prema Prodajnoj Fakturi (samo za interne trans
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2010
#: erpnext/accounts/services/taxes.py:322
msgid "Valuation type charges can not be marked as Inclusive"
msgstr "Naknade za tip vrijednovanja ne mogu biti označene kao Inkluzivne"
msgstr "Naknade za tip vrijednovanja ne mogu biti odabrane kao Inkluzivne"
#: erpnext/public/js/controllers/accounts.js:228
msgid "Valuation type charges cannot be marked as Inclusive"
msgstr "Naknade tipa procjene vrijednosti ne mogu biti označene kao uključene."
msgstr "Naknade tipa procjene vrijednosti ne mogu biti odabrane kao uključene."
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.js:58
msgid "Value (G - D)"
@@ -62118,13 +62118,13 @@ msgstr "Upozori pri novim Zahtjevima za Ponudu"
#. in DocType 'Selling Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Warn or stop if Item rate is changed in Delivery Notes and Sales Invoices generated from a Sales Order."
msgstr "Upozori ili zaustavi ako se cjena artikla promijeni u Otpremnicama i Prodajnim Fakturama stvorenih iz Prodajnog Naloga."
msgstr "Upozori ili zaustavi ako se cjena artikla promijeni u Otpremnicama i Prodajnim Fakturama izrađenih iz Prodajnog Naloga."
#. Description of the 'Maintain same rate throughout the purchase cycle'
#. (Check) field in DocType 'Buying Settings'
#: erpnext/buying/doctype/buying_settings/buying_settings.json
msgid "Warn or stop if Item rate is changed in Purchase Invoice or Purchase Receipt generated from a Purchase Order."
msgstr "Upozori ili zaustavi ako se cjena artikla promijeni u fakturi ili potvrdi o nabavi stvorenoj iz naloga nabave."
msgstr "Upozori ili zaustavi ako se cjena artikla promijeni u fakturi ili potvrdi o nabavi izrađenoj iz naloga nabave."
#: erpnext/projects/doctype/timesheet_detail/timesheet_detail.py:134
msgid "Warning - Row {0}: Billing Hours are more than Actual Hours"
@@ -64185,7 +64185,7 @@ msgstr "{0} mora biti negativan u povratnom dokumentu"
#: erpnext/accounts/doctype/sales_invoice/services/inter_company.py:60
msgid "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
msgstr "{0} nije dozvoljeno obavljati transakcije sa {1}. Promijeni poduzeće ili dodaj poduzeće u sekciju 'Dozvoljena Transakcija s' u zapisu klijenata."
msgstr "{0} nije dozvoljeno obavljati transakcije sa {1}. Promijeni poduzeće ili dodaj poduzeće u odjeljak 'Dozvoljena Transakcija s' u zapisu klijenata."
#: erpnext/manufacturing/doctype/bom/services/costing.py:63
msgid "{0} not found for item {1}"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-02 10:09+0000\n"
"PO-Revision-Date: 2026-08-05 10:02\n"
"PO-Revision-Date: 2026-08-06 10:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -39010,7 +39010,7 @@ msgstr "لطفاً یک یادداشت تحویل را انتخاب کنید"
#: erpnext/crm/doctype/appointment_booking_settings/appointment_booking_settings.py:81
msgid "Please select a Holiday List to enable Appointment Scheduling."
msgstr ""
msgstr "لطفا برای فعال کردن زمان‌بندی قرار ملاقات، یک لیست تعطیلات انتخاب کنید."
#: erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py:152
msgid "Please select a Subcontracting Purchase Order."
@@ -39096,7 +39096,7 @@ msgstr ""
#: erpnext/accounts/report/accounts_receivable/accounts_receivable.py:1356
msgid "Please select a valid {0}"
msgstr ""
msgstr "لطفا یک {0} معتبر انتخاب کنید"
#: erpnext/selling/doctype/quotation/quotation.js:245
msgid "Please select a value for {0} quotation_to {1}"
@@ -44133,7 +44133,7 @@ msgstr ""
#: erpnext/stock/doctype/bin/bin.js:10
msgid "Recalculate Values"
msgstr ""
msgstr "محاسبه مجدد مقادیر"
#. Option for the 'Status' (Select) field in DocType 'Asset'
#. Option for the 'Purpose' (Select) field in DocType 'Asset Movement'
@@ -49365,7 +49365,7 @@ msgstr "زمانبند غیرفعال است. نمی‌توان حساب‌ها
#: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:232
msgid "Scheduler is inactive. Reposting will only run once background jobs are processed."
msgstr ""
msgstr "زمان‌بند غیرفعال است. ارسال مجدد فقط زمانی اجرا می‌شود که کارهای پس‌زمینه پردازش شوند."
#. Label of the schedules (Table) field in DocType 'Maintenance Schedule'
#: erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.json
@@ -49811,7 +49811,7 @@ msgstr "انتخاب آدرس تامین کننده"
#: erpnext/stock/doctype/material_request/material_request.js:449
msgid "Select Supplier for Items"
msgstr ""
msgstr "انتخاب تامین کننده برای آیتم‌ها"
#: erpnext/stock/doctype/batch/batch.js:150
msgid "Select Target Warehouse"
@@ -49865,7 +49865,7 @@ msgstr "یک تامین کننده انتخاب کنید"
#: erpnext/stock/doctype/material_request/mapper.py:230
#: erpnext/stock/doctype/material_request/material_request.js:553
msgid "Select a Supplier for Item {0}"
msgstr ""
msgstr "انتخاب یک تأمین‌کننده برای آیتم {0}"
#: banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx:49
msgid "Select a bank account to reconcile"
@@ -49910,7 +49910,7 @@ msgstr "از هر مجموعه یک آیتم را برای استفاده در
#: erpnext/stock/doctype/material_request/mapper.py:211
#: erpnext/stock/doctype/material_request/material_request.js:540
msgid "Select at least one Item"
msgstr ""
msgstr "حداقل یک آیتم را انتخاب کنید"
#: erpnext/stock/doctype/item/item.js:1256
msgid "Select at least one attribute value."
@@ -50249,7 +50249,7 @@ msgstr "ارسال با پیوست"
#: erpnext/accounts/doctype/payment_request/payment_request.js:51
#: erpnext/accounts/doctype/payment_request/payment_request.js:55
msgid "Sending Email"
msgstr ""
msgstr "ارسال ایمیل"
#. Option for the 'Detected Amount Format' (Select) field in DocType 'Bank
#. Statement Import Log'
@@ -51134,7 +51134,7 @@ msgstr "تنظیم تامین کننده"
#: erpnext/stock/doctype/material_request/material_request.js:456
msgid "Set Supplier for All Items"
msgstr ""
msgstr "تنظیم تأمین‌کننده برای همه آیتم‌ها"
#. Label of the set_target_warehouse (Link) field in DocType 'Sales Invoice'
#. Label of the set_warehouse (Link) field in DocType 'Purchase Order'
@@ -61276,7 +61276,7 @@ msgstr ""
#. Label of the verification_token (Data) field in DocType 'Appointment'
#: erpnext/crm/doctype/appointment/appointment.json
msgid "Verification Token"
msgstr ""
msgstr "توکن تأیید"
#: erpnext/www/book_appointment/verify/index.html:15
msgid "Verification failed please check the link"
@@ -62147,7 +62147,7 @@ msgstr ""
#: erpnext/templates/emails/appointment_confirmed.html:3
msgid "We look forward to meeting you"
msgstr ""
msgstr "مشتاق دیدار شما هستیم"
#: banking/src/pages/BankStatementImporter.tsx:169
msgid "We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-08-02 10:09+0000\n"
"PO-Revision-Date: 2026-08-04 09:44\n"
"PO-Revision-Date: 2026-08-06 10:02\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -18542,7 +18542,7 @@ msgstr "Påminnelse Typ"
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:178
msgid "Duplicate Customer Group"
msgstr "Kopiera Kund Grupp"
msgstr "Duplicera Kund Grupp"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:190
msgid "Duplicate DocType"
@@ -18554,11 +18554,11 @@ msgstr "Dubblett Post. Kontrollera Auktorisering Regel {0}"
#: erpnext/assets/doctype/asset/asset.py:418
msgid "Duplicate Finance Book"
msgstr "Kopiera Bokslut Register"
msgstr "Duplicera Bokslut Register"
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:172
msgid "Duplicate Item Group"
msgstr "Kopiera Artikel Grupp"
msgstr "Duplicera Artikel Grupp"
#: erpnext/manufacturing/doctype/bom_creator/bom_creator.py:102
msgid "Duplicate Item Under Same Parent"
@@ -18576,7 +18576,7 @@ msgstr "Duplicera Kassa Fällt"
#: erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py:106
#: erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py:64
msgid "Duplicate POS Invoices found"
msgstr "Kopia av Kassa Fakturor hittad"
msgstr "Dubblett av Kassa Fakturor hittad"
#: erpnext/accounts/doctype/payment_request/payment_request.py:155
msgid "Duplicate Payment Schedule selected"
@@ -18584,7 +18584,7 @@ msgstr "Duplicerad Betalning Schema vald"
#: erpnext/projects/doctype/project/project.js:83
msgid "Duplicate Project with Tasks"
msgstr "Kopiera Projekt med Uppgifter"
msgstr "Duplicera Projekt med Uppgifter"
#: erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py:159
msgid "Duplicate Sales Invoices found"
@@ -18604,7 +18604,7 @@ msgstr "Kopia av Kund Grupp finns i Kund Grupp Tabell"
#: erpnext/stock/doctype/item_manufacturer/item_manufacturer.py:44
msgid "Duplicate entry against the item code {0} and manufacturer {1}"
msgstr "Kopiera post mot Artikel Kod {0} och Producent {1}"
msgstr "Duplicera post mot artikel kod {0} och producent {1}"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:189
msgid "Duplicate entry: {0}{1}"
@@ -18612,19 +18612,19 @@ msgstr "Duplicerad post: {0}{1}"
#: erpnext/accounts/doctype/pos_profile/pos_profile.py:172
msgid "Duplicate item group found in the item group table"
msgstr "Kopiera Artikel Grupp hittad i Artikel Grupp Tabell"
msgstr "Dubblett av Artikel Grupp hittad i Artikel Grupp Tabell"
#: erpnext/accounts/doctype/dunning_type/dunning_type.py:133
msgid "Duplicate languages found on Dunning Letter Text. Keep only one of them."
msgstr "Det finns flera språk i påminnelse brev. Behåll endast ett språk."
msgstr "Det finns flera språk i Påminnelse Brev. Behåll endast ett språk."
#: erpnext/projects/doctype/project/project.js:186
msgid "Duplicate project has been created"
msgstr "Kopia av Projekt är skapad"
msgstr "Dubblett av Projekt är skapad"
#: erpnext/utilities/transaction_base.py:112
msgid "Duplicate row {0} with same {1}"
msgstr "Kopiera Rad {0} med samma {1}"
msgstr "Duplicera Rad {0} med samma {1}"
#: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:110
msgid "Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."
@@ -18632,7 +18632,7 @@ msgstr "Dubbletter av verifikat hittades. Ta bort dubbletter för att fortsätta
#: erpnext/accounts/doctype/pricing_rule/pricing_rule.py:157
msgid "Duplicate {0} found in the table"
msgstr "Kopia {0} hittades i Tabell"
msgstr "Dubblett {0} hittades i Tabell"
#. Label of the duration (Int) field in DocType 'Task'
#: erpnext/projects/doctype/task/task.json
@@ -47176,7 +47176,7 @@ msgstr "Rad # #{0}: Avskrivning Start Datum erfordras"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:336
msgid "Row #{0}: Duplicate entry in References {1} {2}"
msgstr "Rad # {0}: Duplikat Post i Referenser {1} {2}"
msgstr "Rad #{0}: Dubblett Post i Referenser {1} {2}"
#: erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py:113
msgid "Row #{0}: Either Party ID or Party Name is required"

View File

@@ -314,6 +314,7 @@ class BOM(WebsiteGenerator):
self.clear_inspection()
self.validate_main_item()
self.validate_currency()
self.set_operation_finished_goods()
self.set_materials_based_on_operation_bom()
self.set_conversion_rate()
self.set_plc_conversion_rate()
@@ -340,15 +341,42 @@ class BOM(WebsiteGenerator):
self.set_fg_cost_allocation()
self.validate_total_cost_allocation()
def set_operation_finished_goods(self):
"""Fill each operation's FG item where it is unambiguous: the final operation produces
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
if not self.track_semi_finished_goods:
return
for row in self.operations:
if row.is_final_finished_good and not row.finished_good:
row.finished_good = self.item
elif row.bom_no and not row.finished_good:
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods or not self.operations:
return
fg_items = []
for row in self.operations:
if not row.finished_good:
frappe.throw(
_(
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
).format(row.idx, bold(row.operation)),
)
if not row.is_final_finished_good:
continue
if row.finished_good != self.item:
frappe.throw(
_(
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
).format(row.idx, bold(row.operation), bold(self.item)),
)
fg_items.append(row.finished_good)
if not fg_items:
@@ -800,15 +828,10 @@ class BOM(WebsiteGenerator):
row.update(get_item_details(row.get("item_code")))
row.operation_row_id = operation_row_id
item_row = self.get_item_data(row.name) if row.name else None
item_row = self.get_item_data(row.item_code, operation_row_id)
if item_row:
item_row.update(
{
"item_code": row.get("item_code"),
"qty": row.get("qty"),
}
)
item_row.qty = row.get("qty")
else:
row.idx = None
row.name = None
@@ -827,9 +850,9 @@ class BOM(WebsiteGenerator):
return False
def get_item_data(self, name):
def get_item_data(self, item_code, operation_row_id):
for row in self.items:
if row.item_code == name:
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
return row
@frappe.whitelist()

View File

@@ -7,7 +7,7 @@ from functools import partial
import frappe
from frappe.tests import timeout
from frappe.utils import cstr, flt
from frappe.utils import cint, cstr, flt
from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
@@ -919,6 +919,207 @@ class TestBOM(ERPNextTestSuite):
for row in bom.items:
self.assertEqual(row.stock_uom, "Kg")
@timeout
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the first operation produces nothing derivable: no FG item, no BOM to take it from
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[0].finished_good = sfg_item
bom.insert()
# the final operation's FG item is derived from the BOM's own item
self.assertEqual(bom.operations[1].finished_good, fg_item)
@timeout
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.insert()
def rows_for(item_code, operation_row_id):
return [
row
for row in bom.items
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
]
# the item already used by operation 1 gets its own new row under operation 2
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
# adding it again for the same operation updates the row instead of stacking another
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
@timeout
def test_operation_bom_materials_expand_on_single_pass_submit(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"bom_no": sfg_bom.name,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.submit()
self.assertEqual(bom.docstatus, 1)
self.assertEqual(bom.operations[0].finished_good, sfg_item)
self.assertTrue(
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
)
@timeout
def test_final_operation_must_produce_the_bom_item(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
"finished_good": sfg_item,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the final operation claims to produce the semi FG, not this BOM's item
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[1].finished_good = fg_item
bom.insert()
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})

View File

@@ -140,7 +140,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fetch_from": "item_code.stock_uom",
@@ -264,7 +265,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 21:15:55.187671",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Creator Item",

View File

@@ -177,7 +177,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fieldname": "rate_amount_section",
@@ -327,7 +328,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 19:00:38.646539",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",

View File

@@ -213,6 +213,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "FG / Semi FG Item",
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
"options": "Item"
},
{
@@ -307,7 +308,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-25 17:15:42.044630",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -99,6 +99,7 @@
"fieldtype": "Float",
"label": "Conversion Factor",
"non_negative": 1,
"precision": "9",
"reqd": 1
},
{
@@ -217,7 +218,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-16 16:51:40.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Secondary Item",

View File

@@ -891,6 +891,9 @@ class JobCard(Document):
frappe.msgprint(message, alert=True, indicator="orange")
def validate_transfer_qty(self):
if self.track_semi_finished_goods and self.skip_material_transfer:
return
if (
not self.finished_good
and not self.is_corrective_job_card
@@ -1111,6 +1114,9 @@ class JobCard(Document):
wo.calculate_operating_cost()
wo.set_actual_dates()
if wo.track_semi_finished_goods:
wo.set_process_loss_qty()
if time_data:
wo.status = "In Process"
@@ -1461,12 +1467,12 @@ class JobCard(Document):
)
if self.track_semi_finished_goods and previous_operations:
manufactured_qty = self.get_manufactured_qty_per_operation(
[row.name for row in previous_operations]
)
totals = self.get_manufactured_qty_per_operation([row.name for row in previous_operations])
for row in previous_operations:
row.manufactured_qty = flt(manufactured_qty.get(row.name))
operation_totals = totals.get(row.name)
row.manufactured_qty = flt(operation_totals and operation_totals.manufactured_qty)
row.process_loss_qty = flt(operation_totals and operation_totals.process_loss_qty)
return previous_operations
@@ -1475,7 +1481,11 @@ class JobCard(Document):
data = (
frappe.qb.from_(job_card)
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
.select(
job_card.operation_id,
Sum(job_card.manufactured_qty).as_("manufactured_qty"),
Sum(job_card.process_loss_qty).as_("process_loss_qty"),
)
.where(
(job_card.work_order == self.work_order)
& (job_card.docstatus == 1)
@@ -1483,9 +1493,9 @@ class JobCard(Document):
& (job_card.operation_id.isin(operation_ids))
)
.groupby(job_card.operation_id)
).run()
).run(as_dict=True)
return dict(data)
return {row.operation_id: row for row in data}
def get_current_operation_completed_qty(self):
current_operation_qty = 0.0
@@ -1537,19 +1547,35 @@ class JobCard(Document):
OperationSequenceError,
)
if manufactured_qty < current_operation_qty:
if manufactured_qty >= current_operation_qty:
return
if manufactured_qty + flt(row.process_loss_qty) >= current_operation_qty:
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}, as {4} was booked as process loss there."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation),
bold(self.get_qty_with_uom(flt(row.process_loss_qty), row.finished_good)),
),
OperationSequenceError,
)
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation),
),
OperationSequenceError,
)
def validate_work_order(self):
if self.is_work_order_closed():
frappe.throw(_("You cannot make any changes to Job Card since Work Order is closed."))
@@ -1801,10 +1827,11 @@ class JobCard(Document):
def build_manufacture_stock_entry(self):
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
consumed_process_loss = self.get_consumed_process_loss()
return ManufactureEntry(
{
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - self.get_consumed_process_loss(), 0),
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,

View File

@@ -1447,6 +1447,204 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(flt(job_card.manufactured_qty), 3)
self.assertEqual(job_card.status, "Completed")
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Process Loss Rollup Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=8,
for_quantity=10,
pending_qty=0,
process_loss_qty=2,
end_time="2024-05-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.process_loss_qty), 2)
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
self.assertEqual(
flt(
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
),
2,
)
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
"""Loss booked by an earlier operation shrinks what the final operation can produce,
so it has to show up on the work order even though the final operation loses nothing."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operations = [
{
"operation": "Intermediate Loss Op A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"sequence_id": 1,
},
{
"operation": "Intermediate Loss Op B",
"finished_good": fg,
"is_final_finished_good": 1,
"sequence_id": 2,
},
]
for row in operations:
row.update(
{
"workstation": "_Test Workstation A",
"finished_good_qty": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
)
make_workstation(row)
make_operation(row)
fg_bom.append("operations", row)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
for row in work_order.operations:
row.time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
def get_job_card(operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card",
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
"name",
),
)
jc_a = get_job_card("Intermediate Loss Op A")
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
jc_a.save()
jc_a.complete_job_card(
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
)
jc_a.reload()
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.process_loss_qty), 2)
# Operation A handed over only 8 units, so the final operation works on 8.
jc_b = get_job_card("Intermediate Loss Op B")
jc_b.for_quantity = 8
for row in jc_b.items:
row.required_qty = 8
jc_b.append(
"time_logs",
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
)
jc_b.save()
jc_b.submit()
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -1726,6 +1924,299 @@ class TestJobCard(ERPNextTestSuite):
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item("PL Scope RM 1", {"is_stock_item": 1}).name
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": "PL Scope Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "PL Scope Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 2
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
# operation A's loss must not leak into operation B's entry
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
fg_row = next(row for row in me_b.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 3.0)
me_b.submit()
def make_semi_fg_work_order(self, prefix, qty=5):
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
consumes it. Both operations skip material transfer; stock is pre-seeded."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item(f"{prefix} RM 1", {"is_stock_item": 1}).name
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": f"{prefix} Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": f"{prefix} Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=qty,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
for item_code in (rm1, rm2, sfg):
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
return work_order
def get_semi_fg_job_card(self, work_order, operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
)
def test_partial_manufacture_entry_then_finish(self):
work_order = self.make_semi_fg_work_order("PL Partial")
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
# so it accounts for 3 of 5 and its materials are trimmed to the same share
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in first.items if row.is_finished_item)
fg_row.qty = 1
for row in first.items:
if row.s_warehouse and not row.is_finished_item:
row.qty = flt(row.qty) * 3 / 5
first.save()
first.submit()
# the follow-up entry must be generated net of the already-booked loss and still submit
jc_b.reload()
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in second.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 2.0)
self.assertEqual(flt(second.process_loss_qty), 0.0)
second.submit()
jc_b.reload()
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
# across both entries, consumption adds up to the job card's requirement of 5, no more
consumed = frappe.get_all(
"Stock Entry Detail",
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
fields=["item_code", {"SUM": "qty", "as": "qty"}],
group_by="item_code",
)
self.assertTrue(consumed)
for row in consumed:
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
def test_update_after_submit_keeps_manufacture_entry_intact(self):
work_order = self.make_semi_fg_work_order("PL Update")
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
entry.submit()
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
entry.reload()
entry.select_print_heading = "_Test SFG Heading"
entry.save()
entry.reload()
self.assertEqual(flt(entry.process_loss_qty), 2.0)
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
work_order = self.make_semi_fg_work_order("PL NoBom")
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_one.submit()
stale = frappe.get_doc("Stock Entry", draft_two.name)
self.assertRaises(frappe.ValidationError, stale.submit)
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -2302,3 +2793,43 @@ class TestJobCardLogic(ERPNextTestSuite):
self.assertTrue(jc.has_overlap(1, sequential))
self.assertFalse(jc.has_overlap(2, sequential))
self.assertTrue(jc.has_overlap(2, overlapping))
def test_previous_operation_shortfall_from_process_loss_gets_the_right_message(self):
jc = frappe.new_doc("Job Card")
jc.operation = "_Test Painting"
jc.stock_uom = "Nos"
row = frappe._dict(
operation="_Test Assembly", manufactured_qty=8, process_loss_qty=2, finished_good=None
)
with self.assertRaises(OperationSequenceError) as loss_error:
jc.validate_previous_operation_manufactured_qty(row, 10)
self.assertIn("process loss", str(loss_error.exception))
row.process_loss_qty = 0
with self.assertRaises(OperationSequenceError) as pending_error:
jc.validate_previous_operation_manufactured_qty(row, 10)
self.assertIn("Submit the manufacturing entry", str(pending_error.exception))
jc.validate_previous_operation_manufactured_qty(row, 8)
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
jc = frappe.new_doc("Job Card")
jc.track_semi_finished_goods = 1
jc.skip_material_transfer = 1
jc.for_quantity = 10
jc.transferred_qty = 0
jc.append("items", {"item_code": "_Test Item"})
jc.validate_transfer_qty()
# with transfer enabled, a legacy card without an FG item keeps the strict check
jc.skip_material_transfer = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
jc.finished_good = "_Test Item"
jc.validate_transfer_qty()
jc.finished_good = None
jc.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)

View File

@@ -193,6 +193,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -266,7 +267,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-10-30 17:01:25.996352",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",

View File

@@ -11,6 +11,7 @@ existing imports of ``...services.material_planning`` keep working through here.
import copy
import json
from collections import defaultdict
from decimal import ROUND_CEILING, Decimal
import frappe
from frappe import _, msgprint
@@ -493,8 +494,16 @@ def get_material_request_items(
)
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
return _material_request_item_row(
row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
row,
sales_order,
target_warehouse,
bin_dict,
required_qty,
conversion_factor,
item_group_defaults,
min_order_qty,
)
@@ -533,13 +542,24 @@ def _adjust_required_qty_for_uom(row, required_qty):
row["purchase_uom"], row["stock_uom"], row.item_code
)
)
required_qty = required_qty / row["conversion_factor"]
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
return required_qty
def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0):
"""Convert to purchase UOM; a binding minimum order qty takes the smallest
representable quantity whose stock equivalent still meets it."""
precision = frappe.get_precision("Material Request Plan Item", "quantity")
quantity = flt(required_qty / conversion_factor, precision)
if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty:
grid = Decimal(10) ** -precision
exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor))
quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING))
return quantity
def _mr_purchase_conversion_factor(row):
item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1)
if (
@@ -552,7 +572,14 @@ def _mr_purchase_conversion_factor(row):
def _material_request_item_row(
row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults
row,
sales_order,
warehouse,
bin_dict,
required_qty,
conversion_factor,
item_group_defaults,
min_order_qty=0,
):
warehouse = (
warehouse
@@ -563,7 +590,7 @@ def _material_request_item_row(
return {
"item_code": row.item_code,
"item_name": row.item_name,
"quantity": required_qty / conversion_factor,
"quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty),
"conversion_factor": conversion_factor,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
@@ -640,7 +667,8 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = required_qty / item.get("conversion_factor")
min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0
item["quantity"] = _quantity_in_purchase_uom(required_qty, item.get("conversion_factor"), min_order_qty)
new_mr_items.append(item)

View File

@@ -1367,6 +1367,29 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.uom, "Nos")
self.assertEqual(row.qty, 1)
def test_material_request_item_quantity_rounded_to_precision(self):
from erpnext.stock.doctype.item.test_item import make_item
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
bom_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
).name
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
doc = frappe.get_doc("Item", bom_item)
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
doc.save()
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1"
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
self.assertEqual(len(pln.mr_items), 1)
self.assertEqual(pln.mr_items[0].quantity, flt(10 / 3, precision))
def test_material_request_for_sub_assembly_items(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
@@ -2252,6 +2275,40 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.get("uom"), "Nos")
self.assertEqual(row.get("conversion_factor"), 10.0)
def test_remaining_purchase_qty_rounded_to_precision(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
bom_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
).name
store_warehouse = create_warehouse("Store Warehouse", company="_Test Company")
rm_warehouse = create_warehouse("RM Warehouse", company="_Test Company")
make_stock_entry(item_code=bom_item, qty=4, target=store_warehouse, rate=100)
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
doc = frappe.get_doc("Item", bom_item)
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
doc.save()
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item, planned_qty=30, stock_uom="_Test UOM 1", do_not_submit=1
)
pln.for_warehouse = rm_warehouse
pln.ignore_existing_ordered_qty = 1
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": store_warehouse}])
rows_by_type = {row.get("material_request_type"): row for row in items}
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
self.assertEqual(rows_by_type["Purchase"].get("quantity"), flt(26 / 3, precision))
def test_unreserve_qty_on_closing_of_pp(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_or_make_bin
@@ -2327,6 +2384,73 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
def test_min_order_qty_conversion_takes_grid_ceiling(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_quantity_in_purchase_uom,
)
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232)
self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249)
self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0)
self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231)
def test_min_order_qty_grid_ceiling_in_plan_items(self):
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1)
pln.consider_minimum_order_qty = 1
mr_items = get_items_for_material_requests(pln.as_dict())
self.assertEqual(mr_items[0].get("quantity"), 110.232)
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
def test_min_order_qty_grid_ceiling_from_other_locations(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
original_precision = frappe.db.get_default("float_precision")
frappe.db.set_default("float_precision", "3")
self.addCleanup(frappe.db.set_default, "float_precision", original_precision)
conversion_factor = 453.592292197
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"},
uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}],
).name
rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company")
source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse)
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1)
pln.for_warehouse = rm_warehouse
pln.consider_minimum_order_qty = 1
pln.ignore_existing_ordered_qty = 1
mr_items = get_items_for_material_requests(
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
)
rows_by_type = {d.get("material_request_type"): d for d in mr_items}
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232)
def test_fg_item_quantity(self):
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1}).name

View File

@@ -9,6 +9,7 @@ the controller; work_order.py re-exports them for backward compatibility.
"""
import json
import math
from functools import partial
import frappe
@@ -476,42 +477,104 @@ def create_pick_list(
):
frappe.has_permission("Pick List", "create", throw=True)
for_qty = for_qty or frappe.parse_json(target_doc).get("for_qty")
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
postprocess = partial(
_set_pick_list_item_qty, for_qty=for_qty, max_finished_goods_qty=max_finished_goods_qty
)
if for_qty is None:
for_qty = frappe.parse_json(target_doc or "{}").get("for_qty")
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess), target_doc)
for_qty = _validated_for_qty(for_qty)
work_order = frappe.get_doc("Work Order", source_name)
allocation = _allocate_material_demand(work_order, for_qty / flt(work_order.qty))
postprocess = partial(_set_pick_list_item_qty, allocation_by_item=allocation)
doc = get_mapped_doc("Work Order", source_name, _pick_list_mapping(postprocess, allocation), target_doc)
_validate_material_is_pending(doc.locations)
doc.purpose = "Material Transfer for Manufacture"
doc.for_qty = for_qty
doc.set_item_locations()
return doc
def _pick_list_mapping(postprocess):
def _pick_list_mapping(postprocess, allocation):
return {
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
"Work Order Item": {
"doctype": "Pick List Item",
"field_no_map": ["transferred_qty"],
"postprocess": postprocess,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished_goods_qty):
pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
def _allocate_material_demand(work_order, fraction):
"""Fraction of each (item, warehouse, operation row) group's requirement, capped
at the group's proportional share of the item's pending pool."""
required_by_item = {}
covered_by_item = {}
required_by_group = {}
for row in work_order.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
covered_by_item.setdefault(
row.item_code,
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty),
)
key = _allocation_key(row)
required_by_group[key] = required_by_group.get(key, 0.0) + flt(row.required_qty)
qty = 0
if desire_to_transfer <= pending_to_issue:
qty = desire_to_transfer
elif pending_to_issue > 0:
qty = pending_to_issue
pending_pool = {
item_code: required_qty - covered_by_item[item_code]
for item_code, required_qty in required_by_item.items()
}
if not qty:
allocation = {}
for key, required_qty in required_by_group.items():
item_code = key[0]
if required_by_item[item_code] <= 0:
continue
pool_share = pending_pool[item_code] * required_qty / required_by_item[item_code]
qty = min(required_qty * fraction, pool_share)
if qty > 0:
allocation[key] = qty
return allocation
def _allocation_key(row):
"""Manual rows have no operation_row_id; their operation label splits them."""
return (row.item_code, row.source_warehouse, cint(row.operation_row_id) or row.operation)
def _merge_allocation_per_item(allocation):
"""Material Request rejects repeated item codes unless Buying Settings allows them."""
merged = {}
key_by_item = {}
for key, qty in allocation.items():
item_code = key[0]
if item_code in key_by_item:
merged[key_by_item[item_code]] += qty
else:
key_by_item[item_code] = key
merged[key] = qty
return merged
def _validated_for_qty(for_qty):
qty = flt(for_qty)
if not math.isfinite(qty) or qty <= 0:
frappe.throw(_("Quantity must be greater than zero."))
return qty
def _validate_material_is_pending(rows):
if not rows:
frappe.throw(
_("All required items have already been transferred, requested or picked."),
title=_("No Pending Materials"),
)
def _set_pick_list_item_qty(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
@@ -523,15 +586,32 @@ def _set_pick_list_item_qty(source, target, source_parent, for_qty, max_finished
@frappe.whitelist()
def make_material_request(source_name: str, target_doc: str | dict | Document | None = None):
def make_material_request(
source_name: str, target_doc: str | dict | Document | None = None, for_qty: float | None = None
):
frappe.has_permission("Material Request", "create", throw=True)
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
if for_qty is None and frappe.flags.args:
for_qty = frappe.flags.args.for_qty
work_order = frappe.get_doc("Work Order", source_name)
fraction = 1.0
if for_qty is not None:
fraction = _validated_for_qty(for_qty) / flt(work_order.qty)
allocation = _allocate_material_demand(work_order, fraction)
if not cint(frappe.db.get_single_value("Buying Settings", "allow_multiple_items")):
allocation = _merge_allocation_per_item(allocation)
postprocess = partial(_set_material_request_item, allocation_by_item=allocation)
doc = get_mapped_doc(
"Work Order", source_name, _material_request_mapping(postprocess, allocation), target_doc
)
_validate_material_is_pending(doc.items)
doc.material_request_type = "Material Transfer"
return doc
def _material_request_mapping():
def _material_request_mapping(postprocess, allocation):
return {
"Work Order": {
"doctype": "Material Request",
@@ -541,19 +621,23 @@ def _material_request_mapping():
"Work Order Item": {
"doctype": "Material Request Item",
"field_map": [
("required_qty", "qty"),
("stock_uom", "uom"),
("source_warehouse", "from_warehouse"),
],
"postprocess": _set_material_request_item,
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
"postprocess": postprocess,
"condition": lambda doc: _allocation_key(doc) in allocation,
},
}
def _set_material_request_item(source, target, source_parent):
def _set_material_request_item(source, target, source_parent, allocation_by_item):
qty = allocation_by_item.pop(_allocation_key(source), 0.0)
if qty <= 0:
target.delete()
return
target.warehouse = source_parent.wip_warehouse
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
target.qty = qty
target.schedule_date = nowdate()

View File

@@ -9,6 +9,7 @@ callers and the whitelisted entry point keep working unchanged.
"""
import frappe
from frappe import _
from frappe.utils import flt
from pypika import functions as fn
@@ -198,6 +199,97 @@ class RequiredItemsService:
for row in self.doc.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)
def validate_incoming_material_demand(self, incoming_qty_by_item):
"""Reject demand exceeding the pending requirement; callers must hold the
work order row lock (for_update=True)."""
required_by_item = {}
uom_by_item = {}
for row in self.doc.required_items:
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
uom_by_item.setdefault(row.item_code, row.stock_uom)
transferred = self._material_transfer_qty_by_item(is_return=0)
requested = self._material_request_pending_qty_by_item()
picked = self._pick_list_pending_qty_by_item()
for item_code, incoming_qty in incoming_qty_by_item.items():
if item_code not in required_by_item:
continue
pending = (
required_by_item[item_code]
- flt(transferred.get(item_code))
- flt(requested.get(item_code))
- flt(picked.get(item_code))
)
if flt(incoming_qty - pending, 6) > 0:
frappe.throw(
_("Only {0} {1} of {2} is pending in Work Order {3}.").format(
max(pending, 0.0), uom_by_item[item_code], item_code, self.doc.name
),
title=_("Exceeds Pending Qty"),
)
def update_requested_qty_for_required_items(self):
"""Refresh per-row qty requested via open Material Requests but not yet transferred."""
requested_items = self._material_request_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("requested_qty", (requested_items.get(row.item_code) or 0.0), update_modified=False)
def _material_request_pending_qty_by_item(self):
mr = frappe.qb.DocType("Material Request")
mr_item = frappe.qb.DocType("Material Request Item")
query = (
frappe.qb.from_(mr)
.inner_join(mr_item)
.on(mr_item.parent == mr.name)
.select(mr_item.item_code, fn.Sum(mr_item.stock_qty - mr_item.ordered_qty).as_("qty"))
.where(
(mr.docstatus == 1)
& (mr.work_order == self.doc.name)
& (mr.material_request_type == "Material Transfer")
& (mr.status != "Stopped")
& (mr_item.stock_qty > mr_item.ordered_qty)
)
.groupby(mr_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def update_picked_qty_for_required_items(self):
"""Refresh per-row qty picked but not yet transferred. Rows of a live material
request count as requested_qty instead, until that request stops or cancels."""
picked_items = self._pick_list_pending_qty_by_item()
for row in self.doc.required_items:
row.db_set("picked_qty", (picked_items.get(row.item_code) or 0.0), update_modified=False)
def _pick_list_pending_qty_by_item(self):
pick_list = frappe.qb.DocType("Pick List")
pick_list_item = frappe.qb.DocType("Pick List Item")
mr = frappe.qb.DocType("Material Request")
query = (
frappe.qb.from_(pick_list)
.inner_join(pick_list_item)
.on(pick_list_item.parent == pick_list.name)
.left_join(mr)
.on(pick_list_item.material_request == mr.name)
.select(
pick_list_item.item_code,
fn.Sum(pick_list_item.picked_qty - pick_list_item.transferred_qty).as_("qty"),
)
.where(
(pick_list.docstatus == 1)
& (pick_list.work_order == self.doc.name)
& (pick_list_item.picked_qty > pick_list_item.transferred_qty)
& (
(fn.Coalesce(pick_list_item.material_request_item, "") == "")
| (mr.docstatus != 1)
| (mr.status == "Stopped")
)
)
.groupby(pick_list_item.item_code)
)
return frappe._dict({d.item_code: flt(d.qty) for d in query.run(as_dict=1)})
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")

View File

@@ -291,6 +291,12 @@ class StatusService:
)
def set_process_loss_qty(self):
self.doc.db_set("process_loss_qty", self._process_loss_qty())
def _process_loss_qty(self):
if self.doc.track_semi_finished_goods:
return flt(sum(flt(row.process_loss_qty) for row in self.doc.operations))
table = frappe.qb.DocType("Stock Entry")
process_loss_qty = (
frappe.qb.from_(table)
@@ -302,7 +308,7 @@ class StatusService:
)
).run()[0][0]
self.doc.db_set("process_loss_qty", flt(process_loss_qty))
return flt(process_loss_qty)
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.doc.production_plan)

View File

@@ -1638,6 +1638,359 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_material_request_qty_scales_with_requested_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
mr = make_material_request(work_order.name, for_qty=4)
self.assertEqual(len(mr.items), len(required_qty))
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code] * 4 / 10)
mr = make_material_request(work_order.name)
for row in mr.items:
self.assertEqual(row.qty, required_qty[row.item_code])
def test_material_request_qty_capped_at_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
partially_transferred = work_order.required_items[0]
partially_transferred.db_set("transferred_qty", flt(partially_transferred.required_qty) - 1)
work_order.reload()
mr = make_material_request(work_order.name, for_qty=10)
requested_qty = {row.item_code: row.qty for row in mr.items}
self.assertEqual(requested_qty[partially_transferred.item_code], 1)
def test_material_request_maps_only_selected_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
selected = work_order.required_items[0]
try:
frappe.flags.selected_children = {"required_items": [selected.name]}
mr = make_material_request(work_order.name, for_qty=4)
finally:
frappe.flags.selected_children = None
self.assertEqual([row.item_code for row in mr.items], [selected.item_code])
self.assertEqual(mr.items[0].qty, flt(selected.required_qty) * 4 / 10)
def test_material_request_rejects_nonpositive_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, make_material_request, work_order.name, for_qty=-1)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("inf")
)
self.assertRaises(
frappe.ValidationError, make_material_request, work_order.name, for_qty=float("nan")
)
def test_pick_list_rejects_nonpositive_qty(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=0)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=-1)
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("inf"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=float("nan"))
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name)
def submit_material_request(self, work_order_name, for_qty=None):
mr = make_material_request(work_order_name, for_qty=for_qty)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
mr.submit()
return mr
def receive_test_fg_raw_materials(self):
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=1000.0
)
def test_requested_qty_tracks_open_material_requests(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name, for_qty=10)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
mr.cancel()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
def test_requested_qty_moves_to_transferred_qty_on_stock_entry(self):
from erpnext.stock.doctype.material_request.mapper import make_stock_entry as mr_to_stock_entry
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
stock_entry.insert()
stock_entry.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.transferred_qty, mr_qty[row.item_code])
remainder_mr = make_material_request(work_order.name)
for row in remainder_mr.items:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - mr_qty[row.item_code])
def test_picked_qty_tracks_open_pick_lists(self):
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
pick_list = create_pick_list(work_order.name, for_qty=4)
pick_list.insert()
pick_list.submit()
picked_qty = {row.item_code: flt(row.stock_qty) for row in pick_list.locations}
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.picked_qty, picked_qty[row.item_code])
remainder_pick_list = create_pick_list(work_order.name, for_qty=10)
for row in remainder_pick_list.locations:
required_row = next(item for item in work_order.required_items if item.item_code == row.item_code)
self.assertEqual(row.qty, flt(required_row.required_qty) - picked_qty[row.item_code])
remainder_pick_list.insert()
remainder_pick_list.submit()
self.assertRaises(frappe.ValidationError, create_pick_list, work_order.name, for_qty=10)
def test_material_request_submit_rejects_exceeding_pending_qty(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
def full_draft():
mr = make_material_request(work_order.name)
mr.schedule_date = today()
for item in mr.items:
item.schedule_date = today()
mr.insert()
return mr
first, second = full_draft(), full_draft()
first.submit()
self.assertRaises(frappe.ValidationError, second.submit)
def test_picked_qty_counts_pick_list_of_stopped_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
mr_qty = {row.item_code: flt(row.qty) for row in mr.items}
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.submit()
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, mr_qty[row.item_code])
self.assertEqual(row.picked_qty, 0)
mr.reload()
mr.update_status("Stopped")
work_order.reload()
for row in work_order.required_items:
self.assertEqual(row.requested_qty, 0)
self.assertEqual(row.picked_qty, mr_qty[row.item_code])
def test_pending_demand_shared_across_duplicate_item_rows(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
total_required = flt(first.required_qty) + 5
mr = self.submit_material_request(work_order.name, for_qty=4)
requested = sum(flt(row.qty) for row in mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(requested, total_required * 4 / 10, places=6)
work_order.reload()
for row in work_order.required_items:
if row.item_code == first.item_code:
self.assertAlmostEqual(row.requested_qty, requested, places=6)
remainder_mr = make_material_request(work_order.name, for_qty=10)
remainder = sum(flt(row.qty) for row in remainder_mr.items if row.item_code == first.item_code)
self.assertAlmostEqual(remainder, total_required - requested, places=6)
def test_allocation_splits_by_source_warehouse(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = {row.from_warehouse: flt(row.qty) for row in mr.items if row.item_code == first.item_code}
self.assertEqual(len(rows), 2)
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 4 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 4 / 10, places=6)
def test_allocation_collapses_groups_when_multiple_items_disallowed(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
mr = self.submit_material_request(work_order.name, for_qty=4)
rows = [row for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 1)
self.assertAlmostEqual(flt(rows[0].qty), (flt(first.required_qty) + 5) * 4 / 10, places=6)
def test_remainder_allocation_splits_proportionally_across_groups(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
duplicate = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": "_Test Warehouse 1 - _TC",
"docstatus": 1,
},
)
duplicate.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
self.submit_material_request(work_order.name, for_qty=4)
work_order.reload()
remainder = make_material_request(work_order.name, for_qty=10)
rows = {
row.from_warehouse: flt(row.qty) for row in remainder.items if row.item_code == first.item_code
}
self.assertAlmostEqual(rows["Stores - _TC"], flt(first.required_qty) * 6 / 10, places=6)
self.assertAlmostEqual(rows["_Test Warehouse 1 - _TC"], 5 * 6 / 10, places=6)
def test_allocation_splits_manual_rows_by_operation_label(self):
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
first = work_order.required_items[0]
for operation in ("_Test Operation A", "_Test Operation B"):
row = work_order.append(
"required_items",
{
"item_code": first.item_code,
"required_qty": 5,
"stock_uom": first.stock_uom,
"source_warehouse": first.source_warehouse,
"operation": operation,
"docstatus": 1,
},
)
row.db_insert()
work_order.reload()
with self.change_settings("Buying Settings", {"allow_multiple_items": 1}):
mr = make_material_request(work_order.name, for_qty=4)
rows = [flt(row.qty) for row in mr.items if row.item_code == first.item_code]
self.assertEqual(len(rows), 3)
self.assertAlmostEqual(sum(rows), (flt(first.required_qty) + 10) * 4 / 10, places=6)
def test_pick_list_rejects_over_pick_against_material_request(self):
from erpnext.stock.doctype.material_request.mapper import create_pick_list as mr_to_pick_list
self.receive_test_fg_raw_materials()
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=10, source_warehouse="Stores - _TC"
)
mr = self.submit_material_request(work_order.name, for_qty=4)
pick_list = mr_to_pick_list(mr.name)
pick_list.insert()
pick_list.locations[0].picked_qty = flt(pick_list.locations[0].stock_qty) + 1
self.assertRaises(frappe.ValidationError, pick_list.submit)
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",
@@ -5108,6 +5461,24 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(flt(qty_by_item.get(item_a)), 10.0)
self.assertEqual(flt(qty_by_item.get(item_b)), 10.0)
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
wo = frappe.new_doc("Work Order")
wo.track_semi_finished_goods = 1
wo.skip_transfer = 0
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
wo.wip_warehouse = "_Test Warehouse - _TC"
wo.validate_warehouse()
# the top-level target warehouse stays optional; operations may carry their own
wo.fg_warehouse = None
wo.validate_warehouse()
wo.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -5,7 +5,7 @@ frappe.ui.form.on("Work Order", {
setup: function (frm) {
frm.custom_make_buttons = {
"Stock Entry": "Start",
"Pick List": "Create Pick List",
"Pick List": "Pick List",
"Job Card": "Create Job Card",
};
@@ -818,13 +818,21 @@ erpnext.work_order = {
if (pending_to_transfer && frm.doc.status != "Stopped") {
frm.has_start_btn = true;
frm.add_custom_button(__("Create Pick List"), function () {
erpnext.work_order.create_pick_list(frm);
});
frm.add_custom_button(
__("Pick List"),
function () {
erpnext.work_order.create_pick_list(frm);
},
__("Create")
);
frm.add_custom_button(__("Material Request"), function () {
erpnext.work_order.make_material_request(frm);
});
frm.add_custom_button(
__("Material Request"),
function () {
erpnext.work_order.make_material_request(frm);
},
__("Create")
);
var start_btn = frm.add_custom_button(__("Start"), function () {
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
@@ -844,7 +852,10 @@ erpnext.work_order = {
function () {
let purpose = "Material Transfer for Manufacture";
erpnext.work_order
.show_prompt_for_qty_input(frm, purpose, qty, 1)
.show_prompt_for_qty_input(frm, purpose, {
qty: qty,
additional_transfer_entry: 1,
})
.then((data) => {
return frappe.xcall(
"erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
@@ -861,7 +872,7 @@ erpnext.work_order = {
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
});
},
__("Make")
__("Create")
);
}
}
@@ -895,7 +906,7 @@ erpnext.work_order = {
backflush_raw_materials_based_on
);
},
__("Make")
__("Create")
);
}
}
@@ -1030,6 +1041,26 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
get_max_requestable_qty: (frm) => {
const required = {};
const covered = {};
(frm.doc.required_items || []).forEach((row) => {
required[row.item_code] = (required[row.item_code] || 0) + flt(row.required_qty);
if (!(row.item_code in covered)) {
covered[row.item_code] =
flt(row.transferred_qty) + flt(row.requested_qty) + flt(row.picked_qty);
}
});
let max_fraction = 0;
Object.keys(required).forEach((item_code) => {
if (required[item_code] <= 0) return;
const pending = required[item_code] - covered[item_code];
max_fraction = Math.max(max_fraction, pending / required[item_code]);
});
return flt(max_fraction * flt(frm.doc.qty), precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
@@ -1084,20 +1115,20 @@ 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;
show_prompt_for_qty_input: function (frm, purpose, { qty, additional_transfer_entry, target } = {}) {
let max = qty == null ? this.get_max_transferable_qty(frm, purpose) : qty;
let fields = [
{
fieldtype: "Float",
label: __("Qty for {0}", [__(purpose)]),
label: __("Qty for {0}", [target || __(purpose)]),
fieldname: "qty",
description: __("Max: {0}", [max]),
default: max,
},
];
if (!additional_transfer_entry) {
if (!additional_transfer_entry && !target) {
fields.push({
fieldtype: "Check",
label: __("Consider Process Loss"),
@@ -1119,6 +1150,11 @@ erpnext.work_order = {
(data) => {
max += (frm.doc.qty * (frm.doc.__onload.overproduction_percentage || 0.0)) / 100;
if (!data.qty || data.qty <= 0) {
frappe.msgprint(__("Quantity must be greater than zero."));
reject();
return;
}
if (data.qty > max) {
frappe.msgprint(__("Quantity must not be more than {0}", [max]));
reject();
@@ -1161,15 +1197,32 @@ erpnext.work_order = {
}
},
make_material_request: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
});
make_material_request: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_material_request = (for_qty) =>
frappe.model.open_mapped_doc({
method: "erpnext.manufacturing.doctype.work_order.mapper.make_material_request",
frm,
args: { for_qty: for_qty },
});
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Material Request"),
}).then((data) => get_material_request(data.qty));
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
const max = this.get_max_transferable_qty(frm, purpose);
const max = this.get_max_requestable_qty(frm);
if (max <= 0) {
frappe.msgprint(__("All required items have already been transferred, requested or picked."));
return;
}
const get_pick_list = (for_qty) =>
frappe
@@ -1182,11 +1235,10 @@ erpnext.work_order = {
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
if (max <= 0) {
get_pick_list(frm.doc.qty);
} else {
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
}
this.show_prompt_for_qty_input(frm, purpose, {
qty: max,
target: __("Pick List"),
}).then((data) => get_pick_list(data.qty));
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {

View File

@@ -272,7 +272,7 @@
"fieldtype": "Link",
"label": "Work-in-Progress Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods",
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
"options": "Warehouse"
},
{
@@ -739,7 +739,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-06-03 21:35:34.175667",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -601,12 +601,9 @@ class WorkOrder(Document):
)
def validate_warehouse(self):
if self.track_semi_finished_goods:
return
if not self.wip_warehouse and not self.skip_transfer:
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
if not self.fg_warehouse:
if not self.fg_warehouse and not self.track_semi_finished_goods:
frappe.throw(_("Target Warehouse is required before Submit"))
def before_submit(self):

View File

@@ -22,6 +22,8 @@
"amount",
"column_break_11",
"transferred_qty",
"requested_qty",
"picked_qty",
"consumed_qty",
"returned_qty",
"section_break_idhr",
@@ -93,6 +95,22 @@
"label": "Transferred Qty",
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "requested_qty",
"fieldtype": "Float",
"label": "Requested Qty",
"no_copy": 1,
"read_only": 1
},
{
"depends_on": "eval:!parent.skip_transfer",
"fieldname": "picked_qty",
"fieldtype": "Float",
"label": "Picked Qty",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:!parent.subcontracting_inward_order",
@@ -209,7 +227,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2026-05-12 12:05:16.687866",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -31,8 +31,10 @@ class WorkOrderItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
picked_qty: DF.Float
rate: DF.Currency
required_qty: DF.Float
requested_qty: DF.Float
returned_qty: DF.Float
source_warehouse: DF.Link | None
stock_reserved_qty: DF.Float

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-10 11:01:49.066530",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Blanket Order (Standard)",
"name": "Blanket Order - Manufacturing",
"owner": "Administrator"
}

View File

@@ -23,6 +23,6 @@
"modified": "2026-07-10 11:47:13.281237",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM (Standard)",
"name": "BOM - Manufacturing",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-10 11:31:40.252142",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan (Standard)",
"name": "Production Plan - Manufacturing",
"owner": "Administrator"
}

View File

@@ -43,6 +43,6 @@
"modified": "2026-07-20 17:58:35.816693",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order (Standard)",
"name": "Work Order - Manufacturing",
"owner": "Administrator"
}

View File

@@ -508,3 +508,5 @@ erpnext.patches.v16_0.move_warehouse_defaults_to_company
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
erpnext.patches.v16_0.rename_italy_customer_name_fields

View File

@@ -1,3 +1,4 @@
import frappe
from frappe import qb
@@ -13,5 +14,8 @@ def execute():
"Payment Reconciliation Allocation",
]
for x in doctypes:
# child tables may not exist yet on sites where this pre-model-sync patch runs first
if not frappe.db.table_exists(x):
continue
dt = qb.DocType(x)
qb.from_(dt).delete().run()

View File

@@ -0,0 +1,53 @@
import frappe
RENAMED_FIELDS = {
"first_name": "italy_customer_first_name",
"last_name": "italy_customer_last_name",
}
def execute():
"""Rename Italy's Customer name fields, which clash with the standard quick-entry
first_name/last_name fields, and restore any Italy custom field columns that a
previously interrupted fixture run left missing."""
if not has_italy_fixtures():
return
duplicate_fieldnames = [
fieldname for fieldname in RENAMED_FIELDS if frappe.db.exists("Custom Field", f"Customer-{fieldname}")
]
from erpnext.regional.italy.setup import get_custom_fields, make_custom_fields
make_custom_fields()
for doctype in get_custom_fields():
frappe.clear_cache(doctype=doctype)
frappe.db.updatedb(doctype)
for old_fieldname, new_fieldname in RENAMED_FIELDS.items():
copy_customer_names(old_fieldname, new_fieldname)
for old_fieldname in duplicate_fieldnames:
frappe.delete_doc("Custom Field", f"Customer-{old_fieldname}", force=True)
if duplicate_fieldnames:
frappe.clear_cache(doctype="Customer")
def has_italy_fixtures():
return bool(
frappe.db.exists("Company", {"country": "Italy"})
or frappe.db.exists("Custom Field", "Company-fiscal_regime")
)
def copy_customer_names(old_fieldname, new_fieldname):
customer = frappe.qb.DocType("Customer")
old_column = customer[old_fieldname]
new_column = customer[new_fieldname]
(
frappe.qb.update(customer)
.set(new_column, old_column)
.where(old_column.isnotnull() & (old_column != ""))
.where(new_column.isnull() | (new_column == ""))
).run()

View File

@@ -0,0 +1,38 @@
import frappe
from erpnext.manufacturing.doctype.work_order.services.required_items import RequiredItemsService
def execute():
"""Backfill requested_qty and picked_qty for work orders with open demand;
fulfilled documents leave the zero default."""
work_orders = set(
frappe.get_all(
"Material Request",
filters={
"docstatus": 1,
"material_request_type": "Material Transfer",
"work_order": ("is", "set"),
"status": ("!=", "Stopped"),
"per_ordered": ("<", 100),
},
pluck="work_order",
distinct=True,
)
)
work_orders.update(
frappe.get_all(
"Pick List",
filters={"docstatus": 1, "work_order": ("is", "set"), "status": ("!=", "Completed")},
pluck="work_order",
distinct=True,
)
)
for name in work_orders:
if frappe.db.get_value("Work Order", name, "docstatus") != 1:
continue
service = RequiredItemsService(frappe.get_doc("Work Order", name))
service.update_requested_qty_for_required_items()
service.update_picked_qty_for_required_items()

View File

@@ -90,6 +90,7 @@ class Task(NestedSet):
self.validate_completed_on()
self.set_default_end_date_if_missing()
self.validate_parent_is_group()
self.validate_web_form_project_permission()
def validate_dates(self):
self.validate_from_to_dates("exp_start_date", "exp_end_date")
@@ -313,6 +314,23 @@ class Task(NestedSet):
if project_user:
return True
def validate_web_form_project_permission(self):
project_unchanged = not self.is_new() and self.project == self.get_db_value("project")
if (
not frappe.flags.in_web_form
or not self.project
or project_unchanged
or frappe.has_permission("Project", "write", doc=self.project)
or self.has_webform_permission()
):
return
frappe.throw(
_("You are not permitted to create a Task for Project {0}").format(self.project),
frappe.PermissionError,
)
def populate_depends_on(self):
if self.parent_task:
parent = frappe.get_doc("Task", self.parent_task)

View File

@@ -456,7 +456,7 @@ const set_employee_and_company = function (frm) {
const options = { user_id: frappe.session.user };
const fields = ["name", "company"];
frappe.db.get_value("Employee", options, fields).then(({ message }) => {
if (message) {
if (message.name && message.company) {
// there is an employee with the currently logged in user_id
frm.set_value("employee", message.name);
frm.set_value("company", message.company);

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-10 10:37:54.591039",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet (Standard)",
"name": "Timesheet - Projects",
"owner": "Administrator"
}

View File

@@ -1802,7 +1802,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let item = frappe.get_doc(cdt, cdn);
item.conversion_factor = 1.0;
if (item.stock_qty) {
item.conversion_factor = flt(item.stock_qty) / flt(item.qty);
item.conversion_factor = flt(
flt(item.stock_qty) / flt(item.qty),
precision("conversion_factor", item)
);
}
refresh_field("conversion_factor", item.name, item.parentfield);

View File

@@ -742,6 +742,7 @@ erpnext.utils.update_child_items = function (opts) {
qty: d.qty,
rate: d.rate,
uom: d.uom,
warehouse: d.warehouse,
fg_item: d.fg_item,
fg_item_qty: d.fg_item_qty,
description: d.description,
@@ -829,6 +830,7 @@ erpnext.utils.update_child_items = function (opts) {
item_name,
bom_no,
description,
warehouse,
} = r.message;
const row = dialog.fields_dict.trans_items.df.data.find(
(row) => row.name == me.doc.name
@@ -842,6 +844,7 @@ erpnext.utils.update_child_items = function (opts) {
item_name: item_name,
bom_no: bom_no,
description: me.doc.description || description,
warehouse: me.doc.docname ? me.doc.warehouse : warehouse,
});
dialog.fields_dict.trans_items.grid.refresh();
}
@@ -929,6 +932,29 @@ erpnext.utils.update_child_items = function (opts) {
});
}
const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse");
if (warehouse_df) {
fields.splice(3, 0, {
fieldtype: "Link",
fieldname: "warehouse",
options: "Warehouse",
in_list_view: 1,
label: __(warehouse_df.label),
// only new rows may set it, existing rows would leave their
// reserved qty stranded in the previous warehouse's bin
read_only_depends_on: "eval:doc.docname",
get_query: () => {
return {
filters: {
company: frm.doc.company,
is_group: 0,
disabled: 0,
},
};
},
});
}
if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) {
fields.push(
{

View File

@@ -99,8 +99,8 @@
{%- if doc.customer_data.customer_type == "Individual" %}
<CodiceFiscale>{{ doc.customer_data.fiscal_code }}</CodiceFiscale>
<Anagrafica>
<Nome>{{ doc.customer_data.first_name }}</Nome>
<Cognome>{{ doc.customer_data.last_name }}</Cognome>
<Nome>{{ doc.customer_data.italy_customer_first_name }}</Nome>
<Cognome>{{ doc.customer_data.italy_customer_last_name }}</Cognome>
</Anagrafica>
{%- else %}
<IdFiscaleIVA>

View File

@@ -23,6 +23,10 @@ def setup(company=None, patch=True):
def make_custom_fields(update=True):
create_custom_fields(get_custom_fields(), ignore_validate=frappe.flags.in_patch, update=update)
def get_custom_fields():
invoice_item_fields = [
dict(
fieldname="tax_rate",
@@ -96,7 +100,7 @@ def make_custom_fields(update=True):
),
]
custom_fields = {
return {
"Company": [
dict(
fieldname="sb_e_invoicing",
@@ -232,18 +236,18 @@ def make_custom_fields(update=True):
depends_on='eval:doc.customer_type=="Company"',
),
dict(
fieldname="first_name",
fieldname="italy_customer_first_name",
label="First Name",
fieldtype="Data",
insert_after="salutation",
insert_after="customer_type",
print_hide=1,
depends_on='eval:doc.customer_type!="Company"',
),
dict(
fieldname="last_name",
fieldname="italy_customer_last_name",
label="Last Name",
fieldtype="Data",
insert_after="first_name",
insert_after="italy_customer_first_name",
print_hide=1,
depends_on='eval:doc.customer_type!="Company"',
),
@@ -461,8 +465,6 @@ def make_custom_fields(update=True):
],
}
create_custom_fields(custom_fields, ignore_validate=frappe.flags.in_patch, update=update)
def setup_report():
report_name = "Electronic Invoice Register"

View File

@@ -199,7 +199,8 @@ class Customer(TransactionBase):
self.loyalty_program_tier = customer.loyalty_program_tier
if self.sales_team:
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
total = sum(flt(member.allocated_percentage) for member in self.sales_team)
if flt(total, self.precision("allocated_percentage", "sales_team")) != 100:
frappe.throw(_("Total contribution percentage should be equal to 100"))
@frappe.whitelist(methods=["POST"])

View File

@@ -38,6 +38,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -106,7 +107,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-21 18:11:30.134073",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Delivery Schedule Item",

View File

@@ -9,6 +9,11 @@ frappe.ui.form.on("Product Bundle", {
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
};
});
frm.set_query("item_code", "items", () => {
return {
query: "erpnext.controllers.queries.item_query",
};
});
// A submitted bundle is immutable. To change it, create a new version
// (a fresh draft copied from this one) and submit that instead.

View File

@@ -216,6 +216,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -729,7 +730,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 19:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",

View File

@@ -36,6 +36,7 @@ from erpnext.selling.doctype.sales_order.sales_order import (
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_bin_details
from erpnext.stock.utils import InvalidWarehouseCompany
from erpnext.tests.utils import ERPNextTestSuite
@@ -159,6 +160,38 @@ class TestSalesOrder(ERPNextTestSuite):
)
update_child_qty_rate("Sales Order", trans_item, so.name)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_order_negative_grand_total_blocked_without_setting(self):
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
self.assertRaises(frappe.ValidationError, so.save)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
so.save()
so.submit()
self.assertEqual(so.docstatus, 1)
self.assertTrue(so.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_order_negative_rate_error_links_to_selling_settings(self):
so = make_sales_order(qty=1, rate=100, do_not_save=True)
so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -10})
so.save()
with self.assertRaises(frappe.ValidationError) as error:
so.submit()
self.assertIn("selling-settings", str(error.exception))
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_order_negative_rate_setting_does_not_allow_negative_quantity(self):
so = make_sales_order(qty=-1, rate=100, do_not_save=True)
self.assertRaises(frappe.NonNegativeError, so.save)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_sales_order_qty(self):
so = make_sales_order(qty=1, do_not_save=True)
@@ -587,6 +620,116 @@ class TestSalesOrder(ERPNextTestSuite):
self.assertEqual(updated_total, prev_total + 1400)
self.assertNotEqual(updated_total_in_words, prev_total_in_words)
def test_update_child_adding_new_item_with_warehouse(self):
so = make_sales_order(item_code="_Test Item", qty=4)
first_item_of_so = so.get("items")[0]
self.assertNotEqual(first_item_of_so.warehouse, "_Test Warehouse 2 - _TC")
def get_trans_item(warehouse):
return json.dumps(
[
{
"item_code": first_item_of_so.item_code,
"rate": first_item_of_so.rate,
"qty": first_item_of_so.qty,
"docname": first_item_of_so.name,
"warehouse": warehouse,
},
{"item_code": "_Test Item 2", "rate": 200, "qty": 7, "warehouse": warehouse},
]
)
self.assertRaises(
InvalidWarehouseCompany,
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Warehouse 2 - _TC1"),
so.name,
)
self.assertRaisesRegex(
frappe.ValidationError,
"Group node warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Warehouse Group - _TC"),
so.name,
)
if not frappe.db.exists("Warehouse", "_Test Disabled Warehouse - _TC"):
frappe.get_doc(
{
"doctype": "Warehouse",
"warehouse_name": "_Test Disabled Warehouse",
"company": "_Test Company",
"disabled": 1,
}
).insert()
self.assertRaisesRegex(
frappe.ValidationError,
"Disabled Warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_item("_Test Disabled Warehouse - _TC"),
so.name,
)
update_child_qty_rate("Sales Order", get_trans_item("_Test Warehouse 2 - _TC"), so.name)
so.reload()
# the new row picks up the warehouse selected in the dialog
self.assertEqual(so.get("items")[-1].item_code, "_Test Item 2")
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
# existing rows keep theirs, so their reserved qty stays in the same bin
self.assertEqual(so.get("items")[0].warehouse, first_item_of_so.warehouse)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
item_code = make_item("_Test Item Without Default Warehouse", {"is_stock_item": 1}).name
so = make_sales_order(item_code="_Test Item", qty=4)
existing_item = so.get("items")[0]
# a company gets a default warehouse when its warehouses are created
company_default = frappe.db.get_value("Company", so.company, "default_warehouse")
frappe.db.set_value("Company", so.company, "default_warehouse", None)
self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default)
def get_trans_items(warehouse=None):
new_row = {"item_code": item_code, "rate": 200, "qty": 7}
if warehouse:
new_row["warehouse"] = warehouse
return json.dumps(
[
{
"item_code": existing_item.item_code,
"rate": existing_item.rate,
"qty": existing_item.qty,
"docname": existing_item.name,
},
new_row,
]
)
# no default in the Item Master, Item Group, Brand or Company
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Sales Order",
get_trans_items(),
so.name,
)
update_child_qty_rate("Sales Order", get_trans_items("_Test Warehouse - _TC"), so.name)
so.reload()
self.assertEqual(len(so.get("items")), 2)
self.assertEqual(so.get("items")[0].warehouse, existing_item.warehouse)
self.assertEqual(so.get("items")[-1].item_code, item_code)
self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse - _TC")
def test_update_child_removing_item(self):
so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]})
create_dn_against_so(so.name, 2)
@@ -3104,6 +3247,17 @@ class TestSalesOrder(ERPNextTestSuite):
so.save()
self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100)
with self.subTest("floating-point drift in the total is tolerated"):
# 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point
so = make_sales_order(do_not_save=True)
for sales_person, percentage in (
("_Test Sales Person", 10.0),
("_Test Sales Person 1", 58.02),
("_Test Sales Person 2", 31.98),
):
so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage})
so.save()
def test_sales_team_disabled_sales_person_rejected(self):
frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0)
try:

View File

@@ -271,6 +271,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1055,7 +1056,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-08 20:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -15,6 +15,6 @@
"modified": "2026-06-30 15:37:04.244159",
"modified_by": "Administrator",
"module": "Selling",
"name": "Product Bundle (Standard)",
"name": "Product Bundle - Selling",
"owner": "Administrator"
}

View File

@@ -19,6 +19,6 @@
"modified": "2026-07-20 15:34:21.043827",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation (Standard)",
"name": "Quotation - Selling",
"owner": "Administrator"
}

View File

@@ -79,6 +79,6 @@
"modified": "2026-07-20 14:52:59.147895",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order (Standard)",
"name": "Sales Order - Selling",
"owner": "Administrator"
}

View File

@@ -309,6 +309,8 @@ erpnext.company.setup_queries = function (frm) {
["discount_allowed_account", { root_type: "Expense" }],
["discount_received_account", { root_type: "Income" }],
["exchange_gain_loss_account", { root_type: ["in", ["Expense", "Income"]] }],
["exchange_gain_account", { root_type: ["in", ["Expense", "Income"]] }],
["exchange_loss_account", { root_type: ["in", ["Expense", "Income"]] }],
[
"unrealized_exchange_gain_loss_account",
{ root_type: ["in", ["Expense", "Income", "Equity", "Liability"]] },

View File

@@ -65,6 +65,8 @@
"default_finance_book",
"exchange_gain__loss_section",
"exchange_gain_loss_account",
"exchange_gain_account",
"exchange_loss_account",
"column_break_sttp",
"unrealized_exchange_gain_loss_account",
"round_off_section",
@@ -397,6 +399,24 @@
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Gain Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",

View File

@@ -103,7 +103,9 @@ class Company(NestedSet):
enable_provisional_accounting_for_non_stock_items: DF.Check
enable_stock_delivered_but_not_billed: DF.Check
exception_budget_approver_role: DF.Link | None
exchange_gain_account: DF.Link | None
exchange_gain_loss_account: DF.Link | None
exchange_loss_account: DF.Link | None
existing_company: DF.Link | None
expenses_added_to_stock_account: DF.Link | None
expenses_added_to_stock_contra_account: DF.Link | None
@@ -369,6 +371,8 @@ class Company(NestedSet):
["Default Payment Discount Account", "default_discount_account"],
["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
["Exchange Gain Account", "exchange_gain_account"],
["Exchange Loss Account", "exchange_loss_account"],
["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"],
["Round Off Account", "round_off_account"],
["Default Deferred Revenue Account", "default_deferred_revenue_account"],
@@ -792,6 +796,20 @@ class Company(NestedSet):
self.db_set("exchange_gain_loss_account", exchange_gain_loss_acct)
if not self.exchange_gain_account:
exchange_gain_acct = frappe.db.get_value(
"Account", {"account_name": _("Exchange Gain"), "company": self.name, "is_group": 0}
)
self.db_set("exchange_gain_account", exchange_gain_acct)
if not self.exchange_loss_account:
exchange_loss_acct = frappe.db.get_value(
"Account", {"account_name": _("Exchange Loss"), "company": self.name, "is_group": 0}
)
self.db_set("exchange_loss_account", exchange_loss_acct)
if not self.disposal_account:
disposal_acct = frappe.db.get_value(
"Account",

View File

@@ -257,6 +257,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -982,7 +983,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-18 10:00:00.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

@@ -430,7 +430,7 @@ def notify_customers(delivery_trip: str):
frappe.sendmail(
recipients=contact_info.email_id,
subject=dispatch_template.subject,
message=frappe.render_template(dispatch_template.response, context),
message=frappe.render_template(dispatch_template.response, context, restrict_globals=True),
attachments=get_attachments(stop),
)

View File

@@ -1508,7 +1508,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
"UOM Conversion Factor", {"to_uom": from_uom, "from_uom": to_uom}, ["value"], as_dict=1
)
if inverse_match:
return 1 / inverse_match.value
return flt(1 / inverse_match.value, frappe.get_precision("UOM Conversion Factor", "value"))
# This attempts to try and get conversion from intermediate UOM.
# case:
@@ -1528,7 +1528,7 @@ def get_uom_conv_factor(uom: str | None, stock_uom: str | None):
)
if intermediate_match:
return intermediate_match[0].value
return flt(intermediate_match[0].value, frappe.get_precision("UOM Conversion Factor", "value"))
@frappe.whitelist()

View File

@@ -288,51 +288,6 @@ def get_items_based_on_default_supplier(supplier: str):
return supplier_items
@frappe.whitelist()
def make_purchase_order_based_on_supplier(
source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None
):
mr = source_name
supplier_items = get_items_based_on_default_supplier(args.get("supplier"))
def postprocess(source, target_doc):
target_doc.supplier = args.get("supplier")
if getdate(target_doc.schedule_date) < getdate(nowdate()):
target_doc.schedule_date = None
target_doc.set(
"items",
[d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0],
)
set_missing_values(source, target_doc)
target_doc = get_mapped_doc(
"Material Request",
mr,
{
"Material Request": {
"doctype": "Purchase Order",
},
"Material Request Item": {
"doctype": "Purchase Order Item",
"field_map": [
["name", "material_request_item"],
["parent", "material_request"],
["uom", "stock_uom"],
["uom", "uom"],
],
"postprocess": update_item,
"condition": lambda doc: doc.ordered_qty < doc.qty,
},
},
target_doc,
postprocess,
)
return target_doc
@frappe.whitelist()
def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None):
def postprocess(source, target_doc):

View File

@@ -315,7 +315,8 @@
"fieldtype": "Link",
"label": "Work Order",
"options": "Work Order",
"read_only": 1
"read_only": 1,
"search_index": 1
},
{
"fieldname": "terms_tab",
@@ -376,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-07-30 11:04:31.517204",
"modified": "2026-08-07 10:30:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",

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