mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 07:19:31 +00:00
Compare commits
20 Commits
develop
...
codex/seri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653ef6eff2 | ||
|
|
acbefcd603 | ||
|
|
445a30ba60 | ||
|
|
fca005c935 | ||
|
|
e51c01628d | ||
|
|
766e51ae58 | ||
|
|
3acaa55db9 | ||
|
|
dbfec6e9fb | ||
|
|
1426a098f1 | ||
|
|
92b6d708d8 | ||
|
|
fa244a3615 | ||
|
|
cdb12ecf9d | ||
|
|
86489d6905 | ||
|
|
d81fe03776 | ||
|
|
f80cac927d | ||
|
|
687c7d55ba | ||
|
|
6f2cf3bf91 | ||
|
|
48818c963a | ||
|
|
c67a57d9bd | ||
|
|
ca880f6be7 |
@@ -8,7 +8,6 @@ from functools import reduce
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.file.utils import find_file_by_url
|
||||
from frappe.desk.form.linked_with import get_linked_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr
|
||||
@@ -59,8 +58,6 @@ def validate_columns(data):
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_company(company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
|
||||
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
|
||||
)
|
||||
@@ -113,10 +110,7 @@ def import_coa(file_name: str, company: str):
|
||||
|
||||
|
||||
def get_file(file_name):
|
||||
file_doc = find_file_by_url(file_name)
|
||||
if not file_doc:
|
||||
raise frappe.PermissionError
|
||||
|
||||
file_doc = frappe.get_doc("File", {"file_url": file_name})
|
||||
parts = file_doc.get_extension()
|
||||
extension = parts[1]
|
||||
extension = extension.lstrip(".")
|
||||
@@ -185,8 +179,6 @@ def get_coa(
|
||||
):
|
||||
"""called by tree view (to fetch node's children)"""
|
||||
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
file_doc, extension = get_file(file_name)
|
||||
parent = None if parent == _("All Accounts") else parent
|
||||
|
||||
@@ -334,8 +326,6 @@ def build_response_as_excel(writer):
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(file_type: str, template_type: str, company: str):
|
||||
frappe.has_permission("Chart of Accounts Importer", throw=True)
|
||||
|
||||
writer = get_template(template_type, company)
|
||||
|
||||
if file_type == "CSV":
|
||||
@@ -388,6 +378,7 @@ def get_sample_template(writer, company):
|
||||
return writer
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_accounts(file_doc: Document, extension: str):
|
||||
if extension == "csv":
|
||||
accounts = generate_data_from_csv(file_doc, as_dict=True)
|
||||
|
||||
@@ -17,8 +17,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, getdate
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
|
||||
@@ -148,31 +147,6 @@ class Dunning(AccountsController):
|
||||
)
|
||||
row.dunning_level = len(past_dunnings) + 1
|
||||
|
||||
def get_unpaid_base_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in company currency."""
|
||||
if not self.base_dunning_amount:
|
||||
return 0.0
|
||||
|
||||
return flt(
|
||||
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
|
||||
self.precision("base_dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_dunning_amount(self):
|
||||
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
|
||||
return flt(
|
||||
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
|
||||
self.precision("dunning_amount"),
|
||||
)
|
||||
|
||||
def get_unpaid_overdue_payments(self):
|
||||
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
|
||||
return [
|
||||
(row, outstanding)
|
||||
for row in self.overdue_payments
|
||||
if (outstanding := get_current_outstanding(row)) > 0
|
||||
]
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
self.ignore_linked_doctypes = [
|
||||
@@ -187,7 +161,6 @@ class Dunning(AccountsController):
|
||||
"Unreconcile Payment Entries",
|
||||
"Payment Ledger Entry",
|
||||
"Serial and Batch Bundle",
|
||||
"Payment Entry",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -286,73 +259,11 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if has_outstanding:
|
||||
break
|
||||
|
||||
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
|
||||
new_status = "Resolved" if not has_outstanding else "Unresolved"
|
||||
|
||||
|
||||
def update_dunnings_linked_to_payment(payment_entry):
|
||||
"""Refresh dunnings whose interest and fee are settled by this payment."""
|
||||
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
|
||||
|
||||
for name in dunnings:
|
||||
dunning = frappe.get_doc("Dunning", name)
|
||||
if dunning.docstatus != 1:
|
||||
continue
|
||||
|
||||
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
|
||||
|
||||
|
||||
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
|
||||
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
|
||||
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
|
||||
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
|
||||
|
||||
# resolving by hand waives the interest, only an invoice that is owed again reopens it
|
||||
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
|
||||
return
|
||||
|
||||
if dunning.status != new_status:
|
||||
dunning.db_set("status", new_status, notify=True)
|
||||
|
||||
|
||||
def get_paid_dunning_amount(dunning: str) -> float:
|
||||
"""Interest and fee collected for this dunning, in company currency."""
|
||||
deduction = frappe.qb.DocType("Payment Entry Deduction")
|
||||
payment_entry = frappe.qb.DocType("Payment Entry")
|
||||
|
||||
paid = (
|
||||
frappe.qb.from_(deduction)
|
||||
.join(payment_entry)
|
||||
.on(payment_entry.name == deduction.parent)
|
||||
.select(Sum(deduction.amount))
|
||||
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
|
||||
).run()
|
||||
|
||||
# the dunning amount is booked as a negative deduction, against the income account
|
||||
return -flt(paid[0][0]) if paid else 0.0
|
||||
|
||||
|
||||
def get_current_outstanding(overdue_payment) -> float:
|
||||
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
|
||||
invoice = frappe.db.get_value(
|
||||
"Sales Invoice",
|
||||
overdue_payment.sales_invoice,
|
||||
["outstanding_amount", "currency", "party_account_currency"],
|
||||
as_dict=True,
|
||||
)
|
||||
schedule_outstanding = (
|
||||
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
|
||||
if overdue_payment.payment_schedule
|
||||
else flt(overdue_payment.outstanding)
|
||||
)
|
||||
|
||||
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
|
||||
return 0.0
|
||||
|
||||
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
|
||||
if invoice.currency == invoice.party_account_currency:
|
||||
outstanding = min(outstanding, flt(invoice.outstanding_amount))
|
||||
|
||||
return outstanding
|
||||
if dunning.status != new_status:
|
||||
dunning.status = new_status
|
||||
dunning.save()
|
||||
|
||||
|
||||
def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
|
||||
@@ -55,125 +55,6 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
|
||||
"""
|
||||
Regression for #58220: paying the invoice without the interest and fee must not
|
||||
resolve the dunning, the interest is still owed and has to stay claimable.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "4", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the interest and fee can still be collected on their own
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "5", nowdate()
|
||||
self.assertEqual(pe.references, [])
|
||||
self.assertEqual(round(pe.paid_amount, 2), 10.41)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
|
||||
|
||||
# cancelling the interest payment makes the dunning claimable again
|
||||
pe.cancel()
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
|
||||
"""
|
||||
The payment collecting the interest links back to the dunning, which must not stand in
|
||||
the way of cancelling it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
pe.reference_no, pe.reference_date = "6", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
|
||||
dunning.cancel()
|
||||
self.assertEqual(dunning.docstatus, 2)
|
||||
|
||||
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
|
||||
"""
|
||||
Resolving a dunning by hand waives its interest, so a later payment of the invoice
|
||||
must not reopen it.
|
||||
"""
|
||||
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
|
||||
dunning.submit()
|
||||
sales_invoice = dunning.overdue_payments[0].sales_invoice
|
||||
|
||||
# what the "Resolve" button does
|
||||
dunning.reload()
|
||||
dunning.status = "Resolved"
|
||||
dunning.save()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", sales_invoice)
|
||||
pe.reference_no, pe.reference_date = "7", nowdate()
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Resolved")
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
|
||||
"""
|
||||
The interest and fee are collected as a Payment Entry deduction, a company currency
|
||||
field, so what is left to collect has to be measured in the same currency.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -15),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
|
||||
dunning.dunning_type = dunning_type.name
|
||||
dunning.rate_of_interest = dunning_type.rate_of_interest
|
||||
dunning.dunning_fee = dunning_type.dunning_fee
|
||||
dunning.income_account = dunning_type.income_account
|
||||
dunning.cost_center = dunning_type.cost_center
|
||||
dunning.save()
|
||||
|
||||
self.assertEqual(dunning.currency, "USD")
|
||||
self.assertEqual(dunning.conversion_rate, 50)
|
||||
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
|
||||
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
|
||||
|
||||
# nothing collected yet, in either currency
|
||||
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
|
||||
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
|
||||
|
||||
# the deduction booking the interest is in company currency
|
||||
dunning.submit()
|
||||
pe = get_payment_entry("Dunning", dunning.name)
|
||||
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
|
||||
|
||||
def test_fetch_overdue_payments(self):
|
||||
"""
|
||||
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.
|
||||
|
||||
@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
|
||||
total_credit += flt(row.credit, precision("credit", row));
|
||||
});
|
||||
|
||||
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
|
||||
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
|
||||
frm.doc.total_debit = total_debit;
|
||||
frm.doc.total_credit = total_credit;
|
||||
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
|
||||
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
|
||||
},
|
||||
|
||||
@@ -674,14 +674,12 @@ class JournalEntry(AccountsController):
|
||||
if d.debit and d.credit:
|
||||
frappe.throw(_("You cannot credit and debit same account at the same time"))
|
||||
|
||||
self.total_debit = flt(
|
||||
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
|
||||
)
|
||||
self.total_credit = flt(
|
||||
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
|
||||
)
|
||||
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
|
||||
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
|
||||
|
||||
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
|
||||
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
|
||||
self.total_credit, self.precision("total_credit")
|
||||
)
|
||||
|
||||
def validate_multi_currency(self):
|
||||
alternate_currency = []
|
||||
|
||||
@@ -461,59 +461,6 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def make_jv_with_fractional_totals(self):
|
||||
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = nowdate()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = "Journal Entry"
|
||||
jv.remark = "test"
|
||||
for amount in (0.10, 0.20):
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": amount,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 0.30,
|
||||
},
|
||||
)
|
||||
jv.insert()
|
||||
return jv
|
||||
|
||||
def test_totals_are_rounded_to_precision(self):
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
stored = frappe.db.get_value(
|
||||
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
|
||||
)
|
||||
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
|
||||
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
|
||||
self.assertEqual(jv.total_debit, stored.total_debit)
|
||||
self.assertEqual(jv.total_credit, stored.total_credit)
|
||||
self.assertEqual(jv.difference, stored.difference)
|
||||
|
||||
def test_update_after_submit_with_fractional_totals(self):
|
||||
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
|
||||
jv = self.make_jv_with_fractional_totals()
|
||||
jv.submit()
|
||||
|
||||
jv.pay_to_recd_from = "_Test Supplier"
|
||||
jv.save()
|
||||
|
||||
self.assertEqual(jv.docstatus, 1)
|
||||
self.assertEqual(
|
||||
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
|
||||
)
|
||||
|
||||
def test_jv_account_and_party_balance_with_cost_centre(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
@@ -46,27 +46,23 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
frm.set_query("paid_from", function (doc) {
|
||||
frm.set_query("paid_from", function () {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
|
||||
filters.name = ["!=", doc.paid_to];
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -110,25 +106,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
}
|
||||
});
|
||||
|
||||
frm.set_query("paid_to", function (doc) {
|
||||
frm.set_query("paid_to", function () {
|
||||
frm.events.validate_company(frm);
|
||||
|
||||
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
|
||||
? ["Bank", "Cash"]
|
||||
: [frappe.boot.party_account_types[frm.doc.party_type]];
|
||||
let filters = {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
};
|
||||
if (frm.doc.party_type == "Shareholder") {
|
||||
account_types.push("Equity");
|
||||
}
|
||||
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
|
||||
filters.name = ["!=", doc.paid_from];
|
||||
}
|
||||
return {
|
||||
filters,
|
||||
filters: {
|
||||
account_type: ["in", account_types],
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -176,7 +176,6 @@ class PaymentEntry(AccountsController):
|
||||
self.set_liability_account()
|
||||
self.set_missing_ref_details(force=True)
|
||||
self.validate_payment_type()
|
||||
self.validate_internal_transfer_accounts()
|
||||
self.validate_party_details()
|
||||
self.set_exchange_rate()
|
||||
self.validate_mandatory()
|
||||
@@ -209,15 +208,9 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule()
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_linked_dunnings(self):
|
||||
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
|
||||
|
||||
update_dunnings_linked_to_payment(self)
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
@@ -322,7 +315,6 @@ class PaymentEntry(AccountsController):
|
||||
self.update_payment_schedule(cancel=1)
|
||||
self.make_gl_entries(cancel=1)
|
||||
self.update_outstanding_amounts()
|
||||
self.update_linked_dunnings()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
@@ -635,10 +627,6 @@ class PaymentEntry(AccountsController):
|
||||
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
|
||||
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
|
||||
|
||||
def validate_internal_transfer_accounts(self):
|
||||
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
|
||||
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
|
||||
|
||||
def validate_party_details(self):
|
||||
if self.party and not frappe.db.exists(self.party_type, self.party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
|
||||
@@ -2737,7 +2725,7 @@ def get_payment_entry(
|
||||
pe.append("references", reference)
|
||||
else:
|
||||
if dt == "Dunning":
|
||||
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
|
||||
for overdue_payment in doc.overdue_payments:
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
@@ -2745,23 +2733,21 @@ def get_payment_entry(
|
||||
"reference_name": overdue_payment.sales_invoice,
|
||||
"payment_term": overdue_payment.payment_term,
|
||||
"due_date": overdue_payment.due_date,
|
||||
"total_amount": outstanding,
|
||||
"outstanding_amount": outstanding,
|
||||
"allocated_amount": outstanding,
|
||||
"total_amount": overdue_payment.outstanding,
|
||||
"outstanding_amount": overdue_payment.outstanding,
|
||||
"allocated_amount": overdue_payment.outstanding,
|
||||
},
|
||||
)
|
||||
|
||||
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * unpaid_dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
"dunning": doc.name,
|
||||
},
|
||||
)
|
||||
pe.append(
|
||||
"deductions",
|
||||
{
|
||||
"account": doc.income_account,
|
||||
"cost_center": doc.cost_center,
|
||||
"amount": -1 * doc.dunning_amount,
|
||||
"description": _("Interest and/or dunning fee"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
pe.append(
|
||||
"references",
|
||||
@@ -3054,10 +3040,8 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
|
||||
grand_total = doc.rounded_total or doc.grand_total
|
||||
outstanding_amount = doc.outstanding_amount
|
||||
elif dt == "Dunning":
|
||||
# only what is left to collect, the totals on the dunning are the ones it was raised with
|
||||
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
|
||||
grand_total += doc.get_unpaid_dunning_amount()
|
||||
outstanding_amount = grand_total
|
||||
grand_total = doc.grand_total
|
||||
outstanding_amount = doc.grand_total
|
||||
else:
|
||||
if party_account_currency == doc.company_currency:
|
||||
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))
|
||||
|
||||
@@ -782,23 +782,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
self.validate_gl_entries(pe.name, expected_gle)
|
||||
|
||||
def test_internal_transfer_rejects_same_account(self):
|
||||
pe = frappe.new_doc("Payment Entry")
|
||||
pe.payment_type = "Internal Transfer"
|
||||
pe.company = "_Test Company"
|
||||
pe.paid_from = "_Test Bank - _TC"
|
||||
pe.paid_to = "_Test Bank - _TC"
|
||||
pe.paid_amount = 100
|
||||
pe.received_amount = 100
|
||||
pe.reference_no = "same-account-transfer"
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Paid From and Paid To accounts must be different",
|
||||
pe.insert,
|
||||
)
|
||||
|
||||
def test_bank_charges_deduction(self):
|
||||
bank_charges_account = create_account(
|
||||
parent_account="Indirect Expenses - _TC",
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"amount",
|
||||
"column_break_2",
|
||||
"is_exchange_gain_loss",
|
||||
"description",
|
||||
"dunning"
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -56,21 +55,12 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "System Generated",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "dunning",
|
||||
"fieldtype": "Link",
|
||||
"label": "Dunning",
|
||||
"no_copy": 1,
|
||||
"options": "Dunning",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-17 11:20:35.482913",
|
||||
"modified": "2026-03-11 14:26:11.312950",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Deduction",
|
||||
|
||||
@@ -18,7 +18,6 @@ class PaymentEntryDeduction(Document):
|
||||
amount: DF.Currency
|
||||
cost_center: DF.Link
|
||||
description: DF.SmallText | None
|
||||
dunning: DF.Link | None
|
||||
is_exchange_gain_loss: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
|
||||
@@ -818,7 +818,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
|
||||
|
||||
create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
|
||||
batch_no = create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
|
||||
item = frappe.get_doc("Item", "_BATCH ITEM")
|
||||
|
||||
se = make_stock_entry(
|
||||
@@ -826,12 +826,10 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
item_code="_BATCH ITEM",
|
||||
qty=2,
|
||||
basic_rate=100,
|
||||
batch_no="TestBatch 01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pos_inv1 = create_pos_invoice(
|
||||
item=item.name, rate=300, qty=1, do_not_submit=1, batch_no="TestBatch 01"
|
||||
)
|
||||
pos_inv1 = create_pos_invoice(item=item.name, rate=300, qty=1, do_not_submit=1, batch_no=batch_no)
|
||||
pos_inv1.append(
|
||||
"payments",
|
||||
{"mode_of_payment": "Cash", "amount": 300},
|
||||
@@ -849,7 +847,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
"voucher_no": pos_inv2.name,
|
||||
"qty": 2,
|
||||
"avg_rate": 300,
|
||||
"batches": frappe._dict({"TestBatch 01": 2}),
|
||||
"batches": frappe._dict({batch_no: 2}),
|
||||
"type_of_transaction": "Outward",
|
||||
"company": pos_inv2.company,
|
||||
}
|
||||
@@ -925,6 +923,7 @@ class TestPOSInvoice(POSInvoiceTestMixin):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pos_inv.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"allow_negative_stock": 0})
|
||||
def test_bundle_stock_availability_validation(self):
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
|
||||
@@ -36,6 +36,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.tests.test_utils import StockTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -2643,25 +2644,8 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
batch_no = "BATCH-PI-BNU-TPRBI-0001"
|
||||
serial_nos = ["SNU-PI-TPRSI-0001", "SNU-PI-TPRSI-0002", "SNU-PI-TPRSI-0003"]
|
||||
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": serial_item,
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, [batch_no], create=True)[0]
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(serial_item, serial_nos, create=True)
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item_code=batch_item,
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseInvoiceItem(Document):
|
||||
class PurchaseInvoiceItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.assets.doctype.asset.depreciation import get_disposal_account_and_cost_center
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class SalesInvoiceItem(Document):
|
||||
class SalesInvoiceItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -171,7 +171,6 @@ class ReceivablePayableReport:
|
||||
party_account=ple.account,
|
||||
posting_date=ple.posting_date,
|
||||
account_currency=ple.account_currency,
|
||||
cost_center=ple.cost_center,
|
||||
remarks=ple.remarks,
|
||||
invoiced=0.0,
|
||||
paid=0.0,
|
||||
|
||||
@@ -1337,28 +1337,6 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
row = report[1][0]
|
||||
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
|
||||
|
||||
def test_cost_center_on_payment_before_invoice(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.posting_date = add_days(today(), 1)
|
||||
si.due_date = si.posting_date
|
||||
si.payment_schedule[0].due_date = si.posting_date
|
||||
si.save().submit()
|
||||
|
||||
pe = self.create_payment_entry(si.name, do_not_submit=True)
|
||||
pe.cost_center = self.cost_center
|
||||
pe.save().submit()
|
||||
|
||||
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
|
||||
self.assertEqual(row.cost_center, pe.cost_center)
|
||||
|
||||
def test_payment_terms_template_filters(self):
|
||||
from erpnext.controllers.accounts_controller import get_payment_terms
|
||||
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
|
||||
{% } %}
|
||||
</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
</tr>
|
||||
{% } else { %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{%= data[i]["payment_entry"] %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
|
||||
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
|
||||
</tr>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -114,7 +114,6 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -180,15 +180,13 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
columns[0]["fieldname"] = "sales_invoice"
|
||||
columns[0]["options"] = "Item"
|
||||
columns[0]["width"] = 300
|
||||
# removing the duplicate Item Code column and moving Item Name before Customer
|
||||
# removing Item Code and Item Name columns
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
|
||||
del columns[4]
|
||||
columns.insert(1, columns.pop(4))
|
||||
del columns[4:6]
|
||||
else:
|
||||
del columns[5]
|
||||
columns.insert(1, columns.pop(5))
|
||||
del columns[5:7]
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class AssetCapitalizationStockItem(Document):
|
||||
class AssetCapitalizationStockItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class AssetRepairConsumedItem(Document):
|
||||
class AssetRepairConsumedItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -581,7 +581,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
|
||||
var item_length = me.frm.doc.items.length;
|
||||
while (i < item_length) {
|
||||
var qty = me.frm.doc.items[i].qty;
|
||||
(r.message || []).forEach(function (d) {
|
||||
(r.message[0] || []).forEach(function (d) {
|
||||
if (
|
||||
d.qty > 0 &&
|
||||
qty > 0 &&
|
||||
|
||||
@@ -226,7 +226,6 @@ class PurchaseOrder(BuyingController):
|
||||
self.doctype, self.supplier, self.company, self.inter_company_order_reference
|
||||
)
|
||||
self.reset_default_field_value("set_warehouse", "items", "warehouse")
|
||||
self.set_missing_terms()
|
||||
|
||||
def set_has_unit_price_items(self):
|
||||
"""
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseReceiptItemSupplied(Document):
|
||||
class PurchaseReceiptItemSupplied(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
import frappe.permissions
|
||||
|
||||
from erpnext.buying.utils import get_linked_material_requests
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def create_user_with_roles(email, *roles):
|
||||
if frappe.db.exists("User", email):
|
||||
user = frappe.get_doc("User", email)
|
||||
else:
|
||||
user = frappe.new_doc("User")
|
||||
user.email = email
|
||||
user.first_name = email.split("@", 1)[0]
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
user.set("roles", [])
|
||||
for role in roles:
|
||||
user.append("roles", {"role": role})
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
# a user left without roles is downgraded to a Website User on save
|
||||
frappe.db.set_value("User", email, "user_type", "System User")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.material_request = make_material_request(item_code="_Test Item")
|
||||
|
||||
def test_permitted_role_can_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_populated_result_is_a_flat_list_of_rows(self):
|
||||
"""Both callers iterate the response directly, so it has to stay a flat list of rows
|
||||
rather than a list of lists."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIsInstance(rows, list)
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
self.assertNotIsInstance(row, list | tuple)
|
||||
self.assertIsInstance(row, dict)
|
||||
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
|
||||
self.assertIn(fieldname, row)
|
||||
|
||||
def test_empty_result_is_a_flat_empty_list(self):
|
||||
item_without_request = make_item("_Test Item Without Material Request").name
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests([item_without_request])
|
||||
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_a_single_item_code_is_treated_as_one_code(self):
|
||||
"""A lone code must be read as one item code, not iterated character by character."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
rows = get_linked_material_requests(json.dumps("_Test Item"))
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_items_that_are_not_item_codes_are_rejected(self):
|
||||
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
|
||||
so these are the malformed inputs that reach the method."""
|
||||
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
|
||||
bad_inputs = (
|
||||
"not json at all",
|
||||
[{"item_code": "_Test Item"}],
|
||||
[["_Test Item"]],
|
||||
[None],
|
||||
)
|
||||
|
||||
with self.set_user("test_buying_purchase_user@example.com"):
|
||||
for bad_items in bad_inputs:
|
||||
with self.subTest(items=bad_items):
|
||||
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
|
||||
|
||||
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
|
||||
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
|
||||
both of which call this method, so it must hold Material Request read as well."""
|
||||
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
|
||||
|
||||
with self.set_user("test_buying_mfg_manager@example.com"):
|
||||
rows = get_linked_material_requests(["_Test Item"])
|
||||
|
||||
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
|
||||
|
||||
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
|
||||
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
|
||||
|
||||
with self.set_user("test_buying_sales_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
|
||||
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
|
||||
`select` is enough to list names, so the permitted set must be resolved through a
|
||||
filter on the child table, which requires `read`."""
|
||||
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
|
||||
|
||||
with self.set_user("test_buying_delivery_user@example.com"):
|
||||
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
|
||||
|
||||
def test_results_are_restricted_by_user_permissions(self):
|
||||
other_company_request = make_material_request(
|
||||
item_code="_Test Item",
|
||||
company="_Test Company 1",
|
||||
warehouse="_Test Warehouse 2 - _TC1",
|
||||
cost_center="Main - _TC1",
|
||||
)
|
||||
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
|
||||
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
try:
|
||||
with self.set_user(user.name):
|
||||
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
|
||||
finally:
|
||||
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
|
||||
|
||||
self.assertIn(self.material_request.name, mr_names)
|
||||
self.assertNotIn(other_company_request.name, mr_names)
|
||||
@@ -129,33 +129,7 @@ def get_linked_material_requests(items: str | list):
|
||||
Retrieve Material Requests linked to a list of items.
|
||||
"""
|
||||
|
||||
try:
|
||||
items = frappe.parse_json(items)
|
||||
except (TypeError, ValueError):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
|
||||
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
|
||||
frappe.throw(_("Items must be a list of Item codes"))
|
||||
|
||||
permitted_material_requests = frappe.get_list(
|
||||
"Material Request",
|
||||
filters=[
|
||||
["material_request_type", "=", "Purchase"],
|
||||
["docstatus", "=", 1],
|
||||
["status", "!=", "Stopped"],
|
||||
["per_ordered", "<", 99.99],
|
||||
["Material Request Item", "item_code", "in", items],
|
||||
],
|
||||
pluck="name",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not permitted_material_requests:
|
||||
return []
|
||||
|
||||
items = frappe.parse_json(items)
|
||||
mr_list = []
|
||||
|
||||
mr = frappe.qb.DocType("Material Request")
|
||||
@@ -172,7 +146,6 @@ def get_linked_material_requests(items: str | list):
|
||||
mr_item.item_code,
|
||||
mr_item.name.as_("mr_item"),
|
||||
)
|
||||
.where(mr.name.isin(permitted_material_requests))
|
||||
.where(mr_item.item_code == item)
|
||||
.where(mr.material_request_type == "Purchase")
|
||||
.where(mr.per_ordered < 99.99)
|
||||
|
||||
@@ -258,8 +258,6 @@ class AccountsController(TransactionBase):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
self.set_missing_values(for_validate=True)
|
||||
|
||||
self.validate_price_list()
|
||||
|
||||
if self.get("_action") == "submit":
|
||||
self.remove_bundle_for_non_stock_invoices()
|
||||
|
||||
@@ -348,28 +346,6 @@ class AccountsController(TransactionBase):
|
||||
self.set_default_letter_head()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
|
||||
def validate_price_list(self):
|
||||
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
|
||||
price_list = self.get(price_list_field)
|
||||
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
|
||||
return
|
||||
|
||||
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
|
||||
if (
|
||||
self.get("is_return")
|
||||
and self.get("return_against")
|
||||
and price_list
|
||||
== frappe.db.get_value(
|
||||
self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
|
||||
title=_("Disabled Price List"),
|
||||
)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
if hasattr(self, "letter_head") and not self.letter_head:
|
||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||
|
||||
@@ -596,13 +596,15 @@ def get_batch_no(doctype: str, txt: str, searchfield: str, start: int, page_len:
|
||||
if filters.get("is_inward"):
|
||||
filtered_batches.extend(get_empty_batches(filters, start, page_len, filtered_batches, txt))
|
||||
|
||||
return filtered_batches
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
labels = SerialBatchIdentity("Batch").labels([row[0] for row in filtered_batches])
|
||||
return [(row[0], labels.get(row[0], row[0]), *row[1:]) for row in filtered_batches]
|
||||
|
||||
|
||||
def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None):
|
||||
query_filter = {"item": filters.get("item_code"), "disabled": 0}
|
||||
if txt:
|
||||
query_filter["name"] = ("like", f"%{txt}%")
|
||||
or_filters = {"batch_id": ("like", f"%{txt}%"), "name": txt} if txt else None
|
||||
|
||||
exclude_batches = [batch[0] for batch in filtered_batches] if filtered_batches else []
|
||||
if exclude_batches:
|
||||
@@ -612,6 +614,7 @@ def get_empty_batches(filters, start, page_len, filtered_batches=None, txt=None)
|
||||
"Batch",
|
||||
fields=["name", "batch_qty"],
|
||||
filters=query_filter,
|
||||
or_filters=or_filters,
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=1,
|
||||
@@ -687,7 +690,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
|
||||
query = query.select(batch_table[field])
|
||||
|
||||
if txt:
|
||||
txt_condition = batch_table.name.like(f"%{txt}%")
|
||||
txt_condition = batch_table.batch_id.like(f"%{txt}%")
|
||||
for field in [*searchfields, "name"]:
|
||||
txt_condition |= batch_table[field].like(f"%{txt}%")
|
||||
|
||||
@@ -753,7 +756,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
|
||||
bundle_query = bundle_query.select(batch_table[field])
|
||||
|
||||
if txt:
|
||||
txt_condition = batch_table.name.like(f"%{txt}%")
|
||||
txt_condition = batch_table.batch_id.like(f"%{txt}%")
|
||||
for field in [*searchfields, "name"]:
|
||||
txt_condition |= batch_table[field].like(f"%{txt}%")
|
||||
|
||||
@@ -1018,11 +1021,11 @@ def get_batch_numbers(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
query = (
|
||||
frappe.qb.from_(batch)
|
||||
.select(batch.batch_id)
|
||||
.select(batch.name, batch.batch_id, batch.item)
|
||||
.where(
|
||||
(batch.disabled == 0)
|
||||
& (batch.expiry_date.isnull() | (batch.expiry_date >= today()))
|
||||
& batch.name.like(f"%{txt}%")
|
||||
& batch.batch_id.like(f"%{txt}%")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.utils import get_incoming_rate
|
||||
|
||||
|
||||
@@ -433,9 +434,10 @@ class SubcontractingController(StockController):
|
||||
consumed_bundles = voucher_bundle_data.get(bundle_key, frappe._dict())
|
||||
|
||||
if consumed_bundles.serial_nos:
|
||||
self.available_materials[key]["serial_no"] = list(
|
||||
set(self.available_materials[key]["serial_no"]) - set(consumed_bundles.serial_nos)
|
||||
)
|
||||
consumed_serials = set(consumed_bundles.serial_nos)
|
||||
self.available_materials[key]["serial_no"] = [
|
||||
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
|
||||
]
|
||||
|
||||
if consumed_bundles.batch_nos:
|
||||
for batch_no, qty in consumed_bundles.batch_nos.items():
|
||||
@@ -449,9 +451,10 @@ class SubcontractingController(StockController):
|
||||
from erpnext.deprecation_dumpster import deprecation_warning
|
||||
|
||||
deprecation_warning("unknown", "v16", "No instructions.")
|
||||
self.available_materials[key]["serial_no"] = list(
|
||||
set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no))
|
||||
)
|
||||
consumed_serials = set(get_serial_nos(row.serial_no))
|
||||
self.available_materials[key]["serial_no"] = [
|
||||
sn for sn in self.available_materials[key]["serial_no"] if sn not in consumed_serials
|
||||
]
|
||||
|
||||
# Will be deprecated in v16
|
||||
if row.batch_no and not consumed_bundles.batch_nos:
|
||||
@@ -531,6 +534,12 @@ class SubcontractingController(StockController):
|
||||
|
||||
self.__set_alternative_item_details(row)
|
||||
|
||||
serial_numbers = SerialBatchIdentity("Serial No").labels(
|
||||
[sn for details in self.available_materials.values() for sn in details.serial_no]
|
||||
)
|
||||
for details in self.available_materials.values():
|
||||
details.serial_no.sort(key=lambda sn: serial_numbers.get(sn) or sn)
|
||||
|
||||
self.__transferred_items = copy.deepcopy(self.available_materials)
|
||||
self.__update_consumed_materials("Subcontracting Receipt")
|
||||
|
||||
@@ -682,7 +691,7 @@ class SubcontractingController(StockController):
|
||||
return available_batches
|
||||
|
||||
def __get_serial_nos_for_bundle(self, qty, key):
|
||||
available_sns = sorted(self.available_materials[key]["serial_no"])[0 : cint(qty)]
|
||||
available_sns = self.available_materials[key]["serial_no"][0 : cint(qty)]
|
||||
serial_nos = []
|
||||
|
||||
for serial_no in available_sns:
|
||||
|
||||
@@ -995,9 +995,9 @@ class TestSubcontractingController(ERPNextTestSuite):
|
||||
if value.get(field):
|
||||
data = value.get(field)
|
||||
if field == "serial_no":
|
||||
data = sorted(data)
|
||||
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
self.assertCountEqual(data, transferred_detais.get(field))
|
||||
else:
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
|
||||
scr2 = make_subcontracting_receipt(sco.name)
|
||||
scr2.save()
|
||||
@@ -1010,9 +1010,9 @@ class TestSubcontractingController(ERPNextTestSuite):
|
||||
if value.get(field):
|
||||
data = value.get(field)
|
||||
if field == "serial_no":
|
||||
data = sorted(data)
|
||||
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
self.assertCountEqual(data, transferred_detais.get(field))
|
||||
else:
|
||||
self.assertEqual(data, transferred_detais.get(field))
|
||||
|
||||
def test_subcontracting_with_same_components_different_fg_with_serial_batch_fields(self):
|
||||
"""
|
||||
@@ -1338,7 +1338,7 @@ def make_stock_transfer_entry(**args):
|
||||
batches = defaultdict(float)
|
||||
if item_details and item_details.serial_no:
|
||||
serial_nos = item_details.serial_no[0 : cint(row.qty)]
|
||||
item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos))
|
||||
item_details.serial_no = item_details.serial_no[cint(row.qty) :]
|
||||
|
||||
if item_details and item_details.batch_no:
|
||||
for batch_no, batch_qty in item_details.batch_no.items():
|
||||
|
||||
@@ -72,7 +72,10 @@ doctype_list_js = {
|
||||
|
||||
page_js = {"print": "public/js/print.js"}
|
||||
|
||||
extend_doctype_class = {"Address": "erpnext.accounts.custom.address.ERPNextAddress"}
|
||||
extend_doctype_class = {
|
||||
"Address": "erpnext.accounts.custom.address.ERPNextAddress",
|
||||
"Data Import": "erpnext.stock.serial_batch_import.SerialBatchDataImport",
|
||||
}
|
||||
|
||||
override_whitelisted_methods = {"frappe.www.contact.send_message": "erpnext.templates.utils.send_message"}
|
||||
|
||||
@@ -384,6 +387,7 @@ pre_submit_validation_doctypes = [
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"before_print": "erpnext.stock.serial_batch_display.set_serial_number_labels",
|
||||
"validate": [
|
||||
"erpnext.support.doctype.service_level_agreement.service_level_agreement.apply",
|
||||
"erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class MaintenanceScheduleDetail(Document):
|
||||
class MaintenanceScheduleDetail(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class MaintenanceScheduleItem(Document):
|
||||
class MaintenanceScheduleItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -1860,7 +1860,7 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(len(entries), 5)
|
||||
for entry in entries:
|
||||
self.assertEqual(flt(entry.qty), 10.0)
|
||||
self.assertTrue(entry.batch_no.startswith("BS-ROD-PC-"))
|
||||
self.assertTrue(frappe.db.get_value("Batch", entry.batch_no, "batch_id").startswith("BS-ROD-PC-"))
|
||||
self.assertEqual(frappe.db.get_value("Batch", entry.batch_no, "parent_batch"), parent_batch)
|
||||
|
||||
manufacture_entry.reload()
|
||||
|
||||
@@ -57,25 +57,20 @@ class MaterialRequestService:
|
||||
"""Create Material Requests grouped by Sales Order and Material Request Type"""
|
||||
self.validate_mr_subcontracted()
|
||||
|
||||
material_request_map = {}
|
||||
material_request_list = []
|
||||
for item in self.doc.mr_items:
|
||||
qty_to_request = flt(flt(item.quantity) - flt(item.requested_qty), item.precision("quantity"))
|
||||
if qty_to_request <= 0:
|
||||
continue
|
||||
self._add_item_to_material_request(
|
||||
item, qty_to_request, material_request_map, material_request_list
|
||||
)
|
||||
|
||||
if not material_request_list:
|
||||
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
|
||||
msgprint(_("All items are already requested"))
|
||||
return
|
||||
|
||||
material_request_map = {}
|
||||
material_request_list = []
|
||||
for item in self.doc.mr_items:
|
||||
if item.quantity == item.requested_qty:
|
||||
continue
|
||||
self._add_item_to_material_request(item, material_request_map, material_request_list)
|
||||
|
||||
self._submit_material_requests(material_request_list)
|
||||
|
||||
def _add_item_to_material_request(
|
||||
self, item, qty_to_request, material_request_map, material_request_list
|
||||
):
|
||||
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
|
||||
item_doc = frappe.get_cached_doc("Item", item.item_code)
|
||||
material_request_type = item.material_request_type or item_doc.default_material_request_type
|
||||
|
||||
@@ -86,7 +81,7 @@ class MaterialRequestService:
|
||||
material_request_list.append(material_request_map[key])
|
||||
|
||||
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
|
||||
row = self._material_request_item(item, material_request_type, schedule_date, qty_to_request)
|
||||
row = self._material_request_item(item, material_request_type, schedule_date)
|
||||
material_request_map[key].append("items", row)
|
||||
|
||||
def _new_material_request(self, material_request_type):
|
||||
@@ -101,7 +96,7 @@ class MaterialRequestService:
|
||||
)
|
||||
return mr
|
||||
|
||||
def _material_request_item(self, item, material_request_type, schedule_date, qty_to_request):
|
||||
def _material_request_item(self, item, material_request_type, schedule_date):
|
||||
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
|
||||
# a group warehouse cannot receive stock; it must never reach a Material Request line
|
||||
if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
|
||||
@@ -116,7 +111,7 @@ class MaterialRequestService:
|
||||
return {
|
||||
"item_code": item.item_code,
|
||||
"from_warehouse": from_warehouse,
|
||||
"qty": qty_to_request,
|
||||
"qty": item.quantity - item.requested_qty,
|
||||
"uom": item.uom,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
|
||||
@@ -110,23 +110,6 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
pln = frappe.get_doc("Production Plan", pln.name)
|
||||
pln.cancel()
|
||||
|
||||
def test_production_plan_material_request_skips_zero_qty_items(self):
|
||||
pln = create_production_plan(item_code="Test Production Item 1")
|
||||
zero_qty_item, requested_item = pln.mr_items
|
||||
zero_qty_item.quantity = "0"
|
||||
|
||||
pln.make_material_request()
|
||||
|
||||
material_request_items = frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"production_plan": pln.name},
|
||||
fields=["item_code", "qty"],
|
||||
)
|
||||
self.assertEqual(
|
||||
material_request_items,
|
||||
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
|
||||
)
|
||||
|
||||
def test_production_plan_start_date(self):
|
||||
"Test if Work Order has same Planned Start Date as Prod Plan."
|
||||
planned_date = add_to_date(date=None, days=3)
|
||||
|
||||
@@ -36,6 +36,7 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.utils import get_bin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -1971,6 +1972,7 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
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)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 0})
|
||||
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"
|
||||
@@ -2163,6 +2165,8 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
transferred_ste_doc.items[0].serial_no = "\n".join(serial_nos_list)
|
||||
transferred_ste_doc.items[0].serial_and_batch_bundle = None
|
||||
transferred_ste_doc.items[0].use_serial_batch_fields = 1
|
||||
transferred_ste_doc.submit()
|
||||
|
||||
# First Manufacture stock entry
|
||||
@@ -3770,8 +3774,12 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
# Pre-generate two sets of FG serial numbers
|
||||
series = frappe.db.get_value("Item", fg_item, "serial_no_series")
|
||||
fg_serials_1 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_2 = [make_autoname(series) for _ in range(3)]
|
||||
fg_serials_1 = SerialBatchIdentity("Serial No").resolve(
|
||||
fg_item, [make_autoname(series) for _ in range(3)], create=True
|
||||
)
|
||||
fg_serials_2 = SerialBatchIdentity("Serial No").resolve(
|
||||
fg_item, [make_autoname(series) for _ in range(3)], create=True
|
||||
)
|
||||
|
||||
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
|
||||
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
|
||||
@@ -830,58 +830,30 @@ class WorkOrder(Document):
|
||||
|
||||
serial_nos = []
|
||||
if item_details.serial_no_series:
|
||||
serial_nos = get_available_serial_nos(item_details.serial_no_series, self.qty)
|
||||
serial_nos = get_available_serial_nos(
|
||||
item_details.serial_no_series, self.qty, self.production_item
|
||||
)
|
||||
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"company",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"status",
|
||||
"work_order",
|
||||
"batch_no",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos_details = []
|
||||
index = 0
|
||||
for serial_no in serial_nos:
|
||||
index += 1
|
||||
batch_no = None
|
||||
if batches and self.batch_size:
|
||||
batch_no = batches[0]
|
||||
groups = {}
|
||||
for index, number in enumerate(serial_nos, 1):
|
||||
batch_no = batches[0] if batches and self.batch_size else None
|
||||
groups.setdefault(batch_no, []).append(number)
|
||||
if batch_no and index % self.batch_size == 0:
|
||||
batches.pop(0)
|
||||
|
||||
if index % self.batch_size == 0:
|
||||
batches.remove(batch_no)
|
||||
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
frappe.session.user,
|
||||
frappe.session.user,
|
||||
self.company,
|
||||
self.production_item,
|
||||
item_details.item_name,
|
||||
item_details.description,
|
||||
"Inactive",
|
||||
self.name,
|
||||
batch_no,
|
||||
)
|
||||
for batch_no, numbers in groups.items():
|
||||
SerialBatchIdentity("Serial No").resolve(
|
||||
self.production_item,
|
||||
numbers,
|
||||
create=True,
|
||||
defaults={"company": self.company, "work_order": self.name, "batch_no": batch_no},
|
||||
)
|
||||
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
|
||||
def validate_cancel(self):
|
||||
if self.status == "Stopped":
|
||||
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))
|
||||
|
||||
@@ -262,6 +262,7 @@ erpnext.patches.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.convert_commission_rate_to_percent
|
||||
erpnext.patches.v16_0.convert_hide_currency_symbol_to_check
|
||||
erpnext.patches.v17_0.separate_serial_batch_identity
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount
|
||||
@@ -520,4 +521,3 @@ erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
|
||||
erpnext.patches.v16_0.set_secondary_item_valuation_type
|
||||
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
|
||||
erpnext.patches.v16_0.set_supplier_quotation_order_status
|
||||
erpnext.patches.v16_0.recalculate_holiday_list_totals
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import frappe
|
||||
from frappe.query_builder import Case
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
|
||||
|
||||
def execute():
|
||||
holiday_list = frappe.qb.DocType("Holiday List")
|
||||
holiday = frappe.qb.DocType("Holiday")
|
||||
total_holidays = (
|
||||
frappe.qb.from_(holiday)
|
||||
.select(Sum(Case().when(holiday.is_half_day == 1, 0.5).else_(1)))
|
||||
.where(
|
||||
(holiday.parent == holiday_list.name)
|
||||
& (holiday.parenttype == "Holiday List")
|
||||
& (holiday.parentfield == "holidays")
|
||||
)
|
||||
)
|
||||
frappe.qb.update(holiday_list).set(holiday_list.total_holidays, Coalesce(total_holidays, 0)).run()
|
||||
0
erpnext/patches/v17_0/__init__.py
Normal file
0
erpnext/patches/v17_0/__init__.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal file
20
erpnext/patches/v17_0/separate_serial_batch_identity.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
def execute():
|
||||
checked = []
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
identity = SerialBatchIdentity(doctype)
|
||||
if not identity.has_constraint():
|
||||
identity.validate_existing_numbers()
|
||||
checked.append(doctype)
|
||||
|
||||
previous = frappe.flags.serial_batch_preflight
|
||||
try:
|
||||
frappe.flags.serial_batch_preflight = checked
|
||||
for doctype in ("Serial No", "Batch"):
|
||||
frappe.reload_doc("stock", "doctype", frappe.scrub(doctype), force=True)
|
||||
finally:
|
||||
frappe.flags.serial_batch_preflight = previous
|
||||
@@ -541,7 +541,7 @@ erpnext.buying.link_to_mrs = function (frm) {
|
||||
var item_length = frm.doc.items.length;
|
||||
for (let item of frm.doc.items) {
|
||||
var qty = item.qty;
|
||||
(r.message || []).forEach(function (d) {
|
||||
(r.message[0] || []).forEach(function (d) {
|
||||
if (
|
||||
d.qty > 0 &&
|
||||
qty > 0 &&
|
||||
|
||||
@@ -6,6 +6,8 @@ import "./utils/party";
|
||||
import "./utils/draft_link_guard";
|
||||
import "./controllers/stock_controller";
|
||||
import "./utils/serial_no_batch_selector";
|
||||
import "./utils/serial_batch_input";
|
||||
import "./utils/serial_batch_display";
|
||||
import "./utils/serial_batch_inline_editor";
|
||||
import "./payment/payments";
|
||||
import "./templates/visual_plant_floor_template.html";
|
||||
|
||||
@@ -55,8 +55,16 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scan_api_call(input, (r) => {
|
||||
const data = r && r.message;
|
||||
this.scan_api_call(input, async (r) => {
|
||||
let data = r && r.message;
|
||||
if (data?.candidates) {
|
||||
data = await this.select_scan_match(data.candidates);
|
||||
if (!data) {
|
||||
this.clean_up();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!data ||
|
||||
Object.keys(data).length === 0 ||
|
||||
@@ -95,29 +103,98 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
});
|
||||
}
|
||||
|
||||
scan_api_call(input, callback) {
|
||||
select_scan_match(candidates) {
|
||||
const item_codes = [...new Set(candidates.map((candidate) => candidate.item_code))];
|
||||
if (item_codes.length <= 1) {
|
||||
return Promise.resolve(this.get_scan_match(candidates, item_codes[0]));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let selected = false;
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Select Item"),
|
||||
size: "small",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "item_code",
|
||||
label: __("Item"),
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
only_select: 1,
|
||||
get_query: () => ({ filters: { name: ["in", item_codes] } }),
|
||||
filter_description: __("Items matching the scanned number"),
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Select"),
|
||||
primary_action: ({ item_code }) => {
|
||||
const match = this.get_scan_match(candidates, item_code);
|
||||
if (!match) return;
|
||||
selected = true;
|
||||
dialog.hide();
|
||||
resolve(match);
|
||||
},
|
||||
onhide: () => {
|
||||
if (!selected) resolve(null);
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
});
|
||||
}
|
||||
|
||||
get_scan_match(candidates, item_code) {
|
||||
const matches = candidates.filter((candidate) => candidate.item_code === item_code);
|
||||
// Keep the serial reference when the same item's barcode or batch number also matches.
|
||||
return (
|
||||
matches.find((match) => match.serial_no) || matches.find((match) => match.batch_no) || matches[0]
|
||||
);
|
||||
}
|
||||
|
||||
scan_api_call(input, callback, item_code) {
|
||||
frappe
|
||||
.call({
|
||||
method: this.scan_api,
|
||||
args: {
|
||||
search_value: input,
|
||||
allow_multiple: true,
|
||||
ctx: {
|
||||
item_code,
|
||||
set_warehouse: this.frm.doc.set_warehouse,
|
||||
company: this.frm.doc.company,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((r) => {
|
||||
for (const match of r.message?.candidates || [r.message || {}]) {
|
||||
if (match.serial_no && match.serial_number)
|
||||
frappe.utils.add_link_title("Serial No", match.serial_no, match.serial_number);
|
||||
if (match.batch_no && match.batch_number)
|
||||
frappe.utils.add_link_title("Batch", match.batch_no, match.batch_number);
|
||||
}
|
||||
callback(r);
|
||||
});
|
||||
}
|
||||
|
||||
update_table(data) {
|
||||
if (data.has_serial_no && data.batch_no && !data.serial_no) {
|
||||
frappe.msgprint(__("Please scan a serial number for Item {0}", [data.item_code]));
|
||||
return Promise.reject();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let cur_grid = this.frm.fields_dict[this.items_table_name].grid;
|
||||
frappe.flags.trigger_from_barcode_scanner = true;
|
||||
|
||||
const { item_code, barcode, batch_no, serial_no, uom, default_warehouse } = data;
|
||||
if (
|
||||
serial_no &&
|
||||
(this.frm.doc[this.items_table_name] || []).some(
|
||||
(row) => row.item_code === item_code && this.is_duplicate_serial_no(row, serial_no)
|
||||
)
|
||||
) {
|
||||
this.clean_up();
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
let row = this.get_row_to_modify_on_scan(item_code, batch_no, uom, barcode, default_warehouse);
|
||||
const is_new_row = !row?.item_code;
|
||||
if (!row) {
|
||||
@@ -135,12 +212,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
this.frm.has_items = false;
|
||||
}
|
||||
|
||||
if (this.is_duplicate_serial_no(row, serial_no)) {
|
||||
this.clean_up();
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.run_serially([
|
||||
() => this.set_selector_trigger_flag(data),
|
||||
() => this.set_barcode(row, barcode),
|
||||
@@ -180,9 +251,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
set_item(row, item_code, barcode, batch_no, serial_no) {
|
||||
return new Promise((resolve) => {
|
||||
const increment = async (value = 1) => {
|
||||
const item_data = { item_code: item_code, use_serial_batch_fields: 1.0 };
|
||||
const existing = erpnext.serial_batch_input.is_pending(row, this.serial_no_field)
|
||||
? ""
|
||||
: row[this.serial_no_field];
|
||||
const item_data = this.get_scanned_item_values(
|
||||
row,
|
||||
item_code,
|
||||
batch_no,
|
||||
serial_no ? this.merge_serial_nos(existing, serial_no) : null
|
||||
);
|
||||
frappe.flags.trigger_from_barcode_scanner = true;
|
||||
item_data[this.qty_field] = Number(row[this.qty_field] || 0) + Number(value);
|
||||
item_data[this.qty_field] =
|
||||
Number((row.item_code && row[this.qty_field]) || 0) + Number(value);
|
||||
await frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
return value;
|
||||
};
|
||||
@@ -199,6 +279,28 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
});
|
||||
}
|
||||
|
||||
get_scanned_item_values(row, item_code, batch_no, serial_no) {
|
||||
// Item selection must receive the scanned references before it can auto-pick stock.
|
||||
const values = { item_code, use_serial_batch_fields: 1 };
|
||||
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
|
||||
const numbers = serial_no
|
||||
.split("\n")
|
||||
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
|
||||
.join("\n");
|
||||
values[this.serial_no_field] = this.merge_serial_nos(row[this.serial_no_field], numbers);
|
||||
erpnext.serial_batch_input.mark(row, this.serial_no_field, values[this.serial_no_field]);
|
||||
} else {
|
||||
values[this.serial_no_field] = serial_no;
|
||||
}
|
||||
}
|
||||
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
|
||||
values[this.batch_no_field] = batch_no;
|
||||
erpnext.serial_batch_input.clear(row, this.batch_no_field);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
prepare_item_for_scan(row, item_code, barcode, batch_no, serial_no) {
|
||||
var me = this;
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
@@ -206,19 +308,23 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
fields: me.get_fields_for_dialog(row, item_code, barcode, batch_no, serial_no),
|
||||
});
|
||||
|
||||
this.dialog.set_primary_action(__("Update"), () => {
|
||||
const item_data = { item_code: item_code };
|
||||
this.dialog.set_primary_action(__("Update"), async () => {
|
||||
const item_data = this.get_scanned_item_values(
|
||||
row,
|
||||
item_code,
|
||||
this.dialog.get_value("batch_no"),
|
||||
this.dialog.get_value("serial_no")
|
||||
);
|
||||
item_data[this.qty_field] = this.dialog.get_value("scanned_qty");
|
||||
item_data["has_item_scanned"] = 1;
|
||||
|
||||
this.remaining_qty =
|
||||
flt(this.dialog.get_value("qty")) - flt(this.dialog.get_value("scanned_qty"));
|
||||
frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
await frappe.model.set_value(row.doctype, row.name, item_data);
|
||||
|
||||
frappe.run_serially([
|
||||
await frappe.run_serially([
|
||||
() => this.set_batch_no(row, this.dialog.get_value("batch_no")),
|
||||
() => this.set_barcode(row, this.dialog.get_value("barcode")),
|
||||
() => this.set_serial_no(row, this.dialog.get_value("serial_no")),
|
||||
() => this.add_child_for_remaining_qty(row),
|
||||
() => this.clean_up(),
|
||||
]);
|
||||
@@ -245,11 +351,15 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
if (e.target.value) {
|
||||
this.scan_api_call(e.target.value, (r) => {
|
||||
if (r.message) {
|
||||
this.update_dialog_values(item_code, r);
|
||||
}
|
||||
});
|
||||
this.scan_api_call(
|
||||
e.target.value,
|
||||
async (r) => {
|
||||
if (r.message?.candidates)
|
||||
r.message = await this.select_scan_match(r.message.candidates);
|
||||
if (r.message) this.update_dialog_values(item_code, r);
|
||||
},
|
||||
item_code
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -282,7 +392,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
fields.push({
|
||||
fieldtype: "Link",
|
||||
fieldname: "batch_no",
|
||||
options: "Batch No",
|
||||
options: "Batch",
|
||||
label: __("Batch No"),
|
||||
default: batch_no,
|
||||
read_only: 1,
|
||||
@@ -297,6 +407,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
label: __("Serial Nos"),
|
||||
default: serial_no,
|
||||
read_only: 1,
|
||||
hidden: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (serial_no) {
|
||||
fields.push({
|
||||
fieldtype: "Small Text",
|
||||
fieldname: "serial_numbers",
|
||||
label: __("Serial Nos"),
|
||||
default: frappe.utils.get_link_title("Serial No", serial_no) || serial_no,
|
||||
read_only: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -316,7 +437,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
update_dialog_values(scanned_item, r) {
|
||||
const { item_code, barcode, batch_no, serial_no } = r.message;
|
||||
const { item_code, barcode, batch_no, serial_no, serial_number } = r.message;
|
||||
|
||||
this.dialog.set_value("barcode_scanner", "");
|
||||
if (
|
||||
@@ -331,6 +452,10 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
this.validate_duplicate_serial_no(serial_no);
|
||||
let serial_nos = this.dialog.get_value("serial_no") + "\n" + serial_no;
|
||||
this.dialog.set_value("serial_no", serial_nos);
|
||||
this.dialog.set_value(
|
||||
"serial_numbers",
|
||||
this.dialog.get_value("serial_numbers") + "\n" + (serial_number || serial_no)
|
||||
);
|
||||
}
|
||||
|
||||
let qty = flt(this.dialog.get_value("scanned_qty")) + 1.0;
|
||||
@@ -382,18 +507,24 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
|
||||
async set_serial_no(row, serial_no) {
|
||||
if (serial_no && frappe.meta.has_field(row.doctype, this.serial_no_field)) {
|
||||
const existing_serial_nos = row[this.serial_no_field];
|
||||
let new_serial_nos = "";
|
||||
|
||||
if (!!existing_serial_nos) {
|
||||
new_serial_nos = existing_serial_nos + "\n" + serial_no;
|
||||
} else {
|
||||
new_serial_nos = serial_no;
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.serial_no_field)) {
|
||||
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
const merged = this.merge_serial_nos(row[this.serial_no_field], number);
|
||||
erpnext.serial_batch_input.mark(row, this.serial_no_field, merged);
|
||||
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, merged);
|
||||
return;
|
||||
}
|
||||
const new_serial_nos = this.merge_serial_nos(row[this.serial_no_field], serial_no);
|
||||
await frappe.model.set_value(row.doctype, row.name, this.serial_no_field, new_serial_nos);
|
||||
}
|
||||
}
|
||||
|
||||
merge_serial_nos(existing, added) {
|
||||
return [...new Set(`${existing || ""}\n${added || ""}`.split("\n").map((id) => id.trim()))]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async set_barcode_uom(row, uom) {
|
||||
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
|
||||
// barcode uom must not overwrite the row's own uom.
|
||||
@@ -404,6 +535,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
async set_batch_no(row, batch_no) {
|
||||
erpnext.serial_batch_input.clear(row, this.batch_no_field);
|
||||
if (batch_no && frappe.meta.has_field(row.doctype, this.batch_no_field)) {
|
||||
await frappe.model.set_value(row.doctype, row.name, this.batch_no_field, batch_no);
|
||||
}
|
||||
@@ -437,10 +569,18 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
is_duplicate_serial_no(row, serial_no) {
|
||||
const is_duplicate = row[this.serial_no_field]?.includes(serial_no);
|
||||
const physical_number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
const pending_duplicate =
|
||||
erpnext.serial_batch_input.is_pending(row, this.serial_no_field) &&
|
||||
row[this.serial_no_field]
|
||||
?.split("\n")
|
||||
.some((number) => number.toUpperCase() === physical_number?.toUpperCase());
|
||||
const is_duplicate =
|
||||
serial_no && (pending_duplicate || row[this.serial_no_field]?.split("\n").includes(serial_no));
|
||||
|
||||
if (is_duplicate) {
|
||||
this.show_alert(__("Serial No {0} is already added", [serial_no]), "orange");
|
||||
const number = frappe.utils.get_link_title("Serial No", serial_no) || serial_no;
|
||||
this.show_alert(__("Serial No {0} is already added", [number]), "orange");
|
||||
}
|
||||
return is_duplicate;
|
||||
}
|
||||
@@ -461,7 +601,12 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
|
||||
const matching_row = (row) => {
|
||||
const item_match = row.item_code == item_code;
|
||||
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
|
||||
const batch_match =
|
||||
!row[this.batch_no_field] ||
|
||||
(erpnext.serial_batch_input.is_pending(row, this.batch_no_field)
|
||||
? row[this.batch_no_field].toUpperCase() ===
|
||||
frappe.utils.get_link_title("Batch", batch_no)?.toUpperCase()
|
||||
: row[this.batch_no_field] === batch_no);
|
||||
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
|
||||
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
|
||||
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
|
||||
|
||||
@@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) {
|
||||
args = {
|
||||
party: frm.doc.customer || frm.doc.party_name,
|
||||
party_type: party_type,
|
||||
price_list: frappe.defaults.get_default("selling_price_list"),
|
||||
price_list: frm.doc.selling_price_list,
|
||||
};
|
||||
} else if (frm.doc.supplier) {
|
||||
args = {
|
||||
party: frm.doc.supplier,
|
||||
party_type: "Supplier",
|
||||
bill_date: frm.doc.bill_date,
|
||||
price_list: frappe.defaults.get_default("buying_price_list"),
|
||||
price_list: frm.doc.buying_price_list,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
19
erpnext/public/js/utils/serial_batch_display.js
Normal file
19
erpnext/public/js/utils/serial_batch_display.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Reports export physical numbers and retain a separate ID field for each link.
|
||||
frappe.form.formatters.SerialBatchNumber = (value, df, options, doc) => {
|
||||
if (!value) return "";
|
||||
const labels = String(value).split("\n");
|
||||
const ids = String(doc?.[df.reference_field] || "").split("\n");
|
||||
return labels
|
||||
.map((label, index) => {
|
||||
if (
|
||||
!ids[index] ||
|
||||
options?.for_print ||
|
||||
options?.only_value ||
|
||||
!frappe.model.can_read(df.options)
|
||||
) {
|
||||
return frappe.utils.escape_html(label);
|
||||
}
|
||||
return frappe.form.formatters.Link(ids[index], df, { ...options, label }, doc);
|
||||
})
|
||||
.join("<br>");
|
||||
};
|
||||
@@ -469,19 +469,32 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
$td.data("editing", 1);
|
||||
|
||||
let name = $td.data("name");
|
||||
let current = $td.text().trim();
|
||||
let pending_index = $td.data("pending-index");
|
||||
let entry =
|
||||
pending_index != null
|
||||
? this.pending.new_entries[pending_index]
|
||||
: this.last_entries.find((row) => row.name === name);
|
||||
let number_field = opts.field === "serial_no" ? "serial_number" : "batch_number";
|
||||
let current =
|
||||
(pending_index == null && this.pending.updates[name]?.[opts.field]) || entry?.[opts.field] || "";
|
||||
$td.empty().addClass("sbie-input-cell").css("cursor", "default");
|
||||
this.wrapper.find(".sbie-table").css("overflow", "visible");
|
||||
|
||||
let control = this.make_row_link_control($td, {
|
||||
options: opts.options,
|
||||
fieldname: "sbie_edit_link",
|
||||
placeholder: opts.placeholder,
|
||||
placeholder: (!current && entry?.[number_field]) || opts.placeholder,
|
||||
get_query: opts.get_query,
|
||||
onchange: () => {
|
||||
let value = control.get_value();
|
||||
if (value && value !== current) {
|
||||
this.update_entry(name, { [opts.field]: value });
|
||||
if (pending_index != null) {
|
||||
entry[opts.field] = value;
|
||||
delete entry[number_field];
|
||||
this.frm.dirty();
|
||||
} else {
|
||||
this.update_entry(name, { [opts.field]: value });
|
||||
}
|
||||
this.refresh_view();
|
||||
}
|
||||
},
|
||||
@@ -636,7 +649,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
for (const row of rows) {
|
||||
p.new_entries.push({
|
||||
serial_no: row.serial_no || "",
|
||||
serial_number: row.serial_number,
|
||||
batch_no: row.batch_no || "",
|
||||
batch_number: row.batch_number,
|
||||
qty: Math.abs(flt(row.qty)) || 1,
|
||||
});
|
||||
}
|
||||
@@ -692,22 +707,37 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
get_entry_number(entry, field) {
|
||||
let update = this.pending.updates[entry.name] || {};
|
||||
let name = update[field] || entry[field];
|
||||
let is_serial = field === "serial_no";
|
||||
return (
|
||||
(!update[field] && entry[is_serial ? "serial_number" : "batch_number"]) ||
|
||||
frappe.utils.get_link_title(is_serial ? "Serial No" : "Batch", name) ||
|
||||
name ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
get_active_server_row(field, value) {
|
||||
let p = this.pending;
|
||||
if (p.delete_all) return null;
|
||||
|
||||
return this.last_entries.find((d) => d[field] === value && !p.deleted.some((x) => x.name === d.name));
|
||||
return this.last_entries.find(
|
||||
(d) => this.get_entry_number(d, field) === value && !p.deleted.some((x) => x.name === d.name)
|
||||
);
|
||||
}
|
||||
|
||||
get_known_identifiers() {
|
||||
let p = this.pending;
|
||||
let known = new Set(p.new_entries.map((d) => d.serial_no || d.batch_no));
|
||||
let field = cint(this.item.has_serial_no) ? "serial_no" : "batch_no";
|
||||
let known = new Set(p.new_entries.map((d) => this.get_entry_number(d, field)));
|
||||
|
||||
if (!p.delete_all) {
|
||||
let deleted = new Set(p.deleted.map((d) => d.name));
|
||||
for (const d of this.last_entries) {
|
||||
if (!deleted.has(d.name)) {
|
||||
known.add(d.serial_no || d.batch_no);
|
||||
known.add(this.get_entry_number(d, field));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -727,9 +757,9 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
return false;
|
||||
}
|
||||
|
||||
p.new_entries.push({ serial_no: value, batch_no: "", qty: 1 });
|
||||
p.new_entries.push({ serial_number: value, qty: 1 });
|
||||
} else {
|
||||
let existing = p.new_entries.find((d) => d.batch_no === value);
|
||||
let existing = p.new_entries.find((d) => this.get_entry_number(d, "batch_no") === value);
|
||||
let server_row = this.get_active_server_row("batch_no", value);
|
||||
if (existing) {
|
||||
existing.qty = flt(existing.qty) + 1;
|
||||
@@ -738,7 +768,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
let current = update && update.qty != null ? flt(update.qty) : Math.abs(flt(server_row.qty));
|
||||
this.update_entry(server_row.name, { qty: current + 1 });
|
||||
} else {
|
||||
p.new_entries.push({ serial_no: "", batch_no: value, qty: 1 });
|
||||
p.new_entries.push({ batch_number: value, qty: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +818,7 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
let added = 0;
|
||||
for (const serial_no of serial_nos) {
|
||||
if (known.has(serial_no)) continue;
|
||||
p.new_entries.push({ serial_no: serial_no, batch_no: "", qty: 1 });
|
||||
p.new_entries.push({ serial_number: serial_no, qty: 1 });
|
||||
added++;
|
||||
}
|
||||
|
||||
@@ -935,8 +965,8 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
.map((d, i) => {
|
||||
let update = p.updates[d.name] || {};
|
||||
let qty = update.qty != null ? flt(update.qty) : Math.abs(flt(d.qty));
|
||||
let batch_no = this.esc(update.batch_no || d.batch_no || "");
|
||||
let serial_no = this.esc(update.serial_no || d.serial_no || "");
|
||||
let batch_no = this.esc(this.get_entry_number(d, "batch_no"));
|
||||
let serial_no = this.esc(this.get_entry_number(d, "serial_no"));
|
||||
let name = this.esc(d.name);
|
||||
|
||||
return `<tr data-name="${name}">
|
||||
@@ -957,8 +987,12 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
)}" style="cursor: pointer;">${batch_no}</td>`
|
||||
: ""
|
||||
}
|
||||
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
|
||||
!d.serial_no && show_batch ? this.get_qty_input(d, qty) : this.format_float(qty)
|
||||
<td class="${
|
||||
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
|
||||
}" style="text-align: right;">${
|
||||
!(d.serial_no || d.serial_number) && show_batch
|
||||
? this.get_qty_input(d, qty)
|
||||
: this.format_float(qty)
|
||||
}</td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -975,10 +1009,26 @@ erpnext.stock.SerialBatchInlineEditor = class SerialBatchInlineEditor {
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" class="sbie-check" data-pending-index="${index}"></td>
|
||||
<td style="text-align: center;">${base_count + index + 1}</td>
|
||||
${show_serial ? `<td>${this.esc(d.serial_no || "")}</td>` : ""}
|
||||
${show_batch ? `<td>${this.esc(d.batch_no || "")}</td>` : ""}
|
||||
<td class="${!d.serial_no && show_batch ? "sbie-input-cell" : ""}" style="text-align: right;">${
|
||||
!d.serial_no && show_batch
|
||||
${
|
||||
show_serial
|
||||
? `<td class="sbie-serial-cell" data-pending-index="${index}" title="${__(
|
||||
"Click to change Serial No"
|
||||
)}" style="cursor: pointer;">${this.esc(
|
||||
this.get_entry_number(d, "serial_no")
|
||||
)}</td>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
show_batch
|
||||
? `<td class="sbie-batch-cell" data-pending-index="${index}" title="${__(
|
||||
"Click to change Batch No"
|
||||
)}" style="cursor: pointer;">${this.esc(this.get_entry_number(d, "batch_no"))}</td>`
|
||||
: ""
|
||||
}
|
||||
<td class="${
|
||||
!(d.serial_no || d.serial_number) && show_batch ? "sbie-input-cell" : ""
|
||||
}" style="text-align: right;">${
|
||||
!(d.serial_no || d.serial_number) && show_batch
|
||||
? this.get_pending_qty_input(d, index)
|
||||
: this.format_float(d.qty)
|
||||
}</td>
|
||||
|
||||
282
erpnext/public/js/utils/serial_batch_input.js
Normal file
282
erpnext/public/js/utils/serial_batch_input.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// Physical input remains pending until the transaction is saved.
|
||||
const registered_forms = new Set();
|
||||
const pending_values = new WeakMap();
|
||||
const serial_list_fields = new Set(["serial_no", "rejected_serial_no", "current_serial_no"]);
|
||||
|
||||
const with_serial_numbers = (BaseControl) =>
|
||||
class extends BaseControl {
|
||||
number_context() {
|
||||
return this.serial_batch_context || { frm: this.frm, row: this.doc };
|
||||
}
|
||||
|
||||
is_serial_list() {
|
||||
const { frm, row } = this.number_context();
|
||||
return (
|
||||
frm &&
|
||||
this.df.parent !== "Serial No" &&
|
||||
serial_list_fields.has(this.df.fieldname) &&
|
||||
(row?.item_code || row?.rm_item_code)
|
||||
);
|
||||
}
|
||||
|
||||
bind_change_event() {
|
||||
if (!this.frm || !serial_list_fields.has(this.df.fieldname) || this.df.parent === "Serial No")
|
||||
return super.bind_change_event();
|
||||
this.$input.on("change", (event) =>
|
||||
this.parse_validate_and_set_in_model(this.get_input_value(), event)
|
||||
);
|
||||
this.$input.on("input", () => this.number_context().frm.dirty());
|
||||
}
|
||||
|
||||
async parse_validate_and_set_in_model(value, event) {
|
||||
const revision = (this.number_revision = (this.number_revision || 0) + 1);
|
||||
if (!this.is_serial_list() || !event) {
|
||||
return super.parse_validate_and_set_in_model(value, event);
|
||||
}
|
||||
const context = this.number_context();
|
||||
if (
|
||||
context.row.parenttype &&
|
||||
frappe.meta.has_field(context.row.doctype, "serial_and_batch_bundle")
|
||||
) {
|
||||
const numbers = split_physical_numbers(value);
|
||||
await set_pending_number(context, this.df.fieldname, numbers.join("\n"));
|
||||
return;
|
||||
}
|
||||
const { frm, row } = this.number_context();
|
||||
const item_code = row.item_code || row.rm_item_code;
|
||||
const pending = (async () => {
|
||||
const numbers = (value || "")
|
||||
.split(/[,\n]/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
const result = numbers.length
|
||||
? await frappe.xcall("erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers", {
|
||||
item_code,
|
||||
serial_numbers: numbers,
|
||||
})
|
||||
: { serial_nos: [] };
|
||||
const ids = result.serial_nos;
|
||||
if (revision !== this.number_revision || item_code !== (row.item_code || row.rm_item_code))
|
||||
return;
|
||||
ids.forEach((id, index) => frappe.utils.add_link_title("Serial No", id, numbers[index]));
|
||||
return super.parse_validate_and_set_in_model(ids.join("\n"), event);
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
|
||||
throw error;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
|
||||
serial_number_text(value) {
|
||||
const { row } = this.number_context();
|
||||
if (erpnext.serial_batch_input.is_pending(row, this.df.fieldname)) return value || "";
|
||||
return (value || "")
|
||||
.split("\n")
|
||||
.map((id) => frappe.utils.get_link_title("Serial No", id) || id)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async load_serial_titles(value) {
|
||||
if (erpnext.serial_batch_input.is_pending(this.number_context().row, this.df.fieldname)) return;
|
||||
const missing = (value || "")
|
||||
.split("\n")
|
||||
.filter((id) => id && !frappe.utils.get_link_title("Serial No", id));
|
||||
if (!missing.length) return;
|
||||
if (this.title_request_value !== value) {
|
||||
this.title_request_value = value;
|
||||
this.title_request = frappe.xcall(
|
||||
"erpnext.stock.serial_batch_identity.get_serial_batch_labels",
|
||||
{
|
||||
doctype: "Serial No",
|
||||
names: missing,
|
||||
}
|
||||
);
|
||||
}
|
||||
const labels = await this.title_request;
|
||||
Object.entries(labels).forEach(([id, label]) =>
|
||||
frappe.utils.add_link_title("Serial No", id, label)
|
||||
);
|
||||
}
|
||||
|
||||
set_formatted_input(value) {
|
||||
if (!this.is_serial_list()) return super.set_formatted_input(value);
|
||||
super.set_formatted_input(this.serial_number_text(value));
|
||||
this.load_serial_titles(value).then(() => {
|
||||
if (this.get_model_value() === value && !this.$input?.is(":focus")) {
|
||||
super.set_formatted_input(this.serial_number_text(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set_disp_area(value) {
|
||||
if (!this.is_serial_list()) return super.set_disp_area(value);
|
||||
if (this.disp_area) $(this.disp_area).text(this.serial_number_text(value));
|
||||
this.load_serial_titles(value).then(() => {
|
||||
if (this.disp_area && this.get_model_value() === value) {
|
||||
$(this.disp_area).text(this.serial_number_text(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
frappe.ui.form.ControlSmallText = with_serial_numbers(frappe.ui.form.ControlSmallText);
|
||||
frappe.ui.form.ControlText = with_serial_numbers(frappe.ui.form.ControlText);
|
||||
frappe.ui.form.ControlLongText = with_serial_numbers(frappe.ui.form.ControlLongText);
|
||||
|
||||
frappe.ui.form.ControlLink = class extends frappe.ui.form.ControlLink {
|
||||
async parse_validate_and_set_in_model(value, event, label) {
|
||||
const revision = (this.number_revision = (this.number_revision || 0) + 1);
|
||||
const doctype = this.get_options();
|
||||
const { frm, row } = this.serial_batch_context || { frm: this.frm, row: this.doc };
|
||||
const item_code = row?.item_code || row?.rm_item_code || row?.item;
|
||||
if (
|
||||
!frm ||
|
||||
!item_code ||
|
||||
!["Serial No", "Batch"].includes(doctype) ||
|
||||
(!event && label === undefined)
|
||||
) {
|
||||
return super.parse_validate_and_set_in_model(value, event, label);
|
||||
}
|
||||
|
||||
if (
|
||||
doctype === "Batch" &&
|
||||
this.df.fieldname === "batch_no" &&
|
||||
row.parenttype &&
|
||||
frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")
|
||||
) {
|
||||
await set_pending_number({ frm, row }, "batch_no", (label ?? this.get_label_value()).trim());
|
||||
return;
|
||||
}
|
||||
if (label !== undefined) erpnext.serial_batch_input.clear(row, this.df.fieldname);
|
||||
|
||||
// Autocomplete supplies the selected physical label; change/blur supplies typed text.
|
||||
const number = (label ?? this.get_label_value()).trim();
|
||||
const pending = (async () => {
|
||||
let name = "";
|
||||
if (number) {
|
||||
const serial = doctype === "Serial No";
|
||||
const result = await frappe.xcall(
|
||||
"erpnext.stock.serial_batch_identity.resolve_serial_batch_numbers",
|
||||
{
|
||||
item_code,
|
||||
[serial ? "serial_numbers" : "batch_numbers"]: [number],
|
||||
}
|
||||
);
|
||||
name = result[serial ? "serial_nos" : "batch_nos"][0];
|
||||
}
|
||||
if (
|
||||
revision !== this.number_revision ||
|
||||
item_code !== (row?.item_code || row?.rm_item_code || row?.item)
|
||||
)
|
||||
return;
|
||||
return super.parse_validate_and_set_in_model(name, event, number);
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (revision === this.number_revision) this.set_formatted_input(this.get_model_value());
|
||||
throw error;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
set_formatted_input(value) {
|
||||
super.set_formatted_input(value);
|
||||
const { row } = this.serial_batch_context || { row: this.doc };
|
||||
if (this.df.fieldname === "batch_no" && erpnext.serial_batch_input.is_pending(row, "batch_no")) {
|
||||
this.$input?.val(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function split_physical_numbers(value) {
|
||||
return (value || "")
|
||||
.split(/[,\n]/)
|
||||
.map((number) => number.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function set_pending_number({ frm, row }, field, value) {
|
||||
erpnext.serial_batch_input.mark(row, field, value);
|
||||
row[field] = value;
|
||||
frm.dirty();
|
||||
frm.refresh_field(row.parentfield || field);
|
||||
const values = {};
|
||||
if (frappe.meta.has_field(row.doctype, "use_serial_batch_fields")) values.use_serial_batch_fields = 1;
|
||||
if (frappe.meta.has_field(row.doctype, "serial_and_batch_bundle")) values.serial_and_batch_bundle = "";
|
||||
const pending = (async () => {
|
||||
await frappe.model.set_value(row.doctype, row.name, values);
|
||||
const numbers = split_physical_numbers(value);
|
||||
if (field === "serial_no" && numbers.length && !frm.doc.is_return && row.serial_no === value) {
|
||||
await frappe.model.set_value(
|
||||
row.doctype,
|
||||
row.name,
|
||||
"qty",
|
||||
numbers.length / (row.conversion_factor || 1)
|
||||
);
|
||||
}
|
||||
})();
|
||||
track_number_request(frm, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
frm.serial_number_requests.delete(pending);
|
||||
}
|
||||
}
|
||||
|
||||
function track_number_request(frm, pending) {
|
||||
if (!registered_forms.has(frm.doctype)) {
|
||||
registered_forms.add(frm.doctype);
|
||||
const wait = async (form) => {
|
||||
await Promise.all([...(form.serial_number_requests || [])]);
|
||||
for (const row of frappe.model.get_all_docs(form.doc)) {
|
||||
for (const field of [...(row.__serial_batch_input || [])]) {
|
||||
erpnext.serial_batch_input.is_pending(row, field);
|
||||
}
|
||||
}
|
||||
};
|
||||
frappe.ui.form.on(frm.doctype, {
|
||||
validate: wait,
|
||||
before_save: wait,
|
||||
after_save(form) {
|
||||
for (const row of frappe.model.get_all_docs(form.doc)) {
|
||||
delete row.__serial_batch_input;
|
||||
pending_values.delete(row);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
frm.serial_number_requests ||= new Set();
|
||||
frm.serial_number_requests.add(pending);
|
||||
}
|
||||
|
||||
erpnext.serial_batch_input = {
|
||||
mark(row, field, value) {
|
||||
row.__serial_batch_input = [...new Set([...(row.__serial_batch_input || []), field])];
|
||||
const inputs = pending_values.get(row) || {};
|
||||
inputs[field] = value;
|
||||
pending_values.set(row, inputs);
|
||||
},
|
||||
is_pending(row, field) {
|
||||
if (!row?.__serial_batch_input?.includes(field)) return false;
|
||||
const inputs = pending_values.get(row);
|
||||
if (inputs && field in inputs && inputs[field] !== row[field]) {
|
||||
this.clear(row, field);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
clear(row, field) {
|
||||
if (!row?.__serial_batch_input) return;
|
||||
row.__serial_batch_input = row.__serial_batch_input.filter((name) => name !== field);
|
||||
if (!row.__serial_batch_input.length) delete row.__serial_batch_input;
|
||||
const inputs = pending_values.get(row);
|
||||
if (inputs) delete inputs[field];
|
||||
},
|
||||
};
|
||||
@@ -54,26 +54,19 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
|
||||
qty = Math.abs(qty);
|
||||
if (qty > 0) {
|
||||
this.dialog.set_value("qty", qty).then(() => {
|
||||
this.dialog.set_value("qty", qty).then(async () => {
|
||||
if (this.item.serial_no && !this.item.serial_and_batch_bundle) {
|
||||
let serial_nos = this.item.serial_no.split("\n");
|
||||
if (serial_nos.length > 1) {
|
||||
serial_nos.forEach((serial_no) => {
|
||||
this.dialog.fields_dict.entries.df.data.push({
|
||||
serial_no: serial_no,
|
||||
batch_no: this.item.batch_no,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.dialog.set_value("scan_serial_no", this.item.serial_no);
|
||||
}
|
||||
await this.set_data(
|
||||
this.item.serial_no
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((serial_no) => ({ serial_no, batch_no: this.item.batch_no, qty: 1 }))
|
||||
);
|
||||
frappe.model.set_value(this.item.doctype, this.item.name, "serial_no", "");
|
||||
} else if (this.item.batch_no && !this.item.serial_and_batch_bundle) {
|
||||
this.dialog.set_value("scan_batch_no", this.item.batch_no);
|
||||
await this.set_data([{ batch_no: this.item.batch_no, qty }]);
|
||||
frappe.model.set_value(this.item.doctype, this.item.name, "batch_no", "");
|
||||
}
|
||||
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -336,10 +329,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
item_code: this.item.item_code,
|
||||
serial_nos: upload_serial_nos,
|
||||
},
|
||||
callback: (r) => {
|
||||
callback: async (r) => {
|
||||
if (r.message) {
|
||||
this.dialog.fields_dict.entries.df.data = [];
|
||||
this.set_data(r.message);
|
||||
await this.set_data(r.message);
|
||||
this.update_bundle_entries();
|
||||
}
|
||||
},
|
||||
@@ -522,6 +515,18 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
hidden: 1,
|
||||
});
|
||||
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
for (const field of fields) {
|
||||
if (!["serial_no", "batch_no"].includes(field.fieldname)) continue;
|
||||
const reference = field.fieldname;
|
||||
field.fieldtype = "Data";
|
||||
field.fieldname = reference.replace("_no", "_number");
|
||||
field.change = function () {
|
||||
this.doc[reference] = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -571,8 +576,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
},
|
||||
callback: (r) => {
|
||||
if (r.message) {
|
||||
this.dialog.fields_dict.entries.df.data = r.message;
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
this.dialog.fields_dict.entries.df.data = [];
|
||||
this.set_data(r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -584,24 +589,45 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
|
||||
this.dialog.set_value("enter_manually", 0);
|
||||
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
const entries = this.dialog.fields_dict.entries.df.data;
|
||||
if (
|
||||
scan_serial_no &&
|
||||
entries.some((row) => row.serial_number?.toUpperCase() === scan_serial_no.toUpperCase())
|
||||
) {
|
||||
frappe.throw(__("Serial No {0} already exists", [scan_serial_no]));
|
||||
}
|
||||
if (scan_serial_no || scan_batch_no) {
|
||||
const batch =
|
||||
!scan_serial_no &&
|
||||
entries.find((row) => row.batch_number?.toUpperCase() === scan_batch_no.toUpperCase());
|
||||
if (batch) batch.qty = flt(batch.qty) + 1;
|
||||
else entries.push({ serial_number: scan_serial_no, batch_number: scan_batch_no, qty: 1 });
|
||||
this.dialog.set_value("scan_serial_no", "");
|
||||
this.dialog.set_value("scan_batch_no", "");
|
||||
this.dialog.fields_dict.entries.grid.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scan_serial_no || scan_batch_no) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_serial_batch_no_exists",
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.resolve_scanned_serial_batch_numbers",
|
||||
args: {
|
||||
item_code: this.item.item_code,
|
||||
type_of_transaction: this.item.type_of_transaction,
|
||||
serial_no: scan_serial_no,
|
||||
batch_no: scan_batch_no,
|
||||
},
|
||||
callback: (r) => {
|
||||
this.update_serial_batch_no();
|
||||
this.update_serial_batch_no(r.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update_serial_batch_no() {
|
||||
const { scan_serial_no, scan_batch_no } = this.dialog.get_values();
|
||||
update_serial_batch_no(result) {
|
||||
const scan_serial_no = result.serial_nos?.[0];
|
||||
const scan_batch_no = result.batch_nos?.[0];
|
||||
|
||||
if (scan_serial_no) {
|
||||
let existing_row = this.dialog.fields_dict.entries.df.data.filter((d) => {
|
||||
@@ -772,7 +798,22 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
set_data(data) {
|
||||
async set_data(data) {
|
||||
if (this.item.type_of_transaction === "Inward") {
|
||||
for (const [field, doctype] of [
|
||||
["serial_no", "Serial No"],
|
||||
["batch_no", "Batch"],
|
||||
]) {
|
||||
const names = data.map((row) => row[field]).filter(Boolean);
|
||||
const labels = names.length
|
||||
? await frappe.xcall("erpnext.stock.serial_batch_identity.get_serial_batch_labels", {
|
||||
doctype,
|
||||
names,
|
||||
})
|
||||
: {};
|
||||
for (const row of data) row[field.replace("_no", "_number")] ||= labels[row[field]];
|
||||
}
|
||||
}
|
||||
data.forEach((d) => {
|
||||
d.qty = Math.abs(d.qty);
|
||||
d.name = d.child_row || d.name;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class InstallationNoteItem(Document):
|
||||
class InstallationNoteItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -3453,8 +3453,8 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle)
|
||||
batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys())
|
||||
|
||||
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertEqual(sre_batch_nos, batches_in_bundle)
|
||||
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
|
||||
|
||||
dn.items[0].qty = 5
|
||||
dn.save()
|
||||
@@ -3495,8 +3495,8 @@ class TestSalesOrder(ERPNextTestSuite):
|
||||
serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle)
|
||||
batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys())
|
||||
|
||||
self.assertEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertEqual(sre_batch_nos, batches_in_bundle)
|
||||
self.assertCountEqual(sre_serial_nos, serial_nos_in_bundle)
|
||||
self.assertCountEqual(sre_batch_nos, batches_in_bundle)
|
||||
|
||||
si.items[0].qty = 5
|
||||
si.save()
|
||||
|
||||
@@ -16,16 +16,23 @@ from erpnext.stock.utils import scan_barcode
|
||||
|
||||
|
||||
def search_by_term(search_term, warehouse, price_list):
|
||||
result = search_for_serial_or_batch_or_barcode_number(search_term) or {}
|
||||
result = scan_barcode(search_term, allow_multiple=True)
|
||||
if not result or result.get("warehouse"):
|
||||
return
|
||||
matches = result.get("candidates", [result])
|
||||
return {
|
||||
"items": [get_scanned_item(match, warehouse, price_list) for match in matches],
|
||||
"requires_selection": len(matches) > 1,
|
||||
"is_scan": True,
|
||||
}
|
||||
|
||||
item_code = result.get("item_code", search_term)
|
||||
|
||||
def get_scanned_item(result, warehouse, price_list):
|
||||
item_code = result["item_code"]
|
||||
serial_no = result.get("serial_no", "")
|
||||
batch_no = result.get("batch_no", "")
|
||||
barcode = result.get("barcode", "")
|
||||
|
||||
if not result:
|
||||
return
|
||||
|
||||
item_doc = frappe.get_doc("Item", item_code)
|
||||
|
||||
if not item_doc:
|
||||
@@ -109,7 +116,7 @@ def search_by_term(search_term, warehouse, price_list):
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": [item]}
|
||||
return item
|
||||
|
||||
|
||||
def filter_result_items(result, pos_profile):
|
||||
@@ -271,8 +278,10 @@ def get_items(
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str, str | None]:
|
||||
return scan_barcode(search_value)
|
||||
def search_for_serial_or_batch_or_barcode_number(
|
||||
search_value: str, item_code: str | None = None, allow_multiple: bool = False
|
||||
) -> dict:
|
||||
return scan_barcode(search_value, {"item_code": item_code}, allow_multiple=allow_multiple)
|
||||
|
||||
|
||||
def get_conditions(search_term, item=None):
|
||||
|
||||
@@ -199,6 +199,7 @@ erpnext.PointOfSale.ItemDetails = class {
|
||||
parent: this.$form_container.find(`.${fieldname}-control`),
|
||||
render_input: true,
|
||||
});
|
||||
this[`${fieldname}_control`].serial_batch_context = { frm: this.events.get_frm(), row: item };
|
||||
this[`${fieldname}_control`].set_value(item[fieldname]);
|
||||
});
|
||||
|
||||
|
||||
@@ -74,11 +74,34 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
const price_list = (doc && doc.selling_price_list) || this.price_list;
|
||||
let { item_group, pos_profile } = this;
|
||||
|
||||
return frappe.call({
|
||||
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
|
||||
freeze: true,
|
||||
args: { start, page_length, price_list, item_group, search_term, pos_profile },
|
||||
});
|
||||
const cache_key = JSON.stringify([
|
||||
pos_profile,
|
||||
price_list,
|
||||
item_group,
|
||||
start,
|
||||
page_length,
|
||||
search_term,
|
||||
]);
|
||||
this.items_cache ||= new Map();
|
||||
const scanned = this.barcode_search_pending;
|
||||
this.barcode_search_pending = false;
|
||||
if (!scanned && this.items_cache.has(cache_key)) {
|
||||
return $.Deferred()
|
||||
.resolve({ message: this.items_cache.get(cache_key) })
|
||||
.promise();
|
||||
}
|
||||
return frappe
|
||||
.call({
|
||||
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_items",
|
||||
freeze: true,
|
||||
args: { start, page_length, price_list, item_group, search_term, pos_profile },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!scanned && !response.message?.is_scan && response.message?.items?.length) {
|
||||
this.items_cache.set(cache_key, response.message);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
render_item_list(items) {
|
||||
@@ -347,6 +370,7 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
this.search_field.set_focus();
|
||||
this.set_search_value(sScancode);
|
||||
this.barcode_scanned = true;
|
||||
this.barcode_search_pending = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -435,32 +459,14 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
filter_items({ search_term = "" } = {}) {
|
||||
this.start_item_loading_animation();
|
||||
|
||||
const selling_price_list = this.events.get_frm().doc.selling_price_list;
|
||||
|
||||
if (search_term) {
|
||||
search_term = search_term.toLowerCase();
|
||||
|
||||
// memoize
|
||||
this.search_index = this.search_index || {};
|
||||
this.search_index[selling_price_list] = this.search_index[selling_price_list] || {};
|
||||
if (this.search_index[selling_price_list][search_term]) {
|
||||
const items = this.search_index[selling_price_list][search_term];
|
||||
this.items = items;
|
||||
this.render_item_list(items);
|
||||
this.auto_add_item &&
|
||||
this.search_field.$input[0].value &&
|
||||
this.items.length == 1 &&
|
||||
this.add_filtered_item_to_cart();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.get_items({ search_term })
|
||||
.then(({ message }) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { items, serial_no, batch_no, barcode } = message;
|
||||
if (search_term && !barcode) {
|
||||
this.search_index[selling_price_list][search_term] = items;
|
||||
const { items, requires_selection } = message;
|
||||
if (requires_selection) {
|
||||
frappe.show_alert({
|
||||
message: __("Select the item that matches the scanned number."),
|
||||
indicator: "blue",
|
||||
});
|
||||
}
|
||||
this.items = items;
|
||||
this.render_item_list(items);
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
function update_total_holidays(frm) {
|
||||
let total_holidays = 0;
|
||||
for (const holiday of frm.doc.holidays || []) {
|
||||
total_holidays += holiday.is_half_day ? 0.5 : 1;
|
||||
}
|
||||
frm.doc.total_holidays = total_holidays;
|
||||
frm.refresh_field("total_holidays");
|
||||
}
|
||||
|
||||
frappe.ui.form.on("Holiday List", {
|
||||
refresh: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
if (frm.doc.holidays) {
|
||||
frm.set_value("total_holidays", frm.doc.holidays.length);
|
||||
}
|
||||
|
||||
frm.call("get_supported_countries").then((r) => {
|
||||
frm.subdivisions_by_country = r.message.subdivisions_by_country;
|
||||
@@ -50,18 +43,6 @@ frappe.ui.form.on("Holiday List", {
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Holiday", {
|
||||
holidays_add: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
holidays_remove: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
is_half_day: function (frm) {
|
||||
update_total_holidays(frm);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.tour["Holiday List"] = [
|
||||
{
|
||||
fieldname: "holiday_list_name",
|
||||
|
||||
@@ -58,10 +58,9 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Total Holidays",
|
||||
"precision": "1",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import DateTimeLikeObject, cint, formatdate, getdate, today
|
||||
from frappe.utils import DateTimeLikeObject, formatdate, getdate, today
|
||||
|
||||
|
||||
class OverlapError(frappe.ValidationError):
|
||||
@@ -34,7 +34,7 @@ class HolidayList(Document):
|
||||
is_half_day: DF.Check
|
||||
subdivision: DF.Autocomplete | None
|
||||
to_date: DF.Date
|
||||
total_holidays: DF.Float
|
||||
total_holidays: DF.Int
|
||||
weekly_off: DF.Literal[
|
||||
"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
|
||||
]
|
||||
@@ -42,13 +42,10 @@ class HolidayList(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_days()
|
||||
self.update_total_holidays()
|
||||
self.total_holidays = len(self.holidays)
|
||||
self.validate_duplicate_date()
|
||||
self.sort_holidays()
|
||||
|
||||
def update_total_holidays(self):
|
||||
self.total_holidays = sum(0.5 if cint(holiday.is_half_day) else 1 for holiday in self.holidays)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_weekly_off_dates(self):
|
||||
if not self.weekly_off:
|
||||
@@ -70,8 +67,6 @@ class HolidayList(Document):
|
||||
},
|
||||
)
|
||||
|
||||
self.update_total_holidays()
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_supported_countries(self):
|
||||
from holidays.utils import list_supported_countries
|
||||
@@ -113,8 +108,6 @@ class HolidayList(Document):
|
||||
"holidays", {"description": holiday_name, "holiday_date": holiday_date, "weekly_off": 0}
|
||||
)
|
||||
|
||||
self.update_total_holidays()
|
||||
|
||||
def sort_holidays(self):
|
||||
self.holidays.sort(key=lambda x: (x.weekly_off, getdate(x.holiday_date)))
|
||||
for i in range(len(self.holidays)):
|
||||
@@ -160,7 +153,6 @@ class HolidayList(Document):
|
||||
@frappe.whitelist()
|
||||
def clear_table(self):
|
||||
self.set("holidays", [])
|
||||
self.update_total_holidays()
|
||||
|
||||
def validate_duplicate_date(self):
|
||||
unique_dates = []
|
||||
|
||||
@@ -4,7 +4,7 @@ from contextlib import contextmanager
|
||||
from datetime import date, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.utils import get_datetime, getdate
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -45,94 +45,6 @@ class TestHolidayList(ERPNextTestSuite):
|
||||
self.assertIn(date(2023, 2, 26), holidays)
|
||||
self.assertNotIn(date(2023, 3, 5), holidays)
|
||||
|
||||
def test_total_holidays_includes_half_days(self):
|
||||
holiday_list = make_holiday_list(
|
||||
"test_half_day_holiday_list",
|
||||
from_date="2023-01-01",
|
||||
to_date="2023-01-03",
|
||||
holiday_dates=[
|
||||
{"holiday_date": "2023-01-01", "description": "Full-day holiday"},
|
||||
{
|
||||
"holiday_date": "2023-01-02",
|
||||
"description": "Half-day holiday",
|
||||
"is_half_day": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
self.assertEqual(frappe.db.get_value("Holiday List", holiday_list.name, "total_holidays"), 1.5)
|
||||
|
||||
def test_weekly_off_updates_total_without_saving(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2023-01-01"
|
||||
holiday_list.to_date = "2023-01-14"
|
||||
holiday_list.weekly_off = "Saturday"
|
||||
holiday_list.is_half_day = 1
|
||||
holiday_list.append("holidays", {"holiday_date": "2023-01-01", "description": "Full day"})
|
||||
|
||||
holiday_list.get_weekly_off_dates()
|
||||
self.assertEqual(len(holiday_list.holidays), 3)
|
||||
self.assertEqual(holiday_list.total_holidays, 2)
|
||||
|
||||
holiday_list.get_weekly_off_dates()
|
||||
self.assertEqual(len(holiday_list.holidays), 3)
|
||||
self.assertEqual(holiday_list.total_holidays, 2)
|
||||
|
||||
holiday_list.clear_table()
|
||||
self.assertEqual(holiday_list.holidays, [])
|
||||
self.assertEqual(holiday_list.total_holidays, 0)
|
||||
|
||||
def test_local_holidays_updates_total_without_saving(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2023-01-01"
|
||||
holiday_list.to_date = "2023-01-02"
|
||||
holiday_list.country = "DE"
|
||||
holiday_list.append(
|
||||
"holidays", {"holiday_date": "2023-01-02", "description": "Half day", "is_half_day": 1}
|
||||
)
|
||||
|
||||
holiday_list.get_local_holidays()
|
||||
self.assertEqual(len(holiday_list.holidays), 2)
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
|
||||
holiday_list.get_local_holidays()
|
||||
self.assertEqual(len(holiday_list.holidays), 2)
|
||||
self.assertEqual(holiday_list.total_holidays, 1.5)
|
||||
|
||||
def test_recalculate_existing_holiday_list_totals(self):
|
||||
from erpnext.patches.v16_0.recalculate_holiday_list_totals import execute
|
||||
|
||||
cases = (("mixed", [0, 1], 1.5), ("half", [1, 1, 1], 1.5), ("full", [0, 0], 2), ("empty", [], 0))
|
||||
holiday_lists = []
|
||||
for name, half_days, expected in cases:
|
||||
holiday_list = make_holiday_list(
|
||||
f"test_backfill_holidays_{name}",
|
||||
from_date="2023-01-01",
|
||||
to_date="2023-01-03",
|
||||
holiday_dates=[
|
||||
{
|
||||
"holiday_date": date(2023, 1, idx),
|
||||
"description": "Test holiday",
|
||||
"is_half_day": is_half_day,
|
||||
}
|
||||
for idx, is_half_day in enumerate(half_days, start=1)
|
||||
],
|
||||
)
|
||||
# Simulate totals persisted by the old controller, including a stale empty list.
|
||||
holiday_list.db_set("total_holidays", len(half_days) or 1, update_modified=False)
|
||||
holiday_lists.append((holiday_list, expected))
|
||||
|
||||
for _ in range(2):
|
||||
execute()
|
||||
for holiday_list, expected in holiday_lists:
|
||||
with self.subTest(holiday_list=holiday_list.name):
|
||||
total, modified = frappe.db.get_value(
|
||||
"Holiday List", holiday_list.name, ["total_holidays", "modified"]
|
||||
)
|
||||
self.assertEqual(total, expected)
|
||||
self.assertEqual(modified, get_datetime(holiday_list.modified))
|
||||
|
||||
def test_local_holidays(self):
|
||||
holiday_list = frappe.new_doc("Holiday List")
|
||||
holiday_list.from_date = "2022-01-01"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "field:batch_id",
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-05 14:50:38",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
@@ -44,16 +44,16 @@
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.__islocal",
|
||||
"fieldname": "batch_id",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Batch ID",
|
||||
"label": "Batch No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "batch_id",
|
||||
"oldfieldtype": "Data",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
"search_index": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
@@ -215,11 +215,11 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2026-08-21 23:11:39.905227",
|
||||
"modified": "2026-09-09 10:32:25.613814",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Batch",
|
||||
"naming_rule": "By fieldname",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -290,6 +290,8 @@
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "item",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -12,12 +12,14 @@ from frappe.model.naming import make_autoname, revert_series_if_last
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
from frappe.utils.data import DateTimeLikeObject, add_days
|
||||
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
class UnableToSelectBatchError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def get_name_from_hash():
|
||||
def get_name_from_hash(item_code=None):
|
||||
"""
|
||||
Get a name for a Batch by generating a unique hash.
|
||||
:return: The hash that was generated.
|
||||
@@ -25,7 +27,7 @@ def get_name_from_hash():
|
||||
temp = None
|
||||
while not temp:
|
||||
temp = frappe.generate_hash()[:7].upper()
|
||||
if frappe.db.exists("Batch", temp):
|
||||
if SerialBatchIdentity("Batch").exists(temp, item_code):
|
||||
temp = None
|
||||
|
||||
return temp
|
||||
@@ -114,11 +116,10 @@ class Batch(Document):
|
||||
use_batchwise_valuation: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
def autoname(self):
|
||||
"""Generate random ID for batch if not specified"""
|
||||
def before_naming(self):
|
||||
"""Generate a physical batch number when none was entered."""
|
||||
|
||||
if self.batch_id:
|
||||
self.name = self.batch_id
|
||||
return
|
||||
|
||||
create_new_batch, batch_number_series = frappe.db.get_value(
|
||||
@@ -126,7 +127,7 @@ class Batch(Document):
|
||||
)
|
||||
|
||||
if not create_new_batch:
|
||||
frappe.throw(_("Batch ID is mandatory"), frappe.MandatoryError)
|
||||
frappe.throw(_("Batch No is mandatory"), frappe.MandatoryError)
|
||||
|
||||
while not self.batch_id:
|
||||
if batch_number_series:
|
||||
@@ -134,21 +135,22 @@ class Batch(Document):
|
||||
elif batch_uses_naming_series():
|
||||
self.batch_id = self.get_name_from_naming_series()
|
||||
else:
|
||||
self.batch_id = get_name_from_hash()
|
||||
self.batch_id = get_name_from_hash(self.item)
|
||||
|
||||
# User might have manually created a batch with next number
|
||||
if frappe.db.exists("Batch", self.batch_id):
|
||||
if SerialBatchIdentity("Batch").exists(self.batch_id, self.item):
|
||||
self.batch_id = None
|
||||
|
||||
self.name = self.batch_id
|
||||
|
||||
def onload(self):
|
||||
self.image = frappe.db.get_value("Item", self.item, "image")
|
||||
|
||||
def after_delete(self):
|
||||
revert_series_if_last(get_batch_naming_series(), self.name)
|
||||
revert_series_if_last(get_batch_naming_series(), self.batch_id)
|
||||
|
||||
def validate(self):
|
||||
SerialBatchIdentity("Batch").validate(self)
|
||||
if not self.is_new() and frappe.db.get_value("Batch", self.name, "item") != self.item:
|
||||
frappe.throw(_("Item cannot be changed for an existing Batch"))
|
||||
self.item_has_batch_enabled()
|
||||
self.set_batchwise_valuation()
|
||||
|
||||
@@ -478,3 +480,7 @@ def get_batch_no(bundle_id):
|
||||
batches[batch_id] += abs(d.get("qty"))
|
||||
|
||||
return batches
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
SerialBatchIdentity("Batch").sync_constraint()
|
||||
|
||||
@@ -482,7 +482,7 @@ class TestBatch(ERPNextTestSuite):
|
||||
|
||||
if not frappe.db.exists("Batch", batch_name):
|
||||
batch = frappe.get_doc(doctype="Batch", item=item_name, batch_id=batch_name).insert(
|
||||
ignore_permissions=True
|
||||
ignore_permissions=True, set_name=batch_name
|
||||
)
|
||||
batch.save()
|
||||
|
||||
@@ -531,14 +531,15 @@ class TestBatch(ERPNextTestSuite):
|
||||
frappe.set_value("Stock Settings", "Stock Settings", "use_naming_series", 1)
|
||||
|
||||
batch = self.make_new_batch("_Test Stock Item For Batch Test1")
|
||||
batch_name = batch.name
|
||||
batch_name = batch.batch_id
|
||||
|
||||
self.assertNotEqual(batch.name, batch.batch_id)
|
||||
self.assertTrue(batch_name.startswith("BATCH-"))
|
||||
|
||||
batch.delete()
|
||||
batch = self.make_new_batch("_Test Stock Item For Batch Test2")
|
||||
|
||||
self.assertEqual(batch_name, batch.name)
|
||||
self.assertEqual(batch_name, batch.batch_id)
|
||||
|
||||
# reset Stock Settings
|
||||
if not use_naming_series:
|
||||
@@ -714,7 +715,12 @@ class TestBatch(ERPNextTestSuite):
|
||||
get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle),
|
||||
)
|
||||
|
||||
self.assertEqual("BATCHEXISTING002", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle))
|
||||
self.assertEqual(
|
||||
"BATCHEXISTING002",
|
||||
frappe.db.get_value(
|
||||
"Batch", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle), "batch_id"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_batch(item_code, rate, create_item_price_for_batch):
|
||||
@@ -771,6 +777,7 @@ def make_new_batch(**args):
|
||||
if args.expiry_date:
|
||||
batch.expiry_date = args.expiry_date
|
||||
|
||||
batch.insert()
|
||||
# Explicit names model batches already referenced by historical transactions.
|
||||
batch.insert(set_name=args.batch_id)
|
||||
|
||||
return batch
|
||||
|
||||
@@ -29,9 +29,6 @@ class BillingStatusService:
|
||||
def update_billing_status(self, update_modified: bool = True) -> None:
|
||||
doc = self.doc
|
||||
updated_delivery_notes = [doc.name]
|
||||
if doc.is_return and doc.return_against:
|
||||
updated_delivery_notes.append(doc.return_against)
|
||||
|
||||
for d in doc.get("items"):
|
||||
if d.si_detail and not d.so_detail:
|
||||
d.db_set("billed_amt", d.amount, update_modified=update_modified)
|
||||
@@ -40,8 +37,7 @@ class BillingStatusService:
|
||||
|
||||
for dn in set(updated_delivery_notes):
|
||||
dn_doc = doc if (dn == doc.name) else frappe.get_lazy_doc("Delivery Note", dn)
|
||||
update_dn_modified = update_modified and dn != doc.return_against
|
||||
dn_doc.update_billing_percentage(update_modified=update_dn_modified)
|
||||
dn_doc.update_billing_percentage(update_modified=update_modified)
|
||||
|
||||
doc.load_from_db()
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
sn_doc.insert(set_name=sn)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -1060,56 +1060,6 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
def test_dn_is_completed_when_unbilled_item_is_returned(self):
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
|
||||
|
||||
make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
|
||||
|
||||
dn = create_delivery_note(do_not_submit=True)
|
||||
dn.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"conversion_factor": 1,
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
dn.submit()
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.set("items", [item for item in si.items if item.item_code == "_Test Item"])
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"])
|
||||
return_dn.insert()
|
||||
# Mimic the submit request, which reconstructs the document from client data.
|
||||
return_dn = frappe.get_doc(return_dn.as_dict())
|
||||
return_dn.submit()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 1)
|
||||
self.assertEqual(dn.per_billed, 100)
|
||||
self.assertEqual(dn.status, "Completed")
|
||||
|
||||
return_dn.cancel()
|
||||
|
||||
dn.reload()
|
||||
self.assertEqual(dn.items[1].returned_qty, 0)
|
||||
self.assertEqual(dn.per_billed, 50)
|
||||
self.assertEqual(dn.status, "Partially Billed")
|
||||
|
||||
def test_dn_billing_status_case2(self):
|
||||
# SO -> SI and SO -> DN1, DN2
|
||||
from erpnext.selling.doctype.sales_order.mapper import (
|
||||
@@ -2109,7 +2059,7 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
sn_doc.insert(set_name=sn)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class DeliveryNoteItem(Document):
|
||||
class DeliveryNoteItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -73,44 +73,24 @@ frappe.ui.form.on("Inventory Dimension", {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
istable(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
reference_document(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
apply_to_all_doctypes(frm) {
|
||||
frm.trigger("set_parent_fields");
|
||||
},
|
||||
|
||||
set_parent_fields(frm) {
|
||||
const { reference_document, document_type } = frm.doc;
|
||||
if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) {
|
||||
return set_parent_field_options(frm, []);
|
||||
}
|
||||
|
||||
if (frm.doc.apply_to_all_doctypes) {
|
||||
return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]);
|
||||
} else if (document_type && frm.doc.istable) {
|
||||
let options = ["\n", frm.doc.reference_document];
|
||||
|
||||
frm.set_df_property("fetch_from_parent", "options", options);
|
||||
} else if (frm.doc.document_type && frm.doc.istable) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields",
|
||||
args: {
|
||||
child_doctype: document_type,
|
||||
dimension_name: reference_document,
|
||||
child_doctype: frm.doc.document_type,
|
||||
dimension_name: frm.doc.reference_document,
|
||||
},
|
||||
callback: (r) => {
|
||||
if (
|
||||
frm.doc.reference_document !== reference_document ||
|
||||
frm.doc.document_type !== document_type ||
|
||||
frm.doc.apply_to_all_doctypes ||
|
||||
!frm.doc.istable
|
||||
) {
|
||||
return;
|
||||
if (r.message && r.message.length) {
|
||||
frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message));
|
||||
} else {
|
||||
frm.set_df_property("fetch_from_parent", "hidden", 1);
|
||||
}
|
||||
|
||||
return set_parent_field_options(frm, r.message || []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -135,12 +115,3 @@ frappe.ui.form.on("Inventory Dimension", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function set_parent_field_options(frm, fields) {
|
||||
frm.set_df_property("fetch_from_parent", "options", ["", ...fields]);
|
||||
frm.set_df_property("fetch_from_parent", "hidden", !fields.length);
|
||||
|
||||
if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) {
|
||||
return frm.set_value("fetch_from_parent", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import (
|
||||
SerialNoInventoryDimensionError,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -503,6 +504,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -563,6 +565,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Empty Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -599,6 +602,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Required Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
@@ -644,6 +648,7 @@ class TestInventoryDimension(ERPNextTestSuite):
|
||||
{"has_serial_no": 1, "is_stock_item": 1},
|
||||
)
|
||||
serial_no = "Test Serialized Legacy Inventory Dimension Serial No"
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item.name, [serial_no], create=True)[0]
|
||||
warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse")
|
||||
|
||||
create_inventory_dimension(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.naming import NamingSeries
|
||||
from frappe.query_builder import Interval
|
||||
from frappe.query_builder.functions import Count, CurDate, UnixTimestamp
|
||||
from frappe.utils import (
|
||||
@@ -219,7 +218,6 @@ class Item(Document):
|
||||
self.validate_conversion_factor()
|
||||
self.validate_item_type()
|
||||
self.validate_naming_series()
|
||||
self.validate_shelf_life()
|
||||
self.check_for_active_boms()
|
||||
self.fill_customer_code()
|
||||
self.check_item_tax()
|
||||
@@ -399,19 +397,6 @@ class Item(Document):
|
||||
).format(self.item_code)
|
||||
)
|
||||
|
||||
def validate_shelf_life(self):
|
||||
if (
|
||||
self.has_batch_no
|
||||
and self.has_expiry_date
|
||||
and self.create_new_batch
|
||||
and cint(self.shelf_life_in_days) <= 0
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} must be greater than zero.").format(
|
||||
self.get_label_from_fieldname("shelf_life_in_days")
|
||||
)
|
||||
)
|
||||
|
||||
def clear_retain_sample(self):
|
||||
if not self.has_batch_no:
|
||||
self.retain_sample = False
|
||||
@@ -497,24 +482,6 @@ class Item(Document):
|
||||
)
|
||||
)
|
||||
|
||||
if self.is_new() and series:
|
||||
obj = NamingSeries(series)
|
||||
prefix = obj.get_prefix()
|
||||
doctype = frappe.qb.DocType("Series")
|
||||
|
||||
query = frappe.qb.from_(doctype).select(doctype.name).where(doctype.name.like(f"{prefix}%"))
|
||||
|
||||
prefix_exists = query.run(as_dict=True)
|
||||
if prefix_exists:
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"The {0} prefix '{1}' already exists. Please change the Serial No Series, otherwise you will get a Duplicate Entry error."
|
||||
).format(bold(frappe.unscrub(field)), bold(prefix)),
|
||||
title=_("Serial No Series Overlap"),
|
||||
indicator="yellow",
|
||||
alert=True,
|
||||
)
|
||||
|
||||
def check_for_active_boms(self):
|
||||
if self.default_bom:
|
||||
bom_item = frappe.db.get_value("BOM", self.default_bom, "item")
|
||||
@@ -655,6 +622,9 @@ class Item(Document):
|
||||
frappe.db.set_value("Item", old_name, "item_name", new_name)
|
||||
|
||||
if merge:
|
||||
from erpnext.stock.serial_batch_identity import validate_item_merge
|
||||
|
||||
validate_item_merge(old_name, new_name)
|
||||
self.validate_properties_before_merge(new_name)
|
||||
self.validate_duplicate_product_bundles_before_merge(old_name, new_name)
|
||||
self.delete_old_bins(old_name)
|
||||
|
||||
@@ -1268,9 +1268,18 @@ class TestItem(ERPNextTestSuite):
|
||||
).name
|
||||
|
||||
serial_no = f"{item}-SN-01"
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
|
||||
).insert()
|
||||
serial_no = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"serial_no": serial_no,
|
||||
"item_code": item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
|
||||
bundle = make_serial_batch_bundle(
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
|
||||
get_serial_nos_from_bundle,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import SerialNoValuation
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -433,15 +434,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
item_code = "_Test Serialized Item"
|
||||
warehouse = "Stores - TCP1"
|
||||
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": item_code,
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
serial_no = SerialBatchIdentity("Serial No").resolve(item_code, [serial_no], create=True)[0]
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -751,27 +744,12 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-TLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Batch", "BATCH-TLCVSNO-0001"):
|
||||
batch_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"item": batch_item,
|
||||
"batch_id": "BATCH-TLCVSNO-0001",
|
||||
}
|
||||
)
|
||||
batch_doc.insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, ["BATCH-TLCVSNO-0001"], create=True)[0]
|
||||
batch_doc = frappe.get_doc("Batch", batch_no)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -813,7 +791,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if row.item_code == sn_item:
|
||||
row.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
row.db_set("batch_no", "BATCH-TLCVSNO-0001")
|
||||
row.db_set("batch_no", batch_no)
|
||||
|
||||
for sn in serial_nos:
|
||||
sn_doc = frappe.get_doc("Serial No", sn)
|
||||
@@ -902,27 +880,12 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-TDVLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Batch", "BATCH-TDVLCVSNO-0001"):
|
||||
batch_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"item": batch_item,
|
||||
"batch_id": "BATCH-TDVLCVSNO-0001",
|
||||
}
|
||||
)
|
||||
batch_doc.insert()
|
||||
batch_no = SerialBatchIdentity("Batch").resolve(batch_item, ["BATCH-TDVLCVSNO-0001"], create=True)[0]
|
||||
batch_doc = frappe.get_doc("Batch", batch_no)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
@@ -974,7 +937,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if row.item_code == sn_item:
|
||||
row.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
row.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
row.db_set("batch_no", batch_no)
|
||||
|
||||
stock_ledger_entries = frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name})
|
||||
for sle in stock_ledger_entries:
|
||||
@@ -982,7 +945,7 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if doc.item_code == sn_item:
|
||||
doc.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
doc.db_set("batch_no", batch_no)
|
||||
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
@@ -1017,14 +980,14 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
if doc.item_code == sn_item:
|
||||
doc.db_set("serial_no", ", ".join(serial_nos))
|
||||
else:
|
||||
doc.db_set("batch_no", "BATCH-TDVLCVSNO-0001")
|
||||
doc.db_set("batch_no", batch_no)
|
||||
|
||||
available_batches = get_auto_batch_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": batch_item,
|
||||
"warehouse": warehouse,
|
||||
"batch_no": ["BATCH-TDVLCVSNO-0001"],
|
||||
"batch_no": [batch_no],
|
||||
"consider_negative_batches": True,
|
||||
}
|
||||
)
|
||||
@@ -1092,17 +1055,9 @@ class TestLandedCostVoucher(ERPNextTestSuite):
|
||||
"SN-ALCVTDVLCVSNO-0005",
|
||||
]
|
||||
|
||||
for sn in serial_nos:
|
||||
if not frappe.db.exists("Serial No", sn):
|
||||
sn_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Serial No",
|
||||
"item_code": sn_item,
|
||||
"serial_no": sn,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
)
|
||||
sn_doc.insert()
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(
|
||||
sn_item, serial_nos, create=True, defaults={"company": "_Test Company"}
|
||||
)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = frappe.db.get_value("Warehouse", warehouse, "company")
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
"idx": 70,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-09-08 12:00:00.000000",
|
||||
"modified": "2026-08-21 23:11:44.554719",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Material Request",
|
||||
@@ -442,11 +442,6 @@
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Manufacturing Manager"
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager",
|
||||
"select": 1
|
||||
|
||||
@@ -9,13 +9,13 @@ import json
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.get_item_details import get_item_details, get_price_list_rate
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PackedItem(Document):
|
||||
class PackedItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class TestPickList(ERPNextTestSuite):
|
||||
"item_code": "_Test Serialized Item",
|
||||
"serial_no": serial_no,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
stock_reconciliation = frappe.get_doc(
|
||||
{
|
||||
@@ -1152,7 +1152,7 @@ class TestPickList(ERPNextTestSuite):
|
||||
"batch_id": batch_id,
|
||||
"item": item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_id)
|
||||
|
||||
make_stock_entry(
|
||||
item=item,
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PickListItem(Document):
|
||||
class PickListItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -1093,7 +1093,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no[0],
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no[0])
|
||||
|
||||
pr_doc = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no)
|
||||
pr_doc.load_from_db()
|
||||
@@ -3144,6 +3144,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"SNU-TSFISI-000014",
|
||||
"SNU-TSFISI-000015",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(item_code, serial_nos, create=True)
|
||||
removed_serial = serial_nos[-1]
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
@@ -3162,7 +3166,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
for row in sbb_doc.entries:
|
||||
self.assertIn(row.serial_no, serial_nos)
|
||||
|
||||
serial_nos.remove("SNU-TSFISI-000015")
|
||||
serial_nos.remove(removed_serial)
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
@@ -3191,7 +3195,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
|
||||
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
||||
|
||||
serial_no_status = frappe.db.get_value("Serial No", "SNU-TSFISI-000015", "status")
|
||||
serial_no_status = frappe.db.get_value("Serial No", removed_serial, "status")
|
||||
|
||||
self.assertNotEqual(serial_no_status, "Active")
|
||||
|
||||
@@ -3443,7 +3447,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_no)
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
@@ -3454,7 +3458,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=batch_item,
|
||||
@@ -4762,7 +4766,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": batch_no,
|
||||
"item": batch_item,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=batch_no)
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not frappe.db.exists("Serial No", serial_no):
|
||||
@@ -4773,7 +4777,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=batch_item,
|
||||
@@ -5738,7 +5742,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": "BN-TESTDNUBVWF-00001",
|
||||
"item": item_code,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="BN-TESTDNUBVWF-00001")
|
||||
|
||||
doc.db_set("use_batchwise_valuation", 0)
|
||||
doc.reload()
|
||||
@@ -5751,7 +5755,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
"batch_id": "BN-TESTDNUBVWF-00002",
|
||||
"item": item_code,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="BN-TESTDNUBVWF-00002")
|
||||
|
||||
self.assertEqual(doc.use_batchwise_valuation, 1)
|
||||
|
||||
@@ -5881,7 +5885,11 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
).name
|
||||
|
||||
batch_no = "BN-TPRBWV-00001"
|
||||
batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert()
|
||||
batch = (
|
||||
frappe.new_doc("Batch")
|
||||
.update({"batch_id": batch_no, "item": item_code})
|
||||
.insert(set_name=batch_no)
|
||||
)
|
||||
self.assertEqual(batch.use_batchwise_valuation, 1)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class PurchaseReceiptItem(Document):
|
||||
class PurchaseReceiptItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ from frappe.utils import cint, flt, parse_json
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
create_serial_batch_no_ledgers,
|
||||
get_type_of_transaction,
|
||||
make_batch_nos,
|
||||
make_serial_nos,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import add_number_labels, resolve_number_entries
|
||||
|
||||
SUPPORTED_VOUCHER_TYPES = frozenset(
|
||||
[
|
||||
@@ -36,8 +35,14 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
|
||||
page_length = min(cint(page_length) or 50, 500)
|
||||
|
||||
table = frappe.qb.DocType("Serial and Batch Entry")
|
||||
serial = frappe.qb.DocType("Serial No")
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.left_join(serial)
|
||||
.on(serial.name == table.serial_no)
|
||||
.left_join(batch)
|
||||
.on(batch.name == table.batch_no)
|
||||
.select(table.name, table.serial_no, table.batch_no, table.qty)
|
||||
.where(table.parent == bundle)
|
||||
.orderby(table.idx)
|
||||
@@ -47,9 +52,9 @@ def get_bundle_entries(bundle: str, start: int = 0, page_length: int = 50, searc
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.where((table.serial_no.like(search_term)) | (table.batch_no.like(search_term)))
|
||||
query = query.where(serial.serial_no.like(search_term) | batch.batch_id.like(search_term))
|
||||
|
||||
entries = query.run(as_dict=True)
|
||||
entries = add_number_labels(query.run(as_dict=True))
|
||||
summary = get_bundle_summary(bundle)
|
||||
summary["entries"] = entries
|
||||
|
||||
@@ -82,13 +87,13 @@ def download_bundle_entries_csv(bundle: str):
|
||||
item = frappe.get_cached_value("Item", doc.item_code, ["has_serial_no", "has_batch_no"], as_dict=True)
|
||||
|
||||
rows = [get_csv_columns(item)]
|
||||
for entry in doc.entries:
|
||||
for entry in add_number_labels(doc.entries):
|
||||
if item.has_serial_no and item.has_batch_no:
|
||||
rows.append([entry.serial_no, entry.batch_no, abs(entry.qty)])
|
||||
rows.append([entry.serial_number, entry.batch_number, abs(entry.qty)])
|
||||
elif item.has_batch_no:
|
||||
rows.append([entry.batch_no, abs(entry.qty)])
|
||||
rows.append([entry.batch_number, abs(entry.qty)])
|
||||
else:
|
||||
rows.append([entry.serial_no])
|
||||
rows.append([entry.serial_number])
|
||||
|
||||
build_csv_response(rows, f"{bundle}-entries")
|
||||
|
||||
@@ -132,9 +137,9 @@ def upsert_bundle_entries(
|
||||
frappe.throw(_("Please add at least one Serial No or Batch to save"))
|
||||
|
||||
frappe.has_permission(doc.get("doctype"), "write", throw=True)
|
||||
if get_type_of_transaction(doc, child_row) == "Inward":
|
||||
make_serial_nos(child_row.item_code, entries)
|
||||
make_batch_nos(child_row.item_code, entries)
|
||||
resolve_number_entries(
|
||||
child_row.item_code, entries, create=get_type_of_transaction(doc, child_row) == "Inward"
|
||||
)
|
||||
|
||||
bundle = create_serial_batch_no_ledgers(entries, child_row, doc)
|
||||
|
||||
@@ -175,6 +180,10 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
|
||||
)
|
||||
)
|
||||
|
||||
if child_row.item_code != bundle.item_code:
|
||||
frappe.throw(_("The bundle belongs to a different item"))
|
||||
|
||||
resolve_number_entries(bundle.item_code, entries, create=bundle.type_of_transaction == "Inward")
|
||||
sign = 1 if bundle.type_of_transaction == "Inward" else -1
|
||||
|
||||
if replace:
|
||||
@@ -198,11 +207,6 @@ def apply_incremental_changes(bundle_name, child_row, entries, deleted, replace=
|
||||
if row.get("serial_no"):
|
||||
entry.serial_no = row.get("serial_no")
|
||||
|
||||
if entries and bundle.type_of_transaction == "Inward":
|
||||
incoming = [frappe._dict(row) for row in entries]
|
||||
make_serial_nos(child_row.item_code, incoming)
|
||||
make_batch_nos(child_row.item_code, incoming)
|
||||
|
||||
for row in new_rows:
|
||||
bundle.append(
|
||||
"entries",
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.query_builder.functions import Concat_ws, Max, Sum
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
cstr,
|
||||
escape_html,
|
||||
flt,
|
||||
format_datetime,
|
||||
get_datetime,
|
||||
@@ -34,6 +35,8 @@ from erpnext.stock.serial_batch_bundle import (
|
||||
get_batches_from_bundle,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
|
||||
from erpnext.stock.serial_batch_display import format_serial_batch_numbers
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity, add_number_labels, resolve_number_entries
|
||||
from erpnext.stock.valuation import FIFOValuation
|
||||
|
||||
|
||||
@@ -169,7 +172,7 @@ class SerialandBatchBundle(Document):
|
||||
"You cannot outward the following {0} as they are either Delivered, Inactive or located in a different warehouse."
|
||||
).format(_("Serial Nos") if len(invalid_serial_nos) > 1 else _("Serial No"))
|
||||
msg += "<hr>"
|
||||
msg += ", ".join(sn for sn in invalid_serial_nos)
|
||||
msg += format_serial_batch_numbers("Serial No", invalid_serial_nos)
|
||||
frappe.throw(msg)
|
||||
|
||||
def validate_voucher_detail_no(self):
|
||||
@@ -230,7 +233,7 @@ class SerialandBatchBundle(Document):
|
||||
_(
|
||||
"You cannot process the serial number {0} as it has already been used in the SABB {1}. {2} If you want to inward the same serial number multiple times, then enable 'Allow existing Serial No to be Manufactured/Received again' in the {3}"
|
||||
).format(
|
||||
row.serial_no,
|
||||
format_serial_batch_numbers("Serial No", [row.serial_no]),
|
||||
get_link_to_form("Serial and Batch Bundle", row.parent),
|
||||
note,
|
||||
get_link_to_form("Stock Settings", "Stock Settings"),
|
||||
@@ -310,10 +313,11 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse:
|
||||
serial_number = format_serial_batch_numbers("Serial No", [serial_no])
|
||||
reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse)
|
||||
if reservation:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} is in warehouse {bold(self.warehouse)}"
|
||||
f"Serial No {bold(serial_number)} is in warehouse {bold(self.warehouse)}"
|
||||
f" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}"
|
||||
f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}."
|
||||
f" Please use an unreserved serial number or cancel the reservation.",
|
||||
@@ -321,7 +325,9 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
else:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.",
|
||||
_("Serial No {0} is not present in the warehouse {1}.").format(
|
||||
bold(serial_number), bold(self.warehouse)
|
||||
),
|
||||
SerialNoWarehouseError,
|
||||
)
|
||||
|
||||
@@ -359,7 +365,9 @@ class SerialandBatchBundle(Document):
|
||||
for data in available_serial_nos:
|
||||
if data.serial_no in serial_nos:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(data.serial_no)} is already present in the warehouse {bold(data.warehouse)}.",
|
||||
_("Serial No {0} is already present in the warehouse {1}.").format(
|
||||
bold(format_serial_batch_numbers("Serial No", [data.serial_no])), bold(data.warehouse)
|
||||
),
|
||||
SerialNoDuplicateError,
|
||||
)
|
||||
|
||||
@@ -378,13 +386,13 @@ class SerialandBatchBundle(Document):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Serial No {0} is already Delivered. You cannot use it again in Manufacture / Repack entry."
|
||||
).format(bold(serial_nos[0]))
|
||||
).format(bold(format_serial_batch_numbers("Serial No", [serial_nos[0]])))
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Serial Nos {0} are already Delivered. You cannot use them again in Manufacture / Repack entry."
|
||||
).format(bold(", ".join(serial_nos)))
|
||||
).format(bold(format_serial_batch_numbers("Serial No", serial_nos)))
|
||||
)
|
||||
|
||||
def throw_error_message(self, message, exception=frappe.ValidationError):
|
||||
@@ -533,14 +541,22 @@ class SerialandBatchBundle(Document):
|
||||
self.throw_error_message(
|
||||
_(
|
||||
"Serial No {0} is not present in the {1} {2}, hence you can't return it against the {1} {2}"
|
||||
).format(bold(row.serial_no), self.voucher_type, bold(return_against))
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Serial No", [row.serial_no])),
|
||||
self.voucher_type,
|
||||
bold(return_against),
|
||||
)
|
||||
)
|
||||
|
||||
if row.batch_no and row.batch_no not in original_inv_details["batches"]:
|
||||
self.throw_error_message(
|
||||
_(
|
||||
"Batch No {0} is not present in the original {1} {2}, hence you can't return it against the {1} {2}"
|
||||
).format(bold(row.batch_no), self.voucher_type, bold(return_against))
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Batch", [row.batch_no])),
|
||||
self.voucher_type,
|
||||
bold(return_against),
|
||||
)
|
||||
)
|
||||
|
||||
def get_valuation_rate_for_return_entry(self, return_against):
|
||||
@@ -772,7 +788,10 @@ class SerialandBatchBundle(Document):
|
||||
if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty):
|
||||
frappe.throw(
|
||||
_("Batch No {0} of Item {1} has negative stock of quantity {2} in the warehouse {3}").format(
|
||||
bold(batch_no), bold(self.item_code), bold(available_qty), self.warehouse
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
bold(self.item_code),
|
||||
bold(available_qty),
|
||||
self.warehouse,
|
||||
),
|
||||
BatchNegativeStockError,
|
||||
)
|
||||
@@ -1092,11 +1111,12 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
msg += "<br><br><ul>"
|
||||
|
||||
add_number_labels(future_entries)
|
||||
for d in future_entries:
|
||||
if self.has_serial_no:
|
||||
msg += f"<li>{d.serial_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += f"<li>{escape_html(d.serial_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
else:
|
||||
msg += f"<li>{d.batch_no} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += f"<li>{escape_html(d.batch_number or '')} in {get_link_to_form(d.voucher_type, d.voucher_no)}</li>"
|
||||
msg += "</li></ul>"
|
||||
|
||||
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransactionError)
|
||||
@@ -1282,7 +1302,7 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
frappe.throw(
|
||||
_("At row {0}: Qty is mandatory for the batch {1}").format(
|
||||
bold(row.idx), bold(row.batch_no)
|
||||
bold(row.idx), bold(format_serial_batch_numbers("Batch", [row.batch_no]))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1333,7 +1353,10 @@ class SerialandBatchBundle(Document):
|
||||
for serial_no, batch_no in serial_batches.items():
|
||||
if correct_batches.get(serial_no) and correct_batches.get(serial_no) != batch_no:
|
||||
self.throw_error_message(
|
||||
f"Serial No {bold(serial_no)} does not belong to Batch No {bold(batch_no)}"
|
||||
_("Serial No {0} does not belong to Batch No {1}").format(
|
||||
bold(format_serial_batch_numbers("Serial No", [serial_no])),
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_incorrect_serial_nos(self, serial_nos):
|
||||
@@ -1344,9 +1367,13 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
if incorrect_serial_nos:
|
||||
incorrect_serial_nos = ", ".join([d.name for d in incorrect_serial_nos])
|
||||
incorrect_serial_nos = format_serial_batch_numbers(
|
||||
"Serial No", [d.name for d in incorrect_serial_nos]
|
||||
)
|
||||
self.throw_error_message(
|
||||
f"Serial Nos {bold(incorrect_serial_nos)} does not belong to Item {bold(self.item_code)}"
|
||||
_("Serial Nos {0} does not belong to Item {1}").format(
|
||||
bold(incorrect_serial_nos), bold(self.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_incorrect_batch_nos(self, batch_nos):
|
||||
@@ -1355,9 +1382,11 @@ class SerialandBatchBundle(Document):
|
||||
)
|
||||
|
||||
if incorrect_batch_nos:
|
||||
incorrect_batch_nos = ", ".join([d.name for d in incorrect_batch_nos])
|
||||
incorrect_batch_nos = format_serial_batch_numbers("Batch", [d.name for d in incorrect_batch_nos])
|
||||
self.throw_error_message(
|
||||
f"Batch Nos {bold(incorrect_batch_nos)} does not belong to Item {bold(self.item_code)}"
|
||||
_("Batch Nos {0} does not belong to Item {1}").format(
|
||||
bold(incorrect_batch_nos), bold(self.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_serial_and_batch_no_for_returned(self):
|
||||
@@ -1399,13 +1428,17 @@ class SerialandBatchBundle(Document):
|
||||
if serial_nos:
|
||||
if not set(current_serial_nos).issubset(set(serial_nos)):
|
||||
self.throw_error_message(
|
||||
f"Serial Nos {bold(', '.join(serial_nos))} are not part of the original document."
|
||||
_("Serial Nos {0} are not part of the original document.").format(
|
||||
bold(format_serial_batch_numbers("Serial No", serial_nos))
|
||||
)
|
||||
)
|
||||
|
||||
if batches:
|
||||
if not set(current_batches).issubset(set(batches)):
|
||||
self.throw_error_message(
|
||||
f"Batch Nos {bold(', '.join(batches))} are not part of the original document."
|
||||
_("Batch Nos {0} are not part of the original document.").format(
|
||||
bold(format_serial_batch_numbers("Batch", batches))
|
||||
)
|
||||
)
|
||||
|
||||
def get_orignal_document_data(self):
|
||||
@@ -1433,12 +1466,18 @@ class SerialandBatchBundle(Document):
|
||||
if serial_nos:
|
||||
for key, value in collections.Counter(serial_nos).items():
|
||||
if value > 1:
|
||||
self.throw_error_message(f"Duplicate Serial No {key} found")
|
||||
self.throw_error_message(
|
||||
_("Duplicate Serial No {0} found").format(
|
||||
format_serial_batch_numbers("Serial No", [key])
|
||||
)
|
||||
)
|
||||
|
||||
if batch_nos:
|
||||
for key, value in collections.Counter(batch_nos).items():
|
||||
if value > 1:
|
||||
self.throw_error_message(f"Duplicate Batch No {key} found")
|
||||
self.throw_error_message(
|
||||
_("Duplicate Batch No {0} found").format(format_serial_batch_numbers("Batch", [key]))
|
||||
)
|
||||
|
||||
def before_cancel(self):
|
||||
self.delink_serial_and_batch_bundle()
|
||||
@@ -1631,7 +1670,9 @@ class SerialandBatchBundle(Document):
|
||||
self.validate_negative_batch(batch_no, available_batches[batch_no])
|
||||
|
||||
self.throw_error_message(
|
||||
f"Batch {bold(batch_no)} is not available in the selected warehouse {self.warehouse}"
|
||||
_("Batch {0} is not available in the selected warehouse {1}").format(
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])), self.warehouse
|
||||
)
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
@@ -1710,7 +1751,7 @@ class SerialandBatchBundle(Document):
|
||||
"However, enabling this setting may lead to negative stock in the system. "
|
||||
"So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."
|
||||
).format(
|
||||
bold(batch_no),
|
||||
bold(format_serial_batch_numbers("Batch", [batch_no])),
|
||||
bold(self.item_code),
|
||||
bold(self.warehouse),
|
||||
date_msg,
|
||||
@@ -1986,19 +2027,19 @@ def parse_csv_file_to_get_serial_batch(reader):
|
||||
continue
|
||||
|
||||
if has_serial_no or (has_serial_no and has_batch_no):
|
||||
_dict = {"serial_no": row[0].strip(), "qty": 1}
|
||||
_dict = {"serial_number": row[0].strip(), "qty": 1}
|
||||
|
||||
if has_batch_no:
|
||||
_dict.update(
|
||||
{
|
||||
"batch_no": row[1].strip(),
|
||||
"batch_number": row[1].strip(),
|
||||
"qty": row[2],
|
||||
}
|
||||
)
|
||||
|
||||
batch_nos.append(
|
||||
{
|
||||
"batch_no": row[1].strip(),
|
||||
"batch_number": row[1].strip(),
|
||||
"qty": row[2],
|
||||
}
|
||||
)
|
||||
@@ -2007,7 +2048,7 @@ def parse_csv_file_to_get_serial_batch(reader):
|
||||
elif has_batch_no:
|
||||
batch_nos.append(
|
||||
{
|
||||
"batch_no": row[0].strip(),
|
||||
"batch_number": row[0].strip(),
|
||||
"qty": row[1],
|
||||
}
|
||||
)
|
||||
@@ -2023,7 +2064,7 @@ def get_serial_batch_from_data(item_code, kwargs):
|
||||
for serial_no in data:
|
||||
if not serial_no:
|
||||
continue
|
||||
serial_nos.append({"serial_no": serial_no, "qty": 1})
|
||||
serial_nos.append({"serial_number": serial_no, "qty": 1})
|
||||
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
|
||||
@@ -2047,106 +2088,12 @@ def create_serial_nos(item_code: str, serial_nos: list | str):
|
||||
|
||||
|
||||
def make_serial_nos(item_code, serial_nos):
|
||||
item = frappe.get_cached_value(
|
||||
"Item", item_code, ["description", "item_code", "item_name", "warranty_period"], as_dict=1
|
||||
)
|
||||
|
||||
serial_nos = [d.get("serial_no").strip() for d in serial_nos if d.get("serial_no")]
|
||||
existing_serial_nos = frappe.get_all("Serial No", filters={"name": ("in", serial_nos)})
|
||||
|
||||
existing_serial_nos = [d.get("name") for d in existing_serial_nos if d.get("name")]
|
||||
serial_nos = list(set(serial_nos) - set(existing_serial_nos))
|
||||
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
serial_nos_details = []
|
||||
user = frappe.session.user
|
||||
for serial_no in serial_nos:
|
||||
serial_nos_details.append(
|
||||
(
|
||||
serial_no,
|
||||
serial_no,
|
||||
now(),
|
||||
now(),
|
||||
user,
|
||||
user,
|
||||
item.item_code,
|
||||
item.item_name,
|
||||
item.description,
|
||||
item.warranty_period or 0,
|
||||
"Inactive",
|
||||
)
|
||||
)
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"serial_no",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"warranty_period",
|
||||
"status",
|
||||
]
|
||||
|
||||
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
|
||||
|
||||
frappe.msgprint(_("Serial Nos are created successfully"), alert=True)
|
||||
"""Resolve explicit physical numbers and populate the entry links."""
|
||||
resolve_number_entries(item_code, serial_nos, create=True)
|
||||
|
||||
|
||||
def make_batch_nos(item_code, batch_nos):
|
||||
item = frappe.get_cached_value("Item", item_code, ["description", "item_code"], as_dict=1)
|
||||
batch_nos = [d.get("batch_no") for d in batch_nos if d.get("batch_no")]
|
||||
|
||||
existing_batches = frappe.get_all("Batch", filters={"name": ("in", batch_nos)})
|
||||
|
||||
existing_batches = [d.get("name") for d in existing_batches if d.get("name")]
|
||||
|
||||
batch_nos = list(set(batch_nos) - set(existing_batches))
|
||||
if not batch_nos:
|
||||
return
|
||||
|
||||
batch_nos_details = []
|
||||
user = frappe.session.user
|
||||
for batch_no in batch_nos:
|
||||
if frappe.db.exists("Batch", batch_no):
|
||||
continue
|
||||
|
||||
batch_nos_details.append(
|
||||
(
|
||||
batch_no,
|
||||
batch_no,
|
||||
now(),
|
||||
now(),
|
||||
user,
|
||||
user,
|
||||
item.item_code,
|
||||
item.item_name,
|
||||
item.description,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"batch_id",
|
||||
"creation",
|
||||
"modified",
|
||||
"owner",
|
||||
"modified_by",
|
||||
"item",
|
||||
"item_name",
|
||||
"description",
|
||||
"use_batchwise_valuation",
|
||||
]
|
||||
|
||||
frappe.db.bulk_insert("Batch", fields=fields, values=set(batch_nos_details))
|
||||
|
||||
frappe.msgprint(_("Batch Nos are created successfully"), alert=True)
|
||||
resolve_number_entries(item_code, batch_nos, create=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -2266,6 +2213,10 @@ def add_serial_batch_ledgers(
|
||||
if parent_doc and isinstance(parent_doc, str):
|
||||
parent_doc = parse_json(parent_doc)
|
||||
|
||||
resolve_number_entries(
|
||||
child_row.item_code, entries, create=get_type_of_transaction(parent_doc, child_row) == "Inward"
|
||||
)
|
||||
|
||||
bundle = child_row.serial_and_batch_bundle
|
||||
if child_row.get("is_rejected"):
|
||||
bundle = child_row.rejected_serial_and_batch_bundle
|
||||
@@ -2472,11 +2423,15 @@ def get_serial_and_batch_ledger(**kwargs):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_auto_data(**kwargs):
|
||||
from erpnext.stock.serial_batch_identity import add_number_labels
|
||||
|
||||
kwargs = frappe._dict(kwargs)
|
||||
data = []
|
||||
if cint(kwargs.has_serial_no):
|
||||
return get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
|
||||
data = get_serial_nos_from_sre(kwargs) if kwargs.scio_detail else get_available_serial_nos(kwargs)
|
||||
elif cint(kwargs.has_batch_no):
|
||||
return get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
|
||||
data = get_batch_nos_from_sre(kwargs) if kwargs.scio_detail else get_auto_batch_nos(kwargs)
|
||||
return add_number_labels(data or [])
|
||||
|
||||
|
||||
def get_available_batches_qty(available_batches):
|
||||
@@ -2832,7 +2787,11 @@ def get_reserved_serial_nos_for_voucher(kwargs, reserved_entries, reserved_vouch
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Serial No {0} is reserved against the {1} {2} and cannot be used for any other transaction."
|
||||
).format(bold(entry.serial_no), entry.voucher_type, bold(entry.voucher_no)),
|
||||
).format(
|
||||
bold(format_serial_batch_numbers("Serial No", [entry.serial_no])),
|
||||
entry.voucher_type,
|
||||
bold(entry.voucher_no),
|
||||
),
|
||||
title=_("Serial No Reserved"),
|
||||
)
|
||||
|
||||
@@ -3693,35 +3652,25 @@ def get_batch_no_from_serial_no(serial_no: str):
|
||||
return frappe.get_cached_value("Serial No", serial_no, "batch_no")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_serial_batch_no_exists(
|
||||
item_code: str, type_of_transaction: str, serial_no: str | None = None, batch_no: str | None = None
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def resolve_scanned_serial_batch_numbers(
|
||||
item_code: str, serial_no: str | None = None, batch_no: str | None = None
|
||||
):
|
||||
if serial_no and not frappe.db.exists("Serial No", serial_no):
|
||||
if type_of_transaction != "Inward":
|
||||
frappe.throw(_("Serial No {0} does not exist").format(serial_no))
|
||||
from erpnext.stock.serial_batch_identity import resolve_serial_batch_numbers
|
||||
|
||||
make_serial_no(serial_no, item_code)
|
||||
|
||||
if batch_no and not frappe.db.exists("Batch", batch_no):
|
||||
if type_of_transaction != "Inward":
|
||||
frappe.throw(_("Batch No {0} does not exist").format(batch_no))
|
||||
|
||||
make_batch_no(batch_no, item_code)
|
||||
return resolve_serial_batch_numbers(
|
||||
item_code,
|
||||
serial_numbers=[serial_no] if serial_no else [],
|
||||
batch_numbers=[batch_no] if batch_no else [],
|
||||
)
|
||||
|
||||
|
||||
def make_serial_no(serial_no, item_code):
|
||||
serial_no_doc = frappe.new_doc("Serial No")
|
||||
serial_no_doc.serial_no = serial_no
|
||||
serial_no_doc.item_code = item_code
|
||||
serial_no_doc.save(ignore_permissions=True)
|
||||
return SerialBatchIdentity("Serial No").resolve(item_code, [serial_no], create=True)[0]
|
||||
|
||||
|
||||
def make_batch_no(batch_no, item_code):
|
||||
batch_doc = frappe.new_doc("Batch")
|
||||
batch_doc.batch_id = batch_no
|
||||
batch_doc.item = item_code
|
||||
batch_doc.save(ignore_permissions=True)
|
||||
return SerialBatchIdentity("Batch").resolve(item_code, [batch_no], create=True)[0]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -39,26 +39,26 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
self.assertTrue(frappe.db.exists("Serial and Batch Bundle", summary.bundle))
|
||||
self.assertEqual(summary.total_count, 2)
|
||||
self.assertEqual(summary.total_qty, 2)
|
||||
for serial_no in serials:
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": serial_no}))
|
||||
|
||||
def test_incremental_append_preserves_existing_entries(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item, qty=3)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": serials[0]}, {"serial_no": serials[1]}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": serials[0]}, {"serial_number": serials[1]}])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
first_entry_names = set(
|
||||
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
|
||||
)
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": serials[2]}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": serials[2]}])
|
||||
second_entry_names = set(
|
||||
frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")
|
||||
)
|
||||
@@ -71,17 +71,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
|
||||
to_delete = frappe.get_all(
|
||||
"Serial and Batch Entry", {"parent": summary.bundle, "serial_no": serials[0]}, pluck="name"
|
||||
"Serial and Batch Entry",
|
||||
{
|
||||
"parent": summary.bundle,
|
||||
"serial_no": frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[0]}),
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
summary = self.upsert(pr, deleted=to_delete)
|
||||
|
||||
self.assertEqual(summary.total_count, 1)
|
||||
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
|
||||
self.assertEqual(remaining, [serials[1]])
|
||||
self.assertEqual(
|
||||
remaining, [frappe.db.get_value("Serial No", {"item_code": item, "serial_no": serials[1]})]
|
||||
)
|
||||
|
||||
def test_batch_qty_update(self):
|
||||
item = make_item(
|
||||
@@ -111,14 +118,21 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
old_serial = f"SN-{frappe.generate_hash(length=8)}"
|
||||
new_serial = f"SN-{frappe.generate_hash(length=8)}"
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": old_serial}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": old_serial}])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
entry_name = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="name")[0]
|
||||
|
||||
self.upsert(pr, entries=[{"name": entry_name, "serial_no": new_serial}])
|
||||
self.upsert(pr, entries=[{"name": entry_name, "serial_number": new_serial}])
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"), new_serial)
|
||||
self.assertTrue(frappe.db.exists("Serial No", new_serial))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Serial No",
|
||||
frappe.db.get_value("Serial and Batch Entry", entry_name, "serial_no"),
|
||||
"serial_no",
|
||||
),
|
||||
new_serial,
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item, "serial_no": new_serial}))
|
||||
|
||||
def test_auto_create_missing_batch_no(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_batch_no": 1}).name
|
||||
@@ -126,14 +140,14 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
batch1 = f"BNEW-{frappe.generate_hash(length=8)}"
|
||||
batch2 = f"BNEW-{frappe.generate_hash(length=8)}"
|
||||
|
||||
self.assertFalse(frappe.db.exists("Batch", batch1))
|
||||
summary = self.upsert(pr, entries=[{"batch_no": batch1, "qty": 4}])
|
||||
self.assertTrue(frappe.db.exists("Batch", batch1))
|
||||
self.assertFalse(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
|
||||
summary = self.upsert(pr, entries=[{"batch_number": batch1, "qty": 4}])
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch1}))
|
||||
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
summary = self.upsert(pr, entries=[{"batch_no": batch2, "qty": 1}])
|
||||
summary = self.upsert(pr, entries=[{"batch_number": batch2, "qty": 1}])
|
||||
|
||||
self.assertTrue(frappe.db.exists("Batch", batch2))
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item, "batch_id": batch2}))
|
||||
self.assertEqual(summary.total_qty, 5)
|
||||
|
||||
def test_update_batch_no_of_existing_entry(self):
|
||||
@@ -164,7 +178,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].serial_and_batch_bundle = bundle
|
||||
pr.items[0].db_set("serial_and_batch_bundle", bundle)
|
||||
@@ -184,12 +198,12 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item)
|
||||
victim_pr = self.make_draft_pr(item)
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].db_set("serial_and_batch_bundle", bundle)
|
||||
|
||||
victim_summary = self.upsert(
|
||||
victim_pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}]
|
||||
victim_pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}]
|
||||
)
|
||||
victim_bundle = victim_summary.bundle
|
||||
victim_pr.items[0].db_set("serial_and_batch_bundle", victim_bundle)
|
||||
@@ -216,7 +230,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
pr = self.make_draft_pr(item, qty=5)
|
||||
serials = sorted(f"SN-{frappe.generate_hash(length=8)}" for _ in range(5))
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
page = get_bundle_entries(summary.bundle, start=0, page_length=2)
|
||||
self.assertEqual(len(page["entries"]), 2)
|
||||
@@ -231,22 +245,22 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
token = frappe.generate_hash(length=8)
|
||||
serials = [f"AAA-{token}", f"BBB-{token}"]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
|
||||
page = get_bundle_entries(summary.bundle, search=f"AAA-{token}")
|
||||
self.assertEqual(len(page["entries"]), 1)
|
||||
self.assertEqual(page["entries"][0].serial_no, f"AAA-{token}")
|
||||
self.assertEqual(page["entries"][0].serial_number, f"AAA-{token}")
|
||||
|
||||
def test_rejected_bundle_created_separately(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item)
|
||||
pr.items[0].rejected_warehouse = "_Test Warehouse 1 - _TC"
|
||||
|
||||
accepted = self.upsert(pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
accepted = self.upsert(pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}])
|
||||
pr.items[0].serial_and_batch_bundle = accepted.bundle
|
||||
|
||||
rejected = self.upsert(
|
||||
pr, entries=[{"serial_no": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
|
||||
pr, entries=[{"serial_number": f"SN-{frappe.generate_hash(length=8)}"}], is_rejected=1
|
||||
)
|
||||
|
||||
self.assertNotEqual(accepted.bundle, rejected.bundle)
|
||||
@@ -260,21 +274,24 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
old_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
new_serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(3)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in old_serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in old_serials])
|
||||
pr.items[0].serial_and_batch_bundle = summary.bundle
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in new_serials], replace=1)
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in new_serials], replace=1)
|
||||
|
||||
self.assertEqual(summary.total_count, 3)
|
||||
remaining = frappe.get_all("Serial and Batch Entry", {"parent": summary.bundle}, pluck="serial_no")
|
||||
self.assertEqual(sorted(remaining), sorted(new_serials))
|
||||
self.assertEqual(
|
||||
sorted(frappe.get_all("Serial No", filters={"name": ("in", remaining)}, pluck="serial_no")),
|
||||
sorted(new_serials),
|
||||
)
|
||||
|
||||
def test_replace_with_no_entries_removes_bundle(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
pr = self.make_draft_pr(item)
|
||||
serials = [f"SN-{frappe.generate_hash(length=8)}" for _ in range(2)]
|
||||
|
||||
summary = self.upsert(pr, entries=[{"serial_no": d} for d in serials])
|
||||
summary = self.upsert(pr, entries=[{"serial_number": d} for d in serials])
|
||||
bundle = summary.bundle
|
||||
pr.items[0].serial_and_batch_bundle = bundle
|
||||
|
||||
@@ -295,7 +312,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
summary = upsert_bundle_entries(
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(se.as_dict(), default=str),
|
||||
entries=json.dumps([{"serial_no": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
|
||||
entries=json.dumps([{"serial_number": f"SN-{frappe.generate_hash(length=8)}"} for _ in range(2)]),
|
||||
deleted=json.dumps([]),
|
||||
)
|
||||
|
||||
@@ -323,7 +340,7 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
upsert_bundle_entries,
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(pr.as_dict(), default=str),
|
||||
entries=json.dumps([{"serial_no": "SBIE-PT-0001"}]),
|
||||
entries=json.dumps([{"serial_number": "SBIE-PT-0001"}]),
|
||||
)
|
||||
|
||||
def test_upsert_rejects_unsupported_voucher_type(self):
|
||||
@@ -342,5 +359,5 @@ class TestSerialBatchInlineEditor(ERPNextTestSuite):
|
||||
upsert_bundle_entries,
|
||||
child_row=json.dumps(child_row, default=str),
|
||||
doc=json.dumps(doc, default=str),
|
||||
entries=json.dumps([{"serial_no": "SBIE-PT-0002"}]),
|
||||
entries=json.dumps([{"serial_number": "SBIE-PT-0002"}]),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
# Explicit names below model historical records referenced by legacy ledgers.
|
||||
from frappe.utils import add_days, add_to_date, flt, nowtime, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -46,7 +48,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=sn)
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -252,7 +254,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
batch_doc.db_set(
|
||||
@@ -422,7 +424,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
batch_doc.db_set(
|
||||
@@ -550,7 +552,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_no_item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no_id)
|
||||
|
||||
sn_doc.db_set(
|
||||
{
|
||||
@@ -685,7 +687,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": serial_and_batch_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -741,7 +743,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
item_row = pr.items[0]
|
||||
item_row.type_of_transaction = "Inward"
|
||||
@@ -840,35 +842,37 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
item_code = make_item(properties={"has_batch_no": 1}).name
|
||||
|
||||
batch_id = "TEST-BATTCCH-VAL-00001"
|
||||
batch_nos = [{"batch_no": batch_id, "qty": 1}]
|
||||
batch_nos = [{"batch_number": batch_id, "qty": 1}]
|
||||
|
||||
make_batch_nos(item_code, batch_nos)
|
||||
self.assertTrue(frappe.db.exists("Batch", batch_id))
|
||||
use_batchwise_valuation = frappe.db.get_value("Batch", batch_id, "use_batchwise_valuation")
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
|
||||
use_batchwise_valuation = frappe.db.get_value(
|
||||
"Batch", {"item": item_code, "batch_id": batch_id}, "use_batchwise_valuation"
|
||||
)
|
||||
self.assertEqual(use_batchwise_valuation, 1)
|
||||
|
||||
batch_id = "TEST-BATTCCH-VAL-00001"
|
||||
batch_nos = [{"batch_no": batch_id, "qty": 1}]
|
||||
batch_nos = [{"batch_number": batch_id, "qty": 1}]
|
||||
|
||||
# Shouldn't throw duplicate entry error
|
||||
make_batch_nos(item_code, batch_nos)
|
||||
self.assertTrue(frappe.db.exists("Batch", batch_id))
|
||||
self.assertTrue(frappe.db.exists("Batch", {"item": item_code, "batch_id": batch_id}))
|
||||
|
||||
def test_serial_no_duplicate_entry(self):
|
||||
item_code = make_item(properties={"has_serial_no": 1}).name
|
||||
|
||||
serial_no_id = "TEST-SNID-VAL-00001"
|
||||
serial_nos = [{"serial_no": serial_no_id, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
|
||||
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
|
||||
|
||||
serial_no_id = "TEST-SNID-VAL-00001"
|
||||
serial_nos = [{"batch_no": serial_no_id, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no_id, "qty": 1}]
|
||||
|
||||
# Shouldn't throw duplicate entry error
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
self.assertTrue(frappe.db.exists("Serial No", serial_no_id))
|
||||
self.assertTrue(frappe.db.exists("Serial No", {"item_code": item_code, "serial_no": serial_no_id}))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1}
|
||||
@@ -879,10 +883,10 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
item_code = make_item(properties={"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
|
||||
serial_no = f"{item_code}-001"
|
||||
serial_nos = [{"serial_no": serial_no, "qty": 1}]
|
||||
serial_nos = [{"serial_number": serial_no, "qty": 1}]
|
||||
make_serial_nos(item_code, serial_nos)
|
||||
|
||||
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_no])
|
||||
pr1 = make_purchase_receipt(item=item_code, qty=1, rate=500, serial_no=[serial_nos[0]["serial_no"]])
|
||||
pr2 = make_purchase_receipt(item=item_code, qty=1, rate=500, do_not_save=True)
|
||||
|
||||
pr1.reload()
|
||||
@@ -906,7 +910,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item_code": sn_item,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=serial_no)
|
||||
serial_nos.append(serial_no)
|
||||
|
||||
frappe.flags.ignore_serial_batch_bundle_validation = True
|
||||
@@ -1069,7 +1073,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name="ACSBBO-TACSB-00001")
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
@@ -1130,7 +1134,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": item_code,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name="TST-ACSBBO-TACSB-00001")
|
||||
|
||||
bundle_doc = make_serial_batch_bundle(
|
||||
{
|
||||
@@ -1233,7 +1237,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
batch_doc.db_set(
|
||||
{
|
||||
@@ -1322,7 +1326,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_id)
|
||||
|
||||
batch_doc.db_set(
|
||||
{
|
||||
@@ -1391,7 +1395,9 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
|
||||
make_item(item_code, props)
|
||||
if batch_no and not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
set_name=batch_no
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
|
||||
@@ -1475,7 +1481,7 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{"doctype": "Batch", "batch_id": batch_no, "item": item_code, "company": "_Test Company"}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(ignore_permissions=True, set_name=batch_no)
|
||||
|
||||
def _allow_negative_stock_temporarily(self):
|
||||
for field in ("allow_negative_stock", "allow_negative_stock_for_batch"):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "field:serial_no",
|
||||
"autoname": "hash",
|
||||
"creation": "2013-05-16 10:59:15",
|
||||
"description": "Distinct unit of an Item",
|
||||
"doctype": "DocType",
|
||||
@@ -58,12 +58,14 @@
|
||||
{
|
||||
"fieldname": "serial_no",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Serial No",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "serial_no",
|
||||
"oldfieldtype": "Data",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
"search_index": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "item_code",
|
||||
@@ -312,11 +314,11 @@
|
||||
"icon": "fa fa-barcode",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2026-08-21 23:11:48.936205",
|
||||
"modified": "2026-09-09 10:32:46.103583",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Serial No",
|
||||
"naming_rule": "By fieldname",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -367,10 +369,12 @@
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "item_code",
|
||||
"search_fields": "serial_no,item_code",
|
||||
"show_name_in_global_search": 1,
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "serial_no",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce
|
||||
from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads
|
||||
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
|
||||
class SerialNoCannotCreateDirectError(ValidationError):
|
||||
@@ -65,6 +66,7 @@ class SerialNo(StockController):
|
||||
self.via_stock_ledger = False
|
||||
|
||||
def validate(self):
|
||||
SerialBatchIdentity("Serial No").validate(self)
|
||||
if self.get("__islocal") and self.warehouse and not self.via_stock_ledger:
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -110,7 +112,7 @@ class SerialNo(StockController):
|
||||
# Find the exact match
|
||||
sle_exists = False
|
||||
for d in sl_entries:
|
||||
if self.name.upper() in get_serial_nos(d.serial_no):
|
||||
if self.name in get_serial_nos(d.serial_no):
|
||||
sle_exists = True
|
||||
break
|
||||
|
||||
@@ -120,23 +122,27 @@ class SerialNo(StockController):
|
||||
)
|
||||
|
||||
|
||||
def get_available_serial_nos(serial_no_series, qty) -> list[str]:
|
||||
def get_available_serial_nos(serial_no_series, qty, item_code=None) -> list[str]:
|
||||
serial_nos = []
|
||||
for _i in range(cint(qty)):
|
||||
serial_nos.append(get_new_serial_number(serial_no_series))
|
||||
serial_nos.append(get_new_serial_number(serial_no_series, item_code))
|
||||
|
||||
return serial_nos
|
||||
|
||||
|
||||
def get_new_serial_number(series):
|
||||
def get_new_serial_number(series, item_code=None):
|
||||
sr_no = make_autoname(series, "Serial No")
|
||||
if frappe.db.exists("Serial No", sr_no):
|
||||
sr_no = get_new_serial_number(series)
|
||||
if SerialBatchIdentity("Serial No").exists(sr_no, item_code):
|
||||
sr_no = get_new_serial_number(series, item_code)
|
||||
return sr_no
|
||||
|
||||
|
||||
def get_items_html(serial_nos, item_code):
|
||||
body = ", ".join(serial_nos)
|
||||
from frappe.utils import escape_html
|
||||
|
||||
labels = SerialBatchIdentity("Serial No").labels(serial_nos)
|
||||
body = ", ".join(escape_html(labels.get(name, name)) for name in serial_nos)
|
||||
item_code = escape_html(item_code)
|
||||
return f"""<details><summary>
|
||||
<b>{item_code}:</b> {len(serial_nos)} Serial Numbers <span class="caret"></span>
|
||||
</summary>
|
||||
@@ -306,4 +312,5 @@ def get_serial_nos_for_outward(kwargs):
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
SerialBatchIdentity("Serial No").sync_constraint()
|
||||
frappe.db.add_index("Serial No", ["item_code", "warehouse"])
|
||||
|
||||
@@ -202,7 +202,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"serial_no": serial_no,
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name=serial_no)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, serial_no=[serial_nos[0]]
|
||||
@@ -350,7 +350,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCWARREXP" + random_string(6))
|
||||
frappe.db.set_value("Serial No", expired_sr.name, "maintenance_status", "Under Warranty")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", expired_sr.name, "maintenance_status"), "Under Warranty"
|
||||
@@ -365,7 +365,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCWARRACT" + random_string(6))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Serial No", active_sr.name, "maintenance_status"), "Under Warranty"
|
||||
)
|
||||
@@ -402,7 +402,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"amc_expiry_date": past_date,
|
||||
"warranty_expiry_date": future_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCEXCL" + random_string(6))
|
||||
frappe.db.set_value("Serial No", excluded_sr.name, "maintenance_status", "Out of AMC")
|
||||
|
||||
# Negative control: same lapsed amc date, but a status NOT in the excluded list, so it
|
||||
@@ -416,7 +416,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCCAND" + random_string(6))
|
||||
frappe.db.set_value("Serial No", candidate_sr.name, "maintenance_status", "Under AMC")
|
||||
|
||||
update_maintenance_status()
|
||||
@@ -446,7 +446,7 @@ class TestSerialNo(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"amc_expiry_date": past_date,
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="_TCAMCNULL" + random_string(6))
|
||||
# Force a NULL maintenance_status while a lapsed amc date keeps the row in or_filters.
|
||||
frappe.db.set_value("Serial No", null_sr.name, "maintenance_status", None)
|
||||
self.assertIsNone(frappe.db.get_value("Serial No", null_sr.name, "maintenance_status"))
|
||||
|
||||
@@ -26,10 +26,17 @@ class StockEntryGLComposer(BaseStockGLComposer):
|
||||
doc = self.doc
|
||||
gl_entries = super().compose(inventory_account_map)
|
||||
|
||||
incoming_items, basis, divide_based_on = doc.get_additional_cost_allocation()
|
||||
if doc.purpose in ("Repack", "Manufacture"):
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
|
||||
else:
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
|
||||
|
||||
divide_based_on = total_basic_amount
|
||||
if doc.get("additional_costs") and not total_basic_amount:
|
||||
divide_based_on = sum(item.qty for item in doc.get("items"))
|
||||
|
||||
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
|
||||
incoming_items, basis, divide_based_on
|
||||
total_basic_amount, divide_based_on
|
||||
)
|
||||
if item_account_wise_additional_cost:
|
||||
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
|
||||
@@ -176,20 +183,24 @@ class StockEntryGLComposer(BaseStockGLComposer):
|
||||
)
|
||||
|
||||
def _build_additional_cost_per_item_account(
|
||||
self, incoming_items: list, basis: str, divide_based_on: float
|
||||
self, total_basic_amount: float, divide_based_on: float
|
||||
) -> dict:
|
||||
doc = self.doc
|
||||
item_account_wise_additional_cost = {}
|
||||
if not divide_based_on:
|
||||
return item_account_wise_additional_cost
|
||||
|
||||
for t in self.doc.get("additional_costs"):
|
||||
for d in incoming_items:
|
||||
for t in doc.get("additional_costs"):
|
||||
for d in doc.get("items"):
|
||||
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
||||
continue
|
||||
elif not d.t_warehouse:
|
||||
continue
|
||||
|
||||
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
|
||||
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
|
||||
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
|
||||
)
|
||||
|
||||
multiply_based_on = flt(d.get(basis))
|
||||
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
|
||||
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
|
||||
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
|
||||
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
|
||||
|
||||
@@ -920,28 +920,22 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs"))
|
||||
|
||||
incoming_items, basis, total_basis = self.get_additional_cost_allocation()
|
||||
if self.purpose in ("Repack", "Manufacture"):
|
||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
|
||||
else:
|
||||
incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
|
||||
|
||||
for d in self.get("items"):
|
||||
d.additional_cost = 0
|
||||
|
||||
if not total_basis:
|
||||
if not incoming_items_cost:
|
||||
return
|
||||
|
||||
for d in incoming_items:
|
||||
d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs
|
||||
|
||||
def get_additional_cost_allocation(self):
|
||||
if self.purpose in ("Repack", "Manufacture"):
|
||||
incoming_items = [d for d in self.get("items") if d.is_finished_item]
|
||||
else:
|
||||
incoming_items = [d for d in self.get("items") if d.t_warehouse]
|
||||
|
||||
total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items)
|
||||
if total_basic_amount:
|
||||
return incoming_items, "basic_amount", total_basic_amount
|
||||
|
||||
return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items)
|
||||
for d in self.get("items"):
|
||||
if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
||||
d.additional_cost = 0
|
||||
continue
|
||||
elif not d.t_warehouse:
|
||||
d.additional_cost = 0
|
||||
continue
|
||||
d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs
|
||||
|
||||
def update_valuation_rate(self, reset_outgoing_rate=True):
|
||||
for d in self.get("items"):
|
||||
|
||||
@@ -910,7 +910,7 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
doc.serial_no = serial_no
|
||||
doc.item_code = "_Test Serialized Item"
|
||||
doc.company = "_Test Company"
|
||||
doc.insert(ignore_permissions=True)
|
||||
doc.insert(ignore_permissions=True, set_name=serial_no)
|
||||
|
||||
se = frappe.copy_doc(self.globalTestRecords["Stock Entry"][0])
|
||||
se.get("items")[0].item_code = "_Test Serialized Item"
|
||||
@@ -2233,12 +2233,10 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items])
|
||||
|
||||
self.check_gl_entries(
|
||||
"Stock Entry",
|
||||
se.name,
|
||||
sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
||||
sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]),
|
||||
)
|
||||
|
||||
def test_conversion_factor_change(self):
|
||||
@@ -2284,184 +2282,6 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_distribution_manufacture_zero_valued_items(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
purpose="Manufacture",
|
||||
additional_costs=[frappe._dict(base_amount=100)],
|
||||
items=[
|
||||
frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10),
|
||||
frappe._dict(
|
||||
item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1
|
||||
),
|
||||
frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"),
|
||||
],
|
||||
)
|
||||
|
||||
se.distribute_additional_costs()
|
||||
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([0.0, 100.0, 0.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_distribution_zero_valued_items(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
purpose="Material Receipt",
|
||||
additional_costs=[frappe._dict(base_amount=100)],
|
||||
items=[
|
||||
frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"),
|
||||
frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"),
|
||||
],
|
||||
)
|
||||
|
||||
se.distribute_additional_costs()
|
||||
|
||||
distributed_costs = [d.additional_cost for d in se.items]
|
||||
self.assertEqual([40.0, 60.0], distributed_costs)
|
||||
|
||||
def test_additional_cost_gl_for_zero_valued_manufacture(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name
|
||||
fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name
|
||||
|
||||
receipt = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Material Receipt",
|
||||
"stock_entry_type": "Material Receipt",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": rm,
|
||||
"qty": 5,
|
||||
"basic_rate": 0,
|
||||
"uom": "Nos",
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
receipt.insert()
|
||||
receipt.submit()
|
||||
|
||||
se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Manufacture",
|
||||
"stock_entry_type": "Manufacture",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": rm,
|
||||
"qty": 5,
|
||||
"uom": "Nos",
|
||||
"s_warehouse": "Stores - TCP1",
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
{
|
||||
"item_code": fg,
|
||||
"qty": 5,
|
||||
"uom": "Nos",
|
||||
"t_warehouse": "Finished Goods - TCP1",
|
||||
"is_finished_item": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
],
|
||||
"additional_costs": [
|
||||
{
|
||||
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||
"amount": 500,
|
||||
"description": "freight",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual(500.0, se.items[1].additional_cost)
|
||||
self.check_gl_entries(
|
||||
"Stock Entry",
|
||||
se.name,
|
||||
sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]),
|
||||
)
|
||||
|
||||
def test_additional_cost_gl_matches_valuation_split(self):
|
||||
company = "_Test Company with perpetual inventory"
|
||||
cost_center = "_Test Additional Cost CC - TCP1"
|
||||
if not frappe.db.exists("Cost Center", cost_center):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Additional Cost CC",
|
||||
"company": company,
|
||||
"is_group": 0,
|
||||
"parent_cost_center": "_Test Company with perpetual inventory - TCP1",
|
||||
}
|
||||
).insert()
|
||||
|
||||
uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}]
|
||||
item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name
|
||||
uoms[1]["conversion_factor"] = 3
|
||||
item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name
|
||||
|
||||
se = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Entry",
|
||||
"purpose": "Material Receipt",
|
||||
"stock_entry_type": "Material Receipt",
|
||||
"posting_date": nowdate(),
|
||||
"company": company,
|
||||
"items": [
|
||||
{
|
||||
"item_code": item_a,
|
||||
"qty": 1,
|
||||
"basic_rate": 0,
|
||||
"uom": "Box",
|
||||
"conversion_factor": 2,
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": "Main - TCP1",
|
||||
},
|
||||
{
|
||||
"item_code": item_b,
|
||||
"qty": 1,
|
||||
"basic_rate": 0,
|
||||
"uom": "Box",
|
||||
"conversion_factor": 3,
|
||||
"t_warehouse": "Stores - TCP1",
|
||||
"allow_zero_valuation_rate": 1,
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
],
|
||||
"additional_costs": [
|
||||
{
|
||||
"expense_account": "Miscellaneous Expenses - TCP1",
|
||||
"amount": 100,
|
||||
"description": "misc",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items])
|
||||
|
||||
expense_by_cost_center = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"},
|
||||
fields=["cost_center", "credit"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"Main - TCP1": 40.0, cost_center: 60.0},
|
||||
{d.cost_center: d.credit for d in expense_by_cost_center},
|
||||
)
|
||||
|
||||
def test_additional_cost_distribution_non_manufacture(self):
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
@@ -3011,6 +2831,9 @@ class TestStockEntry(ERPNextTestSuite):
|
||||
"Test Use Serial and Batch Item SN Item - SN 001",
|
||||
"Test Use Serial and Batch Item SN Item - SN 002",
|
||||
]
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
serial_nos = SerialBatchIdentity("Serial No").resolve(item.name, serial_nos, create=True)
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
flt,
|
||||
get_link_to_form,
|
||||
@@ -13,10 +12,11 @@ from frappe.utils import (
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
||||
OpeningEntryAccountError,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
class StockEntryDetail(Document):
|
||||
class StockEntryDetail(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import date
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.role.role import get_users
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Concat_ws, Max, Sum
|
||||
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
|
||||
|
||||
@@ -17,6 +16,7 @@ from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference, format_serial_batch_numbers
|
||||
|
||||
|
||||
class StockFreezeError(frappe.ValidationError):
|
||||
@@ -38,7 +38,7 @@ class SerialNoInventoryDimensionError(frappe.ValidationError):
|
||||
exclude_from_linked_with = True
|
||||
|
||||
|
||||
class StockLedgerEntry(Document):
|
||||
class StockLedgerEntry(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
@@ -210,7 +210,8 @@ class StockLedgerEntry(Document):
|
||||
if mismatches:
|
||||
frappe.throw(
|
||||
_("Serial No {0} is not available in the selected inventory dimensions: {1}").format(
|
||||
frappe.bold(serial_no), frappe.bold(", ".join(mismatches))
|
||||
frappe.bold(format_serial_batch_numbers("Serial No", [serial_no])),
|
||||
frappe.bold(", ".join(mismatches)),
|
||||
),
|
||||
title=_("Incorrect Inventory Dimension"),
|
||||
exc=SerialNoInventoryDimensionError,
|
||||
@@ -383,7 +384,9 @@ class StockLedgerEntry(Document):
|
||||
if expiry_date:
|
||||
if getdate(self.posting_date) > getdate(expiry_date):
|
||||
frappe.throw(
|
||||
_("Batch {0} of Item {1} has expired.").format(self.batch_no, self.item_code)
|
||||
_("Batch {0} of Item {1} has expired.").format(
|
||||
format_serial_batch_numbers("Batch", [self.batch_no]), self.item_code
|
||||
)
|
||||
)
|
||||
|
||||
def validate_and_set_fiscal_year(self):
|
||||
|
||||
@@ -24,6 +24,7 @@ from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import BackDate
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
from erpnext.stock.tests.test_utils import StockTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -59,11 +60,9 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
item = "_Test Serialized Item"
|
||||
serial = "_Test SN Tie 9"
|
||||
company_a, company_b = "_Test Company", "_Test Company 1"
|
||||
if frappe.db.exists("Serial No", serial):
|
||||
frappe.delete_doc("Serial No", serial, force=1)
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial, "item_code": item, "company": company_b}
|
||||
).insert(ignore_permissions=True)
|
||||
serial = SerialBatchIdentity("Serial No").resolve(
|
||||
item, [serial], create=True, defaults={"company": company_b}
|
||||
)[0]
|
||||
|
||||
def mk_sle(name, rate):
|
||||
if frappe.db.exists("Stock Ledger Entry", name):
|
||||
@@ -1691,7 +1690,8 @@ def setup_item_valuation_test(
|
||||
batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list]
|
||||
|
||||
for i, batch_id in enumerate(batches):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
batches[i] = frappe.db.get_value("Batch", {"item": item.item_code, "batch_id": batch_id})
|
||||
if not batches[i]:
|
||||
ubw = use_batchwise_valuation
|
||||
if isinstance(use_batchwise_valuation, list | tuple):
|
||||
ubw = use_batchwise_valuation[i]
|
||||
@@ -1702,6 +1702,7 @@ def setup_item_valuation_test(
|
||||
).insert()
|
||||
batch.use_batchwise_valuation = ubw
|
||||
batch.db_update()
|
||||
batches[i] = batch.name
|
||||
|
||||
return item.item_code, warehouses, batches
|
||||
|
||||
|
||||
@@ -549,7 +549,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_valid_batch(self):
|
||||
create_batch_item_with_batch("Testing Batch Item 1", "001")
|
||||
create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
batch_no = create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
@@ -559,7 +559,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"voucher_type": "Stock Reconciliation",
|
||||
"entries": [
|
||||
{
|
||||
"batch_no": "002",
|
||||
"batch_no": batch_no,
|
||||
"qty": 1,
|
||||
"incoming_rate": 100,
|
||||
}
|
||||
@@ -567,7 +567,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not belong to Item", doc.save)
|
||||
|
||||
def test_serial_no_cancellation(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
@@ -615,7 +615,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"serial_no": "SR-CREATED-SR-NO",
|
||||
"company": "_Test Company",
|
||||
}
|
||||
).insert()
|
||||
).insert(set_name="SR-CREATED-SR-NO")
|
||||
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=item.name,
|
||||
@@ -1351,7 +1351,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
"item": batch_item_code,
|
||||
"use_batchwise_valuation": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert(set_name=batch_id, ignore_permissions=True)
|
||||
|
||||
self.assertTrue(batch_doc.use_batchwise_valuation)
|
||||
|
||||
@@ -2219,17 +2219,15 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
|
||||
def create_batch_item_with_batch(item_name, batch_id):
|
||||
from erpnext.stock.serial_batch_identity import SerialBatchIdentity
|
||||
|
||||
batch_item_doc = create_item(item_name, is_stock_item=1)
|
||||
if not batch_item_doc.has_batch_no:
|
||||
batch_item_doc.has_batch_no = 1
|
||||
batch_item_doc.create_new_batch = 1
|
||||
batch_item_doc.save(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
b = frappe.new_doc("Batch")
|
||||
b.item = item_name
|
||||
b.batch_id = batch_id
|
||||
b.save()
|
||||
return SerialBatchIdentity("Batch").resolve(item_name, [batch_id], create=True)[0]
|
||||
|
||||
|
||||
def insert_existing_sle(warehouse, item_code="_Test Item"):
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.stock.serial_batch_display import SerialBatchReference
|
||||
|
||||
|
||||
class StockReconciliationItem(Document):
|
||||
class StockReconciliationItem(SerialBatchReference):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
||||
@@ -228,11 +228,7 @@ def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None:
|
||||
if not source_fields or not doc or ctx.get("is_return") or not maintain_same_rate_enabled(ctx):
|
||||
return None
|
||||
|
||||
row = (
|
||||
next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None)
|
||||
if ctx.child_docname
|
||||
else ctx
|
||||
)
|
||||
row = next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt, get_datetime, today
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
data = get_data(filters)
|
||||
|
||||
@@ -11,9 +11,11 @@ from erpnext.stock.report.stock_ledger.stock_ledger import (
|
||||
get_opening_balance,
|
||||
get_stock_ledger_entries,
|
||||
)
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
from erpnext.stock.utils import is_reposting_item_valuation_in_progress
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
is_reposting_item_valuation_in_progress()
|
||||
columns = get_columns(filters)
|
||||
|
||||
@@ -6,7 +6,10 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Date
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
validate_filters(filters)
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ class TestBatchItemExpiryStatus(ERPNextTestSuite):
|
||||
|
||||
data = self.run_report(item=item)
|
||||
|
||||
# Columns: [item, item_name, batch, stock_uom, quantity, expires_on, expiry_in_days]
|
||||
row = next((r for r in data if r[2] == batch_no), None)
|
||||
# Physical batch numbers are displayed; the final hidden column retains the ID.
|
||||
row = next((r for r in data if r[-1] == batch_no), None)
|
||||
self.assertIsNotNone(row, f"Batch {batch_no} not found in report for item {item}")
|
||||
|
||||
self.assertEqual(row[0], item)
|
||||
self.assertEqual(row[2], batch_no)
|
||||
self.assertEqual(row[2], frappe.db.get_value("Batch", batch_no, "batch_id"))
|
||||
self.assertEqual(row[-1], batch_no)
|
||||
self.assertEqual(row[4], 10)
|
||||
# expiry = batch manufacturing_date + 30 day shelf life; matches the Batch record
|
||||
batch_expiry = frappe.db.get_value("Batch", batch_no, "expiry_date")
|
||||
|
||||
@@ -3,7 +3,10 @@ from collections import defaultdict
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
filters = frappe._dict(filters or {})
|
||||
return get_columns(), get_data(filters)
|
||||
|
||||
@@ -11,10 +11,12 @@ from erpnext.accounts.report.utils import validate_mandatory_date_range
|
||||
from erpnext.deprecation_dumpster import deprecated
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
SLE_COUNT_LIMIT = 100_000
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
@@ -8,6 +8,8 @@ from frappe import _
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
from erpnext.stock.serial_batch_display import with_serial_batch_numbers
|
||||
|
||||
SLE_FIELDS = (
|
||||
"name",
|
||||
"item_code",
|
||||
@@ -26,6 +28,7 @@ SLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
@with_serial_batch_numbers
|
||||
def execute(filters=None):
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user