Compare commits

..

20 Commits

Author SHA1 Message Date
Mihir Kandoi
653ef6eff2 fix: batch and serial nos set only once 2026-09-09 10:33:55 +05:30
Mihir Kandoi
acbefcd603 fix(stock): deduplicate scans after serial reselection 2026-09-09 10:01:46 +05:30
Mihir Kandoi
445a30ba60 fix(stock): show physical serial and batch numbers in errors 2026-09-09 09:56:45 +05:30
Mihir Kandoi
fca005c935 fix(stock): allow reselecting pending serial and batch entries 2026-09-09 09:45:19 +05:30
Mihir Kandoi
e51c01628d style(stock): declare the Data Import class extension with the others 2026-09-09 08:33:58 +05:30
Mihir Kandoi
766e51ae58 refactor(stock): drop the forked data import runner
The Data Import override copied the core background runner to swap one
line. Core now calls get_importer, so the subclass is used by the real
import and the copy can go. This also drops the enqueue_after_commit
that the copy had added on top of core.

Requires frappe#42648.
2026-09-09 08:33:23 +05:30
Mihir Kandoi
3acaa55db9 test(stock): verify merged print column escaping 2026-09-09 08:19:18 +05:30
Mihir Kandoi
dbfec6e9fb fix(stock): format serial numbers in builder prints 2026-09-09 08:13:04 +05:30
Mihir Kandoi
1426a098f1 refactor(stock): detect serial and batch input from field metadata 2026-09-09 07:42:46 +05:30
Mihir Kandoi
92b6d708d8 fix(stock): reuse transaction serial and batch fields 2026-09-09 07:11:49 +05:30
Mihir Kandoi
fa244a3615 perf(stock): batch serial and batch number resolution 2026-09-08 22:52:56 +05:30
Mihir Kandoi
cdb12ecf9d test(stock): close CSV fixture before importing 2026-09-08 22:29:28 +05:30
Mihir Kandoi
86489d6905 test(stock): verify FIFO normalization preserves references 2026-09-08 22:29:21 +05:30
Mihir Kandoi
d81fe03776 fix(stock): apply serial and batch identity review fixes 2026-09-08 22:20:12 +05:30
Mihir Kandoi
f80cac927d test: remove standalone serial and barcode tests 2026-09-08 12:54:32 +05:30
Mihir Kandoi
687c7d55ba chore: remove serial and batch documentation assets 2026-09-08 12:52:55 +05:30
Mihir Kandoi
6f2cf3bf91 fix(stock): preserve serial selection and use internal IDs in tests 2026-09-08 12:49:02 +05:30
Mihir Kandoi
48818c963a fix(stock): resolve number aliases and detect migration conflicts 2026-09-08 12:48:45 +05:30
Mihir Kandoi
c67a57d9bd fix(stock): enforce serial permissions and case-insensitive numbers 2026-09-08 12:20:58 +05:30
Mihir Kandoi
ca880f6be7 feat(stock)!: separate serial and batch numbers from document IDs 2026-09-08 12:00:06 +05:30
138 changed files with 3610 additions and 2524 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,7 +114,6 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
"company": filters.company,
},
pluck="name",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View 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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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];
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,10 +58,9 @@
},
{
"fieldname": "total_holidays",
"fieldtype": "Float",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Total Holidays",
"precision": "1",
"read_only": 1
},
{

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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