Merge pull request #54101 from frappe/version-15-hotfix

This commit is contained in:
diptanilsaha
2026-04-07 22:19:03 +05:30
committed by GitHub
62 changed files with 1986 additions and 642 deletions

View File

@@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Poore Simon's",
}
@@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"):
frappe.get_doc(
{
"doctype": "Customer",
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"customer_name": "Fayva",
}

View File

@@ -209,7 +209,7 @@ def make_customer(customer=None):
{
"doctype": "Customer",
"customer_name": customer_name,
"customer_group": "All Customer Groups",
"customer_group": "Individual",
"customer_type": "Company",
"territory": "All Territories",
}

View File

@@ -839,7 +839,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (!frm.doc.received_amount) {
if (frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
@@ -860,7 +860,7 @@ frappe.ui.form.on("Payment Entry", {
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
if (!frm.doc.paid_amount) {
if (frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) {

View File

@@ -2043,6 +2043,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.customer_name = name
customer.default_currency = currency
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
customer = customer.name
return customer

View File

@@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
self.customer = customer.name

View File

@@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency

View File

@@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", {
selling: function (frm) {
frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("buying", !frm.doc.selling);
},
buying: function (frm) {
frm.trigger("set_options_for_applicable_for");
frm.toggle_enable("selling", !frm.doc.buying);
},
set_options_for_applicable_for: function (frm) {

View File

@@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController):
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
for item in self.get("items"):
if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
if item.item_code:
@@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController):
)
# check if the exchange rate has changed
if item.get("purchase_receipt") and self.auto_accounting_for_stock:
if (
not adjust_incoming_rate
and item.get("purchase_receipt")
and self.auto_accounting_for_stock
):
if (
exchange_rate_map[item.purchase_receipt]
and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
@@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController):
item=item,
)
)
if (
self.auto_accounting_for_stock
and self.is_opening == "No"

View File

@@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
@@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice,

View File

@@ -777,8 +777,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.total_billing_amount > 0",
"depends_on": "eval:!doc.is_return",
"collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "time_sheet_list",
"fieldtype": "Section Break",
"hide_border": 1,
@@ -792,7 +791,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Time Sheets",
"no_copy": 1,
"options": "Sales Invoice Timesheet",
"print_hide": 1
},
@@ -2112,7 +2110,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)",
"depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0",
"fieldname": "section_break_104",
"fieldtype": "Section Break"
},
@@ -2200,7 +2198,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-02-05 20:43:44.732805",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -323,10 +323,22 @@ class SalesInvoice(SellingController):
)
self.set_against_income_account()
self.validate_time_sheets_are_submitted()
if self.is_return and not self.return_against and self.timesheets:
frappe.throw(_("Direct return is not allowed for Timesheet."))
if not self.is_return:
self.validate_time_sheets_are_submitted()
self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
if self.is_return:
self.timesheets = []
if self.is_return and self.return_against:
for row in self.timesheets:
if row.billing_hours:
row.billing_hours = -abs(row.billing_hours)
if row.billing_amount:
row.billing_amount = -abs(row.billing_amount)
self.update_packing_list()
self.set_billing_hours_and_amount()
self.update_timesheet_billing_for_project()
@@ -494,7 +506,7 @@ class SalesInvoice(SellingController):
if not cint(self.is_pos) == 1 and not self.is_return:
self.update_against_document_in_jv()
self.update_time_sheet(self.name)
self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
@@ -550,7 +562,7 @@ class SalesInvoice(SellingController):
self.check_if_consolidated_invoice()
super().before_cancel()
self.update_time_sheet(None)
self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -735,8 +747,20 @@ class SalesInvoice(SellingController):
for data in timesheet.time_logs:
if (
(self.project and args.timesheet_detail == data.name)
or (not self.project and not data.sales_invoice)
or (not sales_invoice and data.sales_invoice == self.name)
or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == self.name
and args.timesheet_detail == data.name
)
or (
self.is_return
and self.return_against
and data.sales_invoice
and data.sales_invoice == self.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
data.sales_invoice = sales_invoice
@@ -776,11 +800,25 @@ class SalesInvoice(SellingController):
payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
def validate_time_sheets_are_submitted(self):
# Note: This validation is skipped for return invoices
# to allow returns to reference already-billed timesheet details
for data in self.timesheets:
# Handle invoice duplication
if data.time_sheet and data.timesheet_detail:
if sales_invoice := frappe.db.get_value(
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
):
frappe.throw(
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
)
)
if data.time_sheet:
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
if status not in ["Submitted", "Payslip"]:
frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet))
if status not in ["Submitted", "Payslip", "Partially Billed"]:
frappe.throw(
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
)
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
@@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController):
timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self):
if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled():
if (
not self.is_return
and not self.timesheets
and self.project
and self.is_auto_fetch_timesheet_enabled()
):
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet()

View File

@@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase):
calculate_depreciation=1,
submit=1,
)
post_depreciation_entries()
post_depreciation_entries(date="2025-04-01")
si = create_sales_invoice(
item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01")

View File

@@ -52,7 +52,6 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Timesheet Detail",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
@@ -117,7 +116,7 @@
],
"istable": 1,
"links": [],
"modified": "2021-10-02 03:48:44.979777",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Timesheet",

View File

@@ -629,18 +629,21 @@ def create_parties():
customer.customer_name = "_Test Subscription Customer"
customer.default_currency = "USD"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"})
customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"):
customer = frappe.new_doc("Customer")
customer.customer_name = "Test Subscription Customer Multi Currency"
customer.default_currency = "USD"
customer.customer_group = "Individual"
customer.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer John Doe"
customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"})
customer.customer_group = "Individual"
customer.insert()

View File

@@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
"customer_group": "Individual",
}
)
.insert()
@@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
"customer_group": "Individual",
}
)
.insert()

View File

@@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase):
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.type = "Individual"
customer.customer_group = "Individual"
customer.save()
self.customer = customer.name

View File

@@ -12,6 +12,7 @@ class AccountsTestMixin:
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency
@@ -36,6 +37,7 @@ class AccountsTestMixin:
"account": default_account,
},
)
customer.customer_group = "Individual"
customer.save()
self.customer = customer_name

View File

@@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list
class PartyTestCase(FrappeTestCase):
def test_get_default_price_list_should_return_none_for_invalid_group(self):
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "test customer",
}
{"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"}
).insert(ignore_permissions=True, ignore_mandatory=True)
customer.customer_group = None
customer.save()
price_list = get_default_price_list(customer)
assert price_list is None

View File

@@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase):
# ordered qty should decrease (back to initial) on row deletion
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
def test_discount_amount_partial_purchase_receipt(self):
po = create_purchase_order(qty=4, rate=100, do_not_save=1)
po.apply_discount_on = "Grand Total"
po.discount_amount = 120
po.save()
po.submit()
self.assertEqual(po.grand_total, 280)
pr1 = make_purchase_receipt(po.name)
pr1.items[0].qty = 3
pr1.save()
pr1.submit()
self.assertEqual(pr1.discount_amount, 120)
self.assertEqual(pr1.grand_total, 180)
pr2 = make_purchase_receipt(po.name)
pr2.save()
pr2.submit()
self.assertEqual(pr2.discount_amount, 0)
self.assertEqual(pr2.grand_total, 100)
def test_update_child_perm(self):
po = create_purchase_order(item_code="_Test Item", qty=4)

View File

@@ -175,6 +175,15 @@ def create_supplier(**args):
if not args.without_supplier_group:
doc.supplier_group = args.supplier_group or "Services"
if args.get("party_account"):
doc.append(
"accounts",
{
"company": frappe.db.get_value("Account", args.get("party_account"), "company"),
"account": args.get("party_account"),
},
)
doc.insert()
return doc

View File

@@ -364,7 +364,7 @@ class BuyingController(SubcontractingController):
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
)
net_rate = item.base_net_amount
net_rate = item.qty * item.base_net_rate
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate

View File

@@ -183,6 +183,9 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
for item in self.doc.items:
self.doc.round_floats_in(item)
@@ -238,7 +241,13 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
qty = (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice
and self.doc.doctype == "Purchase Receipt"
else item.qty
)
item.amount = flt(item.rate * qty, item.precision("amount"))
item.net_amount = item.amount
@@ -370,9 +379,16 @@ class calculate_taxes_and_totals:
self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
for item in self._items:
self.doc.total += item.amount
self.doc.total_qty += item.qty
self.doc.total_qty += (
(item.qty + item.rejected_qty)
if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt"
else item.qty
)
self.doc.base_total += item.base_amount
self.doc.net_total += item.net_amount
self.doc.base_net_total += item.base_net_amount
@@ -724,7 +740,8 @@ class calculate_taxes_and_totals:
discount_amount += total_return_discount
# validate that discount amount cannot exceed the total before discount
if (
# only during save (i.e. when `_action` is set)
if self.doc.get("_action") and (
(grand_total >= 0 and discount_amount > grand_total)
or (grand_total < 0 and discount_amount < grand_total) # returns
):

View File

@@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
customer.customer_group = "Individual"
if currency:
customer.default_currency = currency

View File

@@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase):
{
"doctype": "Customer",
"customer_name": uuid4(),
"customer_group": "All Customer Groups",
"customer_group": "Individual",
}
).insert()
self.supplier = frappe.get_doc(

View File

@@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase):
self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email)
# create new customer and create new contact against 'new.opportunity@example.com'
customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True)
customer = make_customer(opp_doc.party_name)
customer.customer_group = "Individual"
customer.insert(ignore_permissions=True)
contact = frappe.get_doc(
{
"doctype": "Contact",

View File

@@ -196,6 +196,7 @@ def create_customer():
if not doc:
doc = frappe.new_doc("Customer")
doc.customer_name = "_Test NC"
doc.customer_group = "Individual"
doc.insert()

View File

@@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", {
reqd: 1,
default: 1,
onchange: () => {
if (!cur_dialog) return;
const { quantity, items: rm } = frm.doc;
const variant_items_map = rm.reduce((acc, item) => {
acc[item.item_code] = item.qty;

View File

@@ -15,6 +15,7 @@
"bom_section",
"update_bom_costs_automatically",
"column_break_lhyt",
"allow_editing_of_items_and_quantities_in_work_order",
"section_break_6",
"default_wip_warehouse",
"default_fg_warehouse",
@@ -243,13 +244,20 @@
"fieldname": "enforce_time_logs",
"fieldtype": "Check",
"label": "Enforce Time Logs"
},
{
"default": "0",
"description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.",
"fieldname": "allow_editing_of_items_and_quantities_in_work_order",
"fieldtype": "Check",
"label": "Allow Editing of Items and Quantities in Work Order"
}
],
"icon": "icon-wrench",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-16 11:23:16.916512",
"modified": "2025-11-07 14:52:56.241459",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Manufacturing Settings",

View File

@@ -18,6 +18,7 @@ class ManufacturingSettings(Document):
from frappe.types import DF
add_corrective_operation_cost_in_finished_good_valuation: DF.Check
allow_editing_of_items_and_quantities_in_work_order: DF.Check
allow_overtime: DF.Check
allow_production_on_holidays: DF.Check
backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"]

View File

@@ -4,7 +4,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings, timeout
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
@@ -2395,7 +2395,7 @@ class TestWorkOrder(FrappeTestCase):
stock_entry.submit()
def test_disassembly_order_with_qty_behavior(self):
def test_disassembly_order_with_qty_from_wo_behavior(self):
# Create raw material and FG item
raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name
@@ -2435,27 +2435,9 @@ class TestWorkOrder(FrappeTestCase):
se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_for_manufacture.submit()
# Simulate a disassembly stock entry
# Disassembly via WO required_items path (no source_stock_entry)
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.append(
"items",
{
"item_code": fg_item,
"qty": disassemble_qty,
"s_warehouse": wo.fg_warehouse,
},
)
for bom_item in bom.items:
stock_entry.append(
"items",
{
"item_code": bom_item.item_code,
"qty": (bom_item.qty / bom.quantity) * disassemble_qty,
"t_warehouse": wo.source_warehouse,
},
)
wo.reload()
stock_entry.save()
@@ -2470,7 +2452,7 @@ class TestWorkOrder(FrappeTestCase):
f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}",
)
# Assert raw materials
# Assert raw materials - qty scaled from WO required_items
for item in stock_entry.items:
if item.item_code == fg_item:
continue
@@ -2494,10 +2476,35 @@ class TestWorkOrder(FrappeTestCase):
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
)
# Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path
# (first disassembly auto-set source_stock_entry since there's only one manufacture entry)
disassemble_qty_2 = 2
stock_entry_2 = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name
)
)
stock_entry_2.save()
stock_entry_2.submit()
# All rows must trace back to se_for_manufacture
for item in stock_entry_2.items:
self.assertEqual(item.against_stock_entry, se_for_manufacture.name)
self.assertTrue(item.ste_detail)
# RM qty scaled from the manufacture SE rows
rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None)
expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2
self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3)
wo.reload()
self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2)
def test_disassembly_with_multiple_manufacture_entries(self):
"""
Test that disassembly does not create duplicate items when manufacturing
is done in multiple batches (multiple manufacture stock entries).
is done in multiple batches (multiple manufacture stock entries), including
secondary/scrap items.
Scenario:
1. Create Work Order for 10 units
@@ -2506,11 +2513,17 @@ class TestWorkOrder(FrappeTestCase):
4. Create Disassembly for 4 units
5. Verify no duplicate items in the disassembly stock entry
"""
# Create RM and FG item
# Create RM, scrap and FG item
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
bom = make_bom(
item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True
)
# add scrap item
bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10})
bom.submit()
# Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
@@ -2585,7 +2598,7 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
expected_items = 3 # FG item + 2 raw materials
expected_items = 4 # FG item + 2 raw materials + 1 scrap item
self.assertEqual(
len(stock_entry.items),
expected_items,
@@ -2596,6 +2609,16 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# Scrap item: should be taken from scrap warehouse in disassembly
scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None)
self.assertIsNotNone(scrap_row)
self.assertEqual(scrap_row.is_scrap_item, 1)
self.assertTrue(scrap_row.s_warehouse)
self.assertFalse(scrap_row.t_warehouse)
self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse)
# BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40
self.assertEqual(scrap_row.qty, 40)
# RM quantities
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
@@ -2607,19 +2630,57 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Raw material {bom_item.item_code} qty mismatch",
)
# -- BOM-path disassembly (no source_stock_entry, no work_order) --
make_stock_entry_test_record(
item_code=scrap_item,
purpose="Material Receipt",
target=wo.fg_warehouse,
qty=50,
basic_rate=10,
)
bom_disassemble_qty = 2
bom_se = frappe.get_doc(
{
"doctype": "Stock Entry",
"stock_entry_type": "Disassemble",
"purpose": "Disassemble",
"from_bom": 1,
"bom_no": bom.name,
"fg_completed_qty": bom_disassemble_qty,
"from_warehouse": wo.fg_warehouse,
"to_warehouse": wo.wip_warehouse,
"company": wo.company,
"posting_date": nowdate(),
"posting_time": nowtime(),
}
)
bom_se.get_items()
bom_se.save()
bom_se.submit()
bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None)
self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly")
# v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20
self.assertEqual(
bom_scrap_row.qty,
20,
f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}",
)
def test_disassembly_with_additional_rm_not_in_bom(self):
"""
Test that disassembly correctly handles additional raw materials that were
manually added during manufacturing (not part of the BOM).
Test that SE-linked disassembly includes additional raw materials
that were manually added during manufacturing (not part of the BOM).
Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item
(not in BOM) in proportion to the manufactured qty
5. Create Disassembly for 4 units
6. Verify that the additional RM is included in disassembly with proportional qty
5. Disassemble 3 units linked to first manufacture entry
6. Verify additional RM is included with correct proportional qty from SE1
"""
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
@@ -2655,9 +2716,8 @@ class TestWorkOrder(FrappeTestCase):
se_for_material_transfer.save()
se_for_material_transfer.submit()
# First Manufacture Entry - 3 units
# First Manufacture Entry - 3 units with additional RM
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
# Additional RM
se_manufacture1.append(
"items",
{
@@ -2670,9 +2730,8 @@ class TestWorkOrder(FrappeTestCase):
se_manufacture1.save()
se_manufacture1.submit()
# Second Manufacture Entry - 7 units
# Second Manufacture Entry - 7 units with additional RM
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
# AAdditional RM
se_manufacture2.append(
"items",
{
@@ -2688,13 +2747,15 @@ class TestWorkOrder(FrappeTestCase):
wo.reload()
self.assertEqual(wo.produced_qty, 10)
# Disassembly for 4 units
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
# Disassemble 3 units linked to first manufacture entry
disassemble_qty = 3
stock_entry = frappe.get_doc(
make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name)
)
stock_entry.save()
stock_entry.submit()
# No duplicate
# No duplicates
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
@@ -2707,16 +2768,15 @@ class TestWorkOrder(FrappeTestCase):
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
# Additional RM qty
# Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM)
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
self.assertIsNotNone(
additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly",
)
# intentional full reversal as not part of BOM
# eg: dies or consumables used during manufacturing
expected_additional_rm_qty = 3 + 7
# SE1 had 3 additional RM for 3 manufactured units, disassembling all 3
expected_additional_rm_qty = 3
self.assertAlmostEqual(
additional_rm_row.qty,
expected_additional_rm_qty,
@@ -2724,7 +2784,7 @@ class TestWorkOrder(FrappeTestCase):
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
)
# RM qty
# BOM RM qty — scaled from SE1's rows
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
@@ -2740,6 +2800,7 @@ class TestWorkOrder(FrappeTestCase):
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# FG + 2 BOM RM + 1 additional RM = 4 items
expected_items = 4
self.assertEqual(
len(stock_entry.items),
@@ -2747,6 +2808,282 @@ class TestWorkOrder(FrappeTestCase):
f"Expected {expected_items} items, found {len(stock_entry.items)}",
)
# Verify traceability
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture1.name)
self.assertTrue(item.ste_detail)
def test_disassembly_auto_sets_source_stock_entry(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2)
wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started")
make_stock_entry_test_record(
item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100
)
se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty))
for item in se_transfer.items:
item.s_warehouse = wo.wip_warehouse
se_transfer.save()
se_transfer.submit()
se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty))
se_manufacture.submit()
# Disassemble without specifying source_stock_entry
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3))
stock_entry.save()
# source_stock_entry should be auto-set since only one manufacture entry
self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name)
# All items should have against_stock_entry linked
for item in stock_entry.items:
self.assertEqual(item.against_stock_entry, se_manufacture.name)
self.assertTrue(item.ste_detail)
stock_entry.submit()
def test_disassembly_batch_tracked_items(self):
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Batch RM for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-RM-.###",
},
).name
fg_item = make_item(
"Test Batch FG for Disassembly SB",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TBRD-FG-.###",
},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two distinct batches (batch_1, batch_2)
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_1 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_batch_2 = get_batch_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches")
fg_batch_1 = make_batch(frappe._dict(item=fg_item))
fg_batch_2 = make_batch(frappe._dict(item=fg_item))
# Manufacture entry 1 — 3 FG using batch_1 RM/FG
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_1
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_1
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — 3 FG using batch_2 RM/FG
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.batch_no = rm_batch_2
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.batch_no = fg_batch_2
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear)
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1)
self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2)
# RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear)
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1)
self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2)
# RM qty: 2 FG disassembled x 2 RM per FG = 4
self.assertAlmostEqual(rm_row.qty, 4.0, places=3)
def test_disassembly_serial_tracked_items(self):
from frappe.model.naming import make_autoname
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
wip_wh = "_Test Warehouse - _TC"
rm_item = make_item(
"Test Serial RM for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"},
).name
fg_item = make_item(
"Test Serial FG for Disassembly SB",
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"},
).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2)
wo = make_wo_order_test_record(
production_item=fg_item,
qty=6,
bom_no=bom.name,
skip_transfer=1,
source_warehouse=wip_wh,
status="Not Started",
)
# Two separate RM receipts → two disjoint sets of serial numbers
rm_receipt_1 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_1 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_1.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_1), 6)
rm_receipt_2 = make_stock_entry_test_record(
item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100
)
rm_serials_2 = get_serial_nos_from_bundle(
frappe.db.get_value(
"Stock Entry Detail",
{"parent": rm_receipt_2.name, "item_code": rm_item},
"serial_and_batch_bundle",
)
)
self.assertEqual(len(rm_serials_2), 6)
self.assertFalse(
set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets"
)
# 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)]
# Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1
se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_1.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_1)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_1)
row.use_serial_batch_fields = 1
se_manufacture_1.save()
se_manufacture_1.submit()
# Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2
se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
for row in se_manufacture_2.items:
if row.item_code == rm_item:
row.serial_no = "\n".join(rm_serials_2)
row.use_serial_batch_fields = 1
elif row.item_code == fg_item:
row.serial_no = "\n".join(fg_serials_2)
row.use_serial_batch_fields = 1
se_manufacture_2.save()
se_manufacture_2.submit()
# Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's
disassemble_qty = 2
stock_entry = frappe.get_doc(
make_stock_entry(
wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name
)
)
stock_entry.save()
stock_entry.submit()
# FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2
fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertIsNotNone(fg_row)
self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle")
fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle)
self.assertEqual(len(fg_dasm_serials), disassemble_qty)
self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1)))
self.assertFalse(
set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials"
)
# RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2
rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None)
self.assertIsNotNone(rm_row)
self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle")
rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle)
self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2)
self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1)))
self.assertFalse(
set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials"
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)

View File

@@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", {
frm.trigger("add_custom_button_to_return_components");
frm.trigger("allow_alternative_item");
frm.trigger("toggle_items_editable");
},
toggle_items_editable(frm) {
let allow_edit = true;
if (!frm.doc.__onload?.allow_editing_items) allow_edit = false;
frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit);
frm.set_df_property("required_items", "cannot_add_rows", !allow_edit);
const grid = frm.fields_dict["required_items"].grid;
grid.update_docfield_property("item_code", "read_only", !allow_edit);
grid.update_docfield_property("required_qty", "read_only", !allow_edit);
grid.refresh();
},
add_custom_button_to_return_components: function (frm) {
@@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", {
make_disassembly_order(frm) {
erpnext.work_order
.show_prompt_for_qty_input(frm, "Disassemble")
.show_disassembly_prompt(frm)
.then((data) => {
if (flt(data.qty) <= 0) {
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
@@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", {
work_order_id: frm.doc.name,
purpose: "Disassemble",
qty: data.qty,
source_stock_entry: data.source_stock_entry,
});
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
if (stock_entry) {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
}
});
},
@@ -865,6 +882,60 @@ erpnext.work_order = {
return flt(max, precision("qty"));
},
show_disassembly_prompt: function (frm) {
let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty);
let fields = [
{
fieldtype: "Link",
label: __("Source Manufacture Entry"),
fieldname: "source_stock_entry",
options: "Stock Entry",
description: __("Optional. Select a specific manufacture entry to reverse."),
get_query: () => {
return {
filters: {
work_order: frm.doc.name,
purpose: "Manufacture",
docstatus: 1,
},
};
},
onchange: async function () {
if (!frm.disassembly_prompt) return;
let se_name = this.value;
let qty = max_qty;
if (se_name) {
qty = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
{ stock_entry_name: se_name }
);
}
frm.disassembly_prompt.set_value("qty", qty);
frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty]));
},
},
{
fieldtype: "Float",
label: __("Qty for {0}", [__("Disassemble")]),
fieldname: "qty",
description: __("Max: {0}", [max_qty]),
default: max_qty,
},
];
return new Promise((resolve, reject) => {
frm.disassembly_prompt = frappe.prompt(
fields,
(data) => resolve(data),
__("Disassemble"),
__("Create")
);
});
},
show_prompt_for_qty_input: function (frm, purpose) {
let max = this.get_max_transferable_qty(frm, purpose);

View File

@@ -141,6 +141,7 @@ class WorkOrder(Document):
def onload(self):
ms = frappe.get_doc("Manufacturing Settings")
self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order)
self.set_onload("material_consumption", ms.material_consumption)
self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on)
self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order)
@@ -167,7 +168,11 @@ class WorkOrder(Document):
validate_uom_is_integer(self, "stock_uom", ["required_qty"])
self.set_required_items(reset_only_qty=len(self.get("required_items")))
if not len(self.get("required_items")) or not frappe.db.get_single_value(
"Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order"
):
self.set_required_items(reset_only_qty=len(self.get("required_items")))
self.validate_operations_sequence()
def validate_operations_sequence(self):
@@ -1480,7 +1485,13 @@ def set_work_order_ops(name):
@frappe.whitelist()
def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
def make_stock_entry(
work_order_id: str,
purpose: str,
qty: float | None = None,
target_warehouse: str | None = None,
source_stock_entry: str | None = None,
):
work_order = frappe.get_doc("Work Order", work_order_id)
if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
wip_warehouse = work_order.wip_warehouse
@@ -1517,6 +1528,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
if purpose == "Disassemble":
stock_entry.from_warehouse = work_order.fg_warehouse
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
if source_stock_entry:
stock_entry.source_stock_entry = source_stock_entry
stock_entry.set_stock_entry_type()
stock_entry.get_items()
@@ -1527,6 +1540,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
return stock_entry.as_dict()
@frappe.whitelist()
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
if not se:
return 0.0
filters = {
"source_stock_entry": stock_entry_name,
"purpose": "Disassemble",
"docstatus": 1,
}
if current_se_name:
filters["name"] = ("!=", current_se_name)
already_disassembled = flt(
frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None)
)
return flt(se.fg_completed_qty) - already_disassembled
@frappe.whitelist()
def get_default_warehouse():
doc = frappe.get_cached_doc("Manufacturing Settings")

View File

@@ -151,7 +151,7 @@
],
"istable": 1,
"links": [],
"modified": "2024-11-19 15:48:16.823384",
"modified": "2025-12-02 11:16:05.081613",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -8,6 +8,7 @@ import frappe
from frappe.tests.utils import change_settings
from frappe.utils import add_to_date, now_datetime, nowdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
from erpnext.setup.doctype.employee.test_employee import make_employee
@@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase):
ts.calculate_percentage_billed()
self.assertEqual(ts.per_billed, 100)
def test_partial_billing_and_return(self):
"""
Test Timesheet status transitions during partial billing, full billing,
sales return, and return cancellation.
Scenario:
1. Create a Timesheet with two billable time logs.
2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed.
3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed.
4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed.
5. Cancel the Sales Return → Timesheet returns to Billed status.
This test ensures Timesheet status is recalculated correctly
across billing and return lifecycle events.
"""
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
timesheet_detail = timesheet.append("time_logs", {})
timesheet_detail.is_billable = 1
timesheet_detail.activity_type = "_Test Activity Type"
timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1)
timesheet_detail.hours = 2
timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta(
hours=timesheet_detail.hours
)
timesheet.save().submit()
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice.due_date = nowdate()
sales_invoice.timesheets.pop()
sales_invoice.submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Partially Billed")
sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice2.due_date = nowdate()
sales_invoice2.submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Billed")
sales_return = make_sales_return(sales_invoice2.name).submit()
timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status")
self.assertEqual(timesheet_status, "Partially Billed")
sales_return.load_from_db()
sales_return.cancel()
timesheet.load_from_db()
self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name)
self.assertEqual(timesheet.status, "Billed")
def make_timesheet(
employee,
@@ -211,6 +264,7 @@ def make_timesheet(
project=None,
task=None,
company=None,
do_not_submit=False,
):
update_activity_type(activity_type)
timesheet = frappe.new_doc("Timesheet")
@@ -237,7 +291,8 @@ def make_timesheet(
else:
timesheet.save(ignore_permissions=True)
timesheet.submit()
if not do_not_submit:
timesheet.submit()
return timesheet

View File

@@ -91,7 +91,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled",
"options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled",
"print_hide": 1,
"read_only": 1
},
@@ -310,7 +310,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-04-20 15:59:11.107831",
"modified": "2026-04-06 22:30:28.513139",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",

View File

@@ -50,7 +50,9 @@ class Timesheet(Document):
per_billed: DF.Percent
sales_invoice: DF.Link | None
start_date: DF.Date | None
status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"]
status: DF.Literal[
"Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled"
]
time_logs: DF.Table[TimesheetDetail]
title: DF.Data | None
total_billable_amount: DF.Currency
@@ -126,6 +128,9 @@ class Timesheet(Document):
if flt(self.per_billed, self.precision("per_billed")) >= 100.0:
self.status = "Billed"
if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0:
self.status = "Partially Billed"
if self.sales_invoice:
self.status = "Completed"
@@ -423,7 +428,9 @@ def get_timesheet_data(name, project):
@frappe.whitelist()
def make_sales_invoice(source_name, item_code=None, customer=None, currency=None):
def make_sales_invoice(
source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None
):
target = frappe.new_doc("Sales Invoice")
timesheet = frappe.get_doc("Timesheet", source_name)
@@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None
target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate})
for time_log in timesheet.time_logs:
if time_log.is_billable:
if time_log.is_billable and not time_log.sales_invoice:
target.append(
"timesheets",
{

View File

@@ -1,6 +1,9 @@
frappe.listview_settings["Timesheet"] = {
add_fields: ["status", "total_hours", "start_date", "end_date"],
get_indicator: function (doc) {
if (doc.status == "Partially Billed") {
return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"];
}
if (doc.status == "Billed") {
return [__("Billed"), "green", "status,=," + "Billed"];
}

View File

@@ -0,0 +1,4 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ pincode }} {{ city | upper }}<br>
{{ country | upper }}

View File

@@ -148,6 +148,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test UAE Customer",
"customer_type": "Company",
"customer_group": "Individual",
}
)
customer.insert()

View File

@@ -115,6 +115,7 @@ def make_customer():
"doctype": "Customer",
"customer_name": "_Test SA Customer",
"customer_type": "Company",
"customer_group": "Individual",
}
).insert()

View File

@@ -145,6 +145,7 @@ class Customer(TransactionBase):
def validate(self):
self.flags.is_new_doc = self.is_new()
self.flags.old_lead = self.lead_name
self.validate_customer_group()
validate_party_accounts(self)
self.validate_credit_limit_on_change()
self.set_loyalty_program()
@@ -324,6 +325,17 @@ class Customer(TransactionBase):
frappe.NameError,
)
def validate_customer_group(self):
if not self.customer_group:
return
is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group")
if is_group:
frappe.throw(
_("Cannot select a Group type Customer Group. Please select a non-group Customer Group."),
title=_("Invalid Customer Group"),
)
def validate_credit_limit_on_change(self):
if self.get("__islocal") or not self.credit_limits:
return

View File

@@ -450,6 +450,7 @@ def make_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
customer.customer_group = "Individual"
customer.insert()
return customer.name
else:

View File

@@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", {
});
}
},
transaction_date(frm) {
prevent_past_delivery_dates(frm);
frm.set_value("delivery_date", "");
frm.doc.items.forEach((d) => {
frappe.model.set_value(d.doctype, d.name, "delivery_date", "");
});
},
refresh: function (frm) {
if (frm.doc.docstatus === 1) {

View File

@@ -189,7 +189,7 @@ class Employee(NestedSet):
frappe.throw(_("User {0} does not exist").format(self.user_id))
if self.status != "Active" and enabled or self.status == "Active" and enabled == 0:
frappe.set_value("User", self.user_id, "enabled", not enabled)
frappe.db.set_value("User", self.user_id, "enabled", not enabled)
def validate_duplicate_user_id(self):
Employee = frappe.qb.DocType("Employee")

View File

@@ -181,6 +181,7 @@ class InventoryDimension(Document):
insert_after="inventory_dimension",
options=self.reference_document,
label=_(label),
depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "",
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
@@ -273,7 +274,7 @@ class InventoryDimension(Document):
elif doctype != "Stock Entry Detail":
display_depends_on = "eval:parent.is_internal_customer == 1"
elif doctype == "Stock Entry Detail":
display_depends_on = "eval:parent.purpose != 'Material Issue'"
display_depends_on = "eval:doc.t_warehouse"
fieldname = f"{fieldname_start_with}_{self.source_fieldname}"
label = f"{label_start_with} {self.dimension_name}"

View File

@@ -510,7 +510,7 @@ class PurchaseReceipt(BuyingController):
else flt(item.net_amount, item.precision("net_amount"))
)
outgoing_amount = item.base_net_amount
outgoing_amount = item.qty * item.base_net_rate
if self.is_internal_transfer() and item.valuation_rate:
outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
@@ -1135,9 +1135,11 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0
item_wise_returned_qty = get_item_wise_returned_qty(pr_doc)
billed_qty_amt = frappe._dict()
if adjust_incoming_rate:
item_wise_billed_qty = get_billed_qty_against_purchase_receipt(pr_doc)
billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc)
billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc)
for item in pr_doc.items:
returned_qty = flt(item_wise_returned_qty.get(item.name))
@@ -1166,13 +1168,47 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
if (
item.billed_amt is not None
and item.amount is not None
and item_wise_billed_qty.get(item.name)
and (
billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item)
)
):
adjusted_amt = (
flt(item.billed_amt / item_wise_billed_qty.get(item.name)) - flt(item.rate)
) * item.qty
qty = None
if billed_qty_amt.get(item.name):
qty = billed_qty_amt.get(item.name).get("qty")
adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount"))
if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item):
if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]:
qty = item.qty
else:
qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]
billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty
billed_amt = item.billed_amt
if billed_qty_amt.get(item.name):
billed_amt = flt(billed_qty_amt.get(item.name).get("amount"))
elif billed_qty_amt_based_on_po.get(item.purchase_order_item):
total_billed_qty = (
billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty
)
if total_billed_qty:
billed_amt = flt(
flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount"))
* (qty / total_billed_qty)
)
else:
billed_amt = 0.0
# Reduce billed amount based on PO for next iterations
billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt
if qty:
adjusted_amt = (
flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate))
) * item.qty
adjusted_amt = flt(adjusted_amt, item.precision("amount"))
pi_landed_cost_amount += adjusted_amt
item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False)
elif amount and item.billed_amt > amount:
@@ -1201,20 +1237,80 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate
adjust_incoming_rate_for_pr(pr_doc)
def get_billed_qty_against_purchase_receipt(pr_doc):
def get_billed_qty_amount_against_purchase_receipt(pr_doc):
pr_names = [d.name for d in pr_doc.items]
parent_table = frappe.qb.DocType("Purchase Invoice")
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(table.pr_detail, fn.Sum(table.qty).as_("qty"))
frappe.qb.from_(parent_table)
.inner_join(table)
.on(parent_table.name == table.parent)
.select(
table.pr_detail,
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
fn.Sum(table.qty).as_("qty"),
)
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
.groupby(table.pr_detail)
)
invoice_data = query.run(as_list=1)
invoice_data = query.run(as_dict=1)
if not invoice_data:
return frappe._dict()
return frappe._dict(invoice_data)
billed_qty_amt = frappe._dict()
for row in invoice_data:
if row.pr_detail not in billed_qty_amt:
billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0}
billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount)
billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty)
return billed_qty_amt
def get_billed_qty_amount_against_purchase_order(pr_doc):
po_names = list(
set(
[
d.purchase_order_item
for d in pr_doc.items
if d.purchase_order_item and not d.purchase_invoice_item
]
)
)
invoice_data_po_based = frappe._dict()
if po_names:
parent_table = frappe.qb.DocType("Purchase Invoice")
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(parent_table)
.inner_join(table)
.on(parent_table.name == table.parent)
.select(
table.po_detail,
fn.Sum(table.qty).as_("qty"),
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
)
.where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
.groupby(table.po_detail)
)
invoice_data = query.run(as_dict=1)
if not invoice_data:
return frappe._dict()
for row in invoice_data:
if row.po_detail not in invoice_data_po_based:
invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0}
invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount)
invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty)
return invoice_data_po_based
def adjust_incoming_rate_for_pr(doc):

View File

@@ -4539,7 +4539,7 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(srbnb_cost, 1500)
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
def test_valuation_rate_for_rejected_materials_without_accepted_materials(self):
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"
@@ -5106,6 +5106,97 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(row.incoming_rate, 100)
def test_bill_for_rejected_quantity_in_purchase_invoice(self):
item_code = make_item("Test Rejected Qty", {"is_stock_item": 1}).name
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0)
pr = make_purchase_receipt(
item_code=item_code,
qty=10,
rejected_qty=2,
rate=10,
warehouse="_Test Warehouse - _TC",
)
self.assertEqual(pr.total_qty, 10)
self.assertEqual(pr.total, 100)
frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1)
pr = make_purchase_receipt(
item_code=item_code,
qty=10,
rejected_qty=2,
rate=10,
warehouse="_Test Warehouse - _TC",
)
self.assertEqual(pr.total_qty, 12)
self.assertEqual(pr.total, 120)
def test_different_exchange_rate_in_pr_and_pi(self):
from erpnext.accounts.doctype.account.test_account import create_account
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1)
party_account = create_account(
account_name="USD Party Account Creditors",
parent_account="Accounts Payable - TCP1",
account_type="Payable",
company="_Test Company with perpetual inventory",
account_currency="USD",
)
supplier = create_supplier(
supplier_name="_Test USD Supplier New 1", default_currency="USD", party_account=party_account
).name
item_code = make_item("Test Item for Different Exchange Rate", {"is_stock_item": 1}).name
pr = make_purchase_receipt(
item_code=item_code,
qty=1,
currency="USD",
conversion_rate=80,
rate=100,
company="_Test Company with perpetual inventory",
warehouse=frappe.get_value(
"Warehouse", {"company": "_Test Company with perpetual inventory"}, "name"
),
supplier=supplier,
)
self.assertEqual(pr.currency, "USD")
self.assertEqual(pr.conversion_rate, 80)
gl_entries = get_gl_entries(pr.doctype, pr.name)
self.assertTrue(len(gl_entries) == 2)
for row in gl_entries:
amount = row.credit or row.debit
self.assertEqual(amount, 8000.0)
pi = make_purchase_invoice(pr.name)
pi.conversion_rate = 90
pi.currency = "USD"
pi.save()
pi.submit()
gl_entries = get_gl_entries(pi.doctype, pi.name)
self.assertTrue(len(gl_entries) == 2)
accounts = ["USD Party Account Creditors - TCP1", "Stock Received But Not Billed - TCP1"]
for row in gl_entries:
amount = row.credit or row.debit
self.assertEqual(amount, 9000.0)
self.assertTrue(row.account in accounts)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
@@ -5276,6 +5367,9 @@ def make_purchase_receipt(**args):
pr.return_against = args.return_against
pr.apply_putaway_rule = args.apply_putaway_rule
if args.get("conversion_rate") is not None:
pr.conversion_rate = args.conversion_rate
qty = args.qty if args.qty is not None else 5
rejected_qty = args.rejected_qty or 0
received_qty = args.received_qty or flt(rejected_qty) + flt(qty)

View File

@@ -67,9 +67,15 @@ frappe.ui.form.on("Repost Item Valuation", {
}
if (frm.doc.status == "In Progress") {
frm.doc.current_index = data.current_index;
frm.doc.items_to_be_repost = data.items_to_be_repost;
frm.doc.total_reposting_count = data.total_reposting_count;
if (data.current_index) {
frm.doc.current_index = data.current_index;
frm.doc.items_to_be_repost = data.items_to_be_repost;
}
if (data.vouchers_posted) {
frm.doc.total_vouchers = data.total_vouchers;
frm.doc.vouchers_posted = data.vouchers_posted;
}
frm.dashboard.reset();
frm.trigger("show_reposting_progress");
@@ -104,15 +110,31 @@ frappe.ui.form.on("Repost Item Valuation", {
show_reposting_progress: function (frm) {
var bars = [];
let title = "";
let progress = 0.0;
let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0;
if (frm.doc?.total_reposting_count) {
total_count = frm.doc.total_reposting_count;
if (total_count > 1) {
progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5;
title = __("Reposting for Item-Wh Completed {0}%", [progress]);
bars.push({
title: title,
width: progress + "%",
progress_class: "progress-bar-success",
});
frm.dashboard.add_progress(__("Reposting Progress"), bars);
}
let progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5;
var title = __("Reposting Completed {0}%", [progress]);
if (!frm.doc.vouchers_posted) {
return;
}
// Show voucher posting progress if vouchers are being reposted
bars = [];
progress = flt((cint(frm.doc.vouchers_posted) / cint(frm.doc.total_vouchers)) * 100, 2) || 0.5;
title = __("Reposting for Vouchers Completed {0}%", [progress]);
bars.push({
title: title,
@@ -120,7 +142,7 @@ frappe.ui.form.on("Repost Item Valuation", {
progress_class: "progress-bar-success",
});
frm.dashboard.add_progress(__("Reposting Progress"), bars);
frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars);
},
restart_reposting: function (frm) {

View File

@@ -24,14 +24,16 @@
"error_section",
"error_log",
"reposting_info_section",
"reposting_data_file",
"items_to_be_repost",
"distinct_item_and_warehouse",
"column_break_o1sj",
"total_reposting_count",
"current_index",
"gl_reposting_index",
"affected_transactions"
"reposting_data_file",
"vouchers_based_on_item_and_warehouse_section",
"total_vouchers",
"column_break_yqwo",
"vouchers_posted"
],
"fields": [
{
@@ -164,15 +166,6 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "distinct_item_and_warehouse",
"fieldtype": "Code",
"hidden": 1,
"label": "Distinct Item and Warehouse",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "current_index",
"fieldtype": "Int",
@@ -182,14 +175,6 @@
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "affected_transactions",
"fieldtype": "Code",
"hidden": 1,
"label": "Affected Transactions",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "gl_reposting_index",
@@ -202,7 +187,7 @@
{
"fieldname": "reposting_info_section",
"fieldtype": "Section Break",
"label": "Reposting Info"
"label": "Reposting Item and Warehouse"
},
{
"fieldname": "column_break_o1sj",
@@ -211,14 +196,7 @@
{
"fieldname": "total_reposting_count",
"fieldtype": "Int",
"label": "Total Reposting Count",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "reposting_data_file",
"fieldtype": "Attach",
"label": "Reposting Data File",
"label": "No of Items to Repost",
"no_copy": 1,
"read_only": 1
},
@@ -228,13 +206,44 @@
"fieldname": "recreate_stock_ledgers",
"fieldtype": "Check",
"label": "Recreate Stock Ledgers"
},
{
"fieldname": "vouchers_based_on_item_and_warehouse_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Reposting Vouchers"
},
{
"fieldname": "total_vouchers",
"fieldtype": "Int",
"label": "Total Ledgers",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_yqwo",
"fieldtype": "Column Break"
},
{
"fieldname": "vouchers_posted",
"fieldtype": "Int",
"label": "Ledgers Posted",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "reposting_data_file",
"fieldtype": "Attach",
"label": "Reposting Data File",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-03-31 12:38:20.566196",
"modified": "2026-03-27 19:59:58.637964",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",

View File

@@ -33,14 +33,12 @@ class RepostItemValuation(Document):
if TYPE_CHECKING:
from frappe.types import DF
affected_transactions: DF.Code | None
allow_negative_stock: DF.Check
allow_zero_rate: DF.Check
amended_from: DF.Link | None
based_on: DF.Literal["Transaction", "Item and Warehouse"]
company: DF.Link | None
current_index: DF.Int
distinct_item_and_warehouse: DF.Code | None
error_log: DF.LongText | None
gl_reposting_index: DF.Int
item_code: DF.Link | None
@@ -49,11 +47,14 @@ class RepostItemValuation(Document):
posting_time: DF.Time | None
recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"]
reposting_reference: DF.Data | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"]
total_reposting_count: DF.Int
total_vouchers: DF.Int
via_landed_cost_voucher: DF.Check
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
vouchers_posted: DF.Int
warehouse: DF.Link | None
# end: auto-generated types
@@ -74,6 +75,7 @@ class RepostItemValuation(Document):
def validate(self):
self.set_company()
self.validate_update_stock()
self.validate_period_closing_voucher()
self.set_status(write=False)
self.reset_field_values()
@@ -81,6 +83,15 @@ class RepostItemValuation(Document):
self.reset_recreate_stock_ledgers()
self.validate_recreate_stock_ledgers()
def validate_update_stock(self):
if self.voucher_type in ["Sales Invoice", "Purchase Invoice"]:
update_stock = frappe.get_value(self.voucher_type, self.voucher_no, "update_stock")
if not update_stock:
msg = _(
"Since {0} has 'Update Stock' disabled, you cannot create repost item valuation against it"
).format(get_link_to_form(self.voucher_type, self.voucher_no))
frappe.throw(msg)
def validate_recreate_stock_ledgers(self):
if not self.recreate_stock_ledgers:
return
@@ -250,6 +261,9 @@ class RepostItemValuation(Document):
self.distinct_item_and_warehouse = None
self.items_to_be_repost = None
self.gl_reposting_index = 0
self.total_reposting_count = 0
self.total_vouchers = 0
self.vouchers_posted = 0
self.clear_attachment()
self.db_update()
@@ -381,7 +395,7 @@ def repost_sl_entries(doc):
)
else:
repost_future_sle(
args=[
items_to_be_repost=[
frappe._dict(
{
"item_code": doc.item_code,

View File

@@ -137,19 +137,14 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin):
item_code="_Test Item",
warehouse="_Test Warehouse - _TC",
based_on="Item and Warehouse",
voucher_type="Sales Invoice",
voucher_no="SI-1",
posting_date="2021-01-02",
posting_time="00:01:00",
)
# new repost without any duplicates
riv1 = frappe.get_doc(riv_args)
riv1.flags.dont_run_in_test = True
riv1.submit()
_assert_status(riv1, "Queued")
self.assertEqual(riv1.voucher_type, "Sales Invoice") # traceability
self.assertEqual(riv1.voucher_no, "SI-1")
# newer than existing duplicate - riv1
riv2 = frappe.get_doc(riv_args.update({"posting_date": "2021-01-03"}))

View File

@@ -400,6 +400,25 @@ class SerialandBatchBundle(Document):
def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None):
if valuation_details := self.get_valuation_rate_for_return_entry(return_against):
from erpnext.stock.utils import get_valuation_method
valuation_method = get_valuation_method(self.item_code)
stock_queue = []
non_batchwise_batches = []
if not self.has_serial_no and valuation_method == "FIFO":
non_batchwise_batches = frappe.get_all(
"Batch",
filters={
"name": ("in", [d.batch_no for d in self.entries if d.batch_no]),
"use_batchwise_valuation": 0,
},
pluck="name",
)
if non_batchwise_batches and prev_sle and prev_sle.stock_queue:
stock_queue = parse_json(prev_sle.stock_queue)
for row in self.entries:
if valuation_details:
self.validate_returned_serial_batch_no(return_against, row, valuation_details)
@@ -421,11 +440,25 @@ class SerialandBatchBundle(Document):
row.incoming_rate = flt(valuation_rate)
row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate)
if (
non_batchwise_batches
and row.batch_no in non_batchwise_batches
and row.incoming_rate is not None
):
if flt(row.qty) > 0:
stock_queue.append([row.qty, row.incoming_rate])
elif flt(row.qty) < 0:
stock_queue = FIFOValuation(stock_queue)
stock_queue.remove_stock(qty=abs(row.qty))
stock_queue = stock_queue.state
row.stock_queue = json.dumps(stock_queue)
if save:
row.db_set(
{
"incoming_rate": row.incoming_rate,
"stock_value_difference": row.stock_value_difference,
"stock_queue": row.get("stock_queue"),
}
)
@@ -1446,6 +1479,7 @@ class SerialandBatchBundle(Document):
def on_cancel(self):
self.validate_voucher_no_docstatus()
self.validate_batch_quantity()
self.remove_source_document_no()
def validate_batch_quantity(self):
if not self.has_batch_no:
@@ -1464,6 +1498,19 @@ class SerialandBatchBundle(Document):
if flt(available_qty, precision) < 0:
self.throw_negative_batch(d.batch_no, available_qty, precision)
def remove_source_document_no(self):
if not self.has_serial_no:
return
if self.total_qty > 0:
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
sn_table = frappe.qb.DocType("Serial No")
(
frappe.qb.update(sn_table)
.set(sn_table.purchase_document_no, None)
.where((sn_table.name.isin(serial_nos)) & (sn_table.purchase_document_no == self.voucher_no))
).run()
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
from erpnext.stock.stock_ledger import NegativeStockError

View File

@@ -1071,6 +1071,126 @@ class TestSerialandBatchBundle(FrappeTestCase):
self.assertTrue(bundle_doc.docstatus == 0)
self.assertRaises(frappe.ValidationError, bundle_doc.submit)
def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
batch_item_code = "Old Batch Return Queue Test"
make_item(
batch_item_code,
{
"has_batch_no": 1,
"batch_number_series": "TEST-RET-Q-.#####",
"create_new_batch": 1,
"is_stock_item": 1,
"valuation_method": "FIFO",
},
)
batch_id = "Old Batch Return Queue 1"
if not frappe.db.exists("Batch", batch_id):
batch_doc = frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_id,
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
batch_doc.db_set(
{
"use_batchwise_valuation": 0,
"batch_qty": 0,
}
)
# Create initial stock with FIFO queue: [[10, 100], [20, 200]]
make_stock_entry(
item_code=batch_item_code,
target="_Test Warehouse - _TC",
qty=10,
rate=100,
batch_no=batch_id,
use_serial_batch_fields=True,
)
make_stock_entry(
item_code=batch_item_code,
target="_Test Warehouse - _TC",
qty=20,
rate=200,
batch_no=batch_id,
use_serial_batch_fields=True,
)
# Purchase Receipt: inward 5 @ 300
pr = make_purchase_receipt(
item_code=batch_item_code,
warehouse="_Test Warehouse - _TC",
qty=5,
rate=300,
batch_no=batch_id,
use_serial_batch_fields=True,
)
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": pr.name},
["stock_queue"],
as_dict=True,
)
# Stock queue should now be [[10, 100], [20, 200], [5, 300]]
self.assertEqual(json.loads(sle.stock_queue), [[10, 100], [20, 200], [5, 300]])
# Purchase Return: return 5 against the PR
return_pr = make_return_doc("Purchase Receipt", pr.name)
return_pr.submit()
return_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_pr.name},
["stock_queue"],
as_dict=True,
)
# Stock queue should have 5 removed via FIFO from [[10, 100], [20, 200], [5, 300]]
# FIFO removes from front: [10, 100] -> [5, 100], rest unchanged
self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]])
def test_reference_voucher_on_cancel(self):
"""
When a source document is cancelled, the reference voucher field
in the respective serial or batch document should be nullified.
"""
item_code = make_item(
"Serial Item",
properties={
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SERIAL.#####",
},
).name
se = make_stock_entry(
item_code=item_code,
qty=1,
target="_Test Warehouse - _TC",
)
serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0]
self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se.name)
se.cancel()
self.assertIsNone(frappe.get_value("Serial No", serial_no, "purchase_document_no"))
se1 = frappe.copy_doc(se, ignore_no_copy=False)
se1.items[0].serial_no = serial_no
se1.submit()
self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name)
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos

View File

@@ -177,7 +177,7 @@ def create_shipment_customer(customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Company"
customer.customer_group = "All Customer Groups"
customer.customer_group = "Individual"
customer.territory = "All Territories"
customer.insert()
return customer

View File

@@ -36,6 +36,16 @@ frappe.ui.form.on("Stock Entry", {
};
});
frm.set_query("source_stock_entry", function () {
return {
filters: {
purpose: "Manufacture",
docstatus: 1,
work_order: frm.doc.work_order || undefined,
},
};
});
frm.set_query("source_warehouse_address", function () {
return {
query: "erpnext.controllers.queries.get_warehouse_address",
@@ -219,6 +229,30 @@ frappe.ui.form.on("Stock Entry", {
});
},
source_stock_entry: async function (frm) {
if (!frm.doc.source_stock_entry || frm.doc.purpose !== "Disassemble") return;
if (frm._via_source_stock_entry) {
frm.call({
doc: frm.doc,
method: "get_items",
callback: function (r) {
if (!r.exc) refresh_field("items");
},
});
frm._via_source_stock_entry = false;
return;
}
let available_qty = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
{ stock_entry_name: frm.doc.source_stock_entry }
);
// triggers get_items() via its onchange
await frm.set_value("fg_completed_qty", available_qty);
},
outgoing_stock_entry: function (frm) {
frappe.call({
doc: frm.doc,
@@ -315,6 +349,59 @@ frappe.ui.form.on("Stock Entry", {
__("View")
);
}
if (frm.doc.purpose === "Manufacture") {
frm.add_custom_button(
__("Disassemble"),
async function () {
let available_qty = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty",
{ stock_entry_name: frm.doc.name }
);
frappe.prompt(
{
fieldtype: "Float",
label: __("Qty to Disassemble"),
fieldname: "qty",
default: available_qty,
description: __("Max: {0}", [available_qty]),
},
async (data) => {
if (frm.doc.work_order) {
let stock_entry = await frappe.xcall(
"erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
{
work_order_id: frm.doc.work_order,
purpose: "Disassemble",
qty: data.qty,
source_stock_entry: frm.doc.name,
}
);
if (stock_entry) {
frappe.model.sync(stock_entry);
frappe.set_route("Form", stock_entry.doctype, stock_entry.name);
}
} else {
let se = frappe.model.get_new_doc("Stock Entry");
se.company = frm.doc.company;
se.stock_entry_type = "Disassemble";
se.purpose = "Disassemble";
se.source_stock_entry = frm.doc.name;
se.from_bom = frm.doc.from_bom;
se.bom_no = frm.doc.bom_no;
se.fg_completed_qty = data.qty;
frm._via_source_stock_entry = true;
frappe.set_route("Form", "Stock Entry", se.name);
}
},
__("Disassemble"),
__("Create")
);
},
__("Create")
);
}
}
if (frm.doc.docstatus === 0) {
@@ -1279,8 +1366,11 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no)
frappe.throw(__("BOM and Manufacturing Quantity are required"));
if (this.frm.doc.work_order || this.frm.doc.bom_no) {
// if work order / bom is mentioned, get items
if (
this.frm.doc.work_order ||
this.frm.doc.bom_no ||
(this.frm.doc.purpose === "Disassemble" && this.frm.doc.source_stock_entry)
) {
return this.frm.call({
doc: me.frm.doc,
freeze: true,

View File

@@ -11,6 +11,7 @@
"naming_series",
"stock_entry_type",
"outgoing_stock_entry",
"source_stock_entry",
"purpose",
"add_to_transit",
"work_order",
@@ -120,6 +121,15 @@
"options": "Stock Entry",
"read_only": 1
},
{
"depends_on": "eval:doc.purpose == 'Disassemble'",
"fieldname": "source_stock_entry",
"fieldtype": "Link",
"label": "Source Stock Entry (Manufacture)",
"no_copy": 1,
"options": "Stock Entry",
"print_hide": 1
},
{
"bold": 1,
"fetch_from": "stock_entry_type.purpose",

View File

@@ -28,7 +28,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.manufacturing.doctype.bom.bom import (
add_additional_cost,
get_bom_items_as_dict,
get_op_cost_from_sub_assemblies,
get_scrap_items_from_sub_assemblies,
validate_bom_no,
@@ -143,6 +142,7 @@ class StockEntry(StockController):
select_print_heading: DF.Link | None
set_posting_time: DF.Check
source_address_display: DF.SmallText | None
source_stock_entry: DF.Link | None
source_warehouse_address: DF.Link | None
stock_entry_type: DF.Link
subcontracting_order: DF.Link | None
@@ -183,6 +183,13 @@ class StockEntry(StockController):
)
def onload(self):
self.update_items_from_bin_details()
def before_print(self, settings=None):
super().before_print(settings)
self.update_items_from_bin_details()
def update_items_from_bin_details(self):
for item in self.get("items"):
item.update(get_bin_details(item.item_code, item.s_warehouse))
@@ -210,6 +217,7 @@ class StockEntry(StockController):
self.validate_warehouse()
self.validate_warehouse_of_sabb()
self.validate_work_order()
self.validate_source_stock_entry()
self.validate_bom()
self.set_process_loss_qty()
self.validate_purchase_order()
@@ -286,6 +294,56 @@ class StockEntry(StockController):
if self.purpose != "Disassemble":
return
if self.get("source_stock_entry"):
self._set_serial_batch_for_disassembly_from_stock_entry()
else:
self._set_serial_batch_for_disassembly_from_available_materials()
def _set_serial_batch_for_disassembly_from_stock_entry(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_voucher_wise_serial_batch_from_bundle,
)
source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty"))
scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0
bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry])
source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()}
for row in self.items:
if not row.ste_detail:
continue
source_row = source_rows_by_name.get(row.ste_detail)
if not source_row:
continue
source_warehouse = source_row.s_warehouse or source_row.t_warehouse
key = (source_row.item_code, source_warehouse, self.source_stock_entry)
source_bundle = bundle_data.get(key, {})
batches = defaultdict(float)
serial_nos = []
if source_bundle.get("batch_nos"):
qty_remaining = row.transfer_qty
for batch_no, batch_qty in source_bundle["batch_nos"].items():
if qty_remaining <= 0:
break
alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining)
batches[batch_no] = alloc
qty_remaining -= alloc
elif source_row.batch_no:
batches[source_row.batch_no] = row.transfer_qty
if source_bundle.get("serial_nos"):
serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)]
elif source_row.serial_no:
serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)]
self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
def _set_serial_batch_for_disassembly_from_available_materials(self):
available_materials = get_available_materials(self.work_order, self)
for row in self.items:
warehouse = row.s_warehouse or row.t_warehouse
@@ -311,34 +369,38 @@ class StockEntry(StockController):
if materials.serial_nos:
serial_nos = materials.serial_nos[: int(row.transfer_qty)]
if not serial_nos and not batches:
continue
self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches)
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"qty": row.transfer_qty,
"type_of_transaction": "Inward" if row.t_warehouse else "Outward",
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches):
if not serial_nos and not batches:
return
row.serial_and_batch_bundle = bundle_doc.name
row.use_serial_batch_fields = 0
warehouse = row.s_warehouse or row.t_warehouse
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"qty": row.transfer_qty,
"type_of_transaction": "Inward" if row.t_warehouse else "Outward",
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches)
row.db_set(
{
"serial_and_batch_bundle": bundle_doc.name,
"use_serial_batch_fields": 0,
}
)
row.serial_and_batch_bundle = bundle_doc.name
row.use_serial_batch_fields = 0
row.db_set(
{
"serial_and_batch_bundle": bundle_doc.name,
"use_serial_batch_fields": 0,
}
)
def on_submit(self):
self.set_serial_batch_for_disassembly()
@@ -722,7 +784,7 @@ class StockEntry(StockController):
if self.purpose == "Disassemble":
if has_bom:
if d.is_finished_item:
if d.is_finished_item or d.is_scrap_item:
d.t_warehouse = None
if not d.s_warehouse:
frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx))
@@ -759,6 +821,36 @@ class StockEntry(StockController):
elif self.purpose != "Material Transfer":
self.work_order = None
def validate_source_stock_entry(self):
if not self.get("source_stock_entry"):
return
if self.work_order:
source_wo = frappe.db.get_value("Stock Entry", self.source_stock_entry, "work_order")
if source_wo and source_wo != self.work_order:
frappe.throw(
_(
"Source Stock Entry {0} belongs to Work Order {1}, not {2}. Please use a manufacture entry from the same Work Order."
).format(self.source_stock_entry, source_wo, self.work_order),
title=_("Work Order Mismatch"),
)
from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty
available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name)
if flt(self.fg_completed_qty) > available_qty:
frappe.throw(
_(
"Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble."
).format(
self.fg_completed_qty,
self.source_stock_entry,
available_qty,
),
title=_("Excess Disassembly"),
)
def check_if_operations_completed(self):
"""Check if Time Sheets are completed against before manufacturing to capture operating costs."""
prod_order = frappe.get_doc("Work Order", self.work_order)
@@ -1951,44 +2043,114 @@ class StockEntry(StockController):
)
def get_items_for_disassembly(self):
"""Get items for Disassembly Order"""
"""Get items for Disassembly Order.
Priority:
1. From a specific Manufacture Stock Entry (exact reversal)
2. From Work Order Manufacture Stock Entries (averaged reversal)
3. From BOM (standalone disassembly)
"""
# Auto-set source_stock_entry if WO has exactly one manufacture entry
if not self.get("source_stock_entry") and self.work_order:
manufacture_entries = frappe.get_all(
"Stock Entry",
filters={
"work_order": self.work_order,
"purpose": "Manufacture",
"docstatus": 1,
},
pluck="name",
limit_page_length=2,
)
if len(manufacture_entries) == 1:
self.source_stock_entry = manufacture_entries[0]
if self.get("source_stock_entry"):
return self._add_items_for_disassembly_from_stock_entry()
if self.work_order:
return self._add_items_for_disassembly_from_work_order()
return self._add_items_for_disassembly_from_bom()
def _add_items_for_disassembly_from_work_order(self):
items = self.get_items_from_manufacture_entry()
def _add_items_for_disassembly_from_stock_entry(self):
source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")
if not source_fg_qty:
frappe.throw(
_("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry)
)
s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse")
disassemble_qty = flt(self.fg_completed_qty)
scale_factor = disassemble_qty / flt(source_fg_qty)
items_dict = get_bom_items_as_dict(
self.bom_no,
self.company,
self.fg_completed_qty,
fetch_exploded=self.use_multi_level_bom,
fetch_qty_in_stock_uom=False,
self._append_disassembly_row_from_source(
disassemble_qty=disassemble_qty,
scale_factor=scale_factor,
)
for row in items:
child_row = self.append("items", {})
for field, value in row.items():
if value is not None:
child_row.set(field, value)
def _add_items_for_disassembly_from_work_order(self):
wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty")
# update qty and amount from BOM items
bom_items = items_dict.get(row.item_code)
if bom_items:
child_row.qty = bom_items.get("qty", child_row.qty)
child_row.amount = bom_items.get("amount", child_row.amount)
wo_produced_qty = flt(wo_produced_qty)
if wo_produced_qty <= 0:
frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order))
if row.is_finished_item:
child_row.qty = self.fg_completed_qty
disassemble_qty = flt(self.fg_completed_qty)
if disassemble_qty <= 0:
frappe.throw(_("Disassemble Qty cannot be less than or equal to 0."))
child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else ""
child_row.t_warehouse = row.s_warehouse
child_row.is_finished_item = 0 if row.is_finished_item else 1
scale_factor = disassemble_qty / wo_produced_qty
self._append_disassembly_row_from_source(
disassemble_qty=disassemble_qty,
scale_factor=scale_factor,
)
def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor):
for source_row in self.get_items_from_manufacture_stock_entry():
if source_row.is_finished_item:
qty = disassemble_qty
s_warehouse = self.from_warehouse or source_row.t_warehouse
t_warehouse = ""
elif source_row.s_warehouse:
# RM: was consumed FROM s_warehouse -> return TO s_warehouse
qty = flt(source_row.qty * scale_factor)
s_warehouse = ""
t_warehouse = self.to_warehouse or source_row.s_warehouse
else:
# Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse
qty = flt(source_row.qty * scale_factor)
s_warehouse = source_row.t_warehouse
t_warehouse = ""
item = {
"item_code": source_row.item_code,
"item_name": source_row.item_name,
"description": source_row.description,
"stock_uom": source_row.stock_uom,
"uom": source_row.uom,
"conversion_factor": source_row.conversion_factor,
"basic_rate": source_row.basic_rate,
"qty": qty,
"s_warehouse": s_warehouse,
"t_warehouse": t_warehouse,
"is_finished_item": source_row.is_finished_item,
"is_scrap_item": source_row.is_scrap_item,
"bom_no": source_row.bom_no,
# batch and serial bundles built on submit
"use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0,
}
if self.source_stock_entry:
item.update(
{
"against_stock_entry": self.source_stock_entry,
"ste_detail": source_row.name,
}
)
self.append("items", item)
def _add_items_for_disassembly_from_bom(self):
if not self.bom_no or not self.fg_completed_qty:
@@ -2004,37 +2166,64 @@ class StockEntry(StockController):
self.add_to_stock_entry_detail(item_dict)
# Scrap items (reverse: take scrap FROM scrap warehouse instead of producing TO it)
scrap_items = self.get_bom_scrap_material(self.fg_completed_qty)
if scrap_items:
scrap_warehouse = self.from_warehouse
if self.work_order:
wo_values = frappe.db.get_value(
"Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True
)
scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse
for item in scrap_items.values():
item["from_warehouse"] = scrap_warehouse
item["to_warehouse"] = ""
item["is_finished_item"] = 0
self.add_to_stock_entry_detail(scrap_items, bom_no=self.bom_no)
# Finished goods
self.load_items_from_bom()
def get_items_from_manufacture_entry(self):
return frappe.get_all(
"Stock Entry",
fields=[
"`tabStock Entry Detail`.`item_code`",
"`tabStock Entry Detail`.`item_name`",
"`tabStock Entry Detail`.`description`",
"sum(`tabStock Entry Detail`.qty) as qty",
"sum(`tabStock Entry Detail`.transfer_qty) as transfer_qty",
"`tabStock Entry Detail`.`stock_uom`",
"`tabStock Entry Detail`.`uom`",
"`tabStock Entry Detail`.`basic_rate`",
"`tabStock Entry Detail`.`conversion_factor`",
"`tabStock Entry Detail`.`is_finished_item`",
"`tabStock Entry Detail`.`batch_no`",
"`tabStock Entry Detail`.`serial_no`",
"`tabStock Entry Detail`.`s_warehouse`",
"`tabStock Entry Detail`.`t_warehouse`",
"`tabStock Entry Detail`.`use_serial_batch_fields`",
],
filters=[
["Stock Entry", "purpose", "=", "Manufacture"],
["Stock Entry", "work_order", "=", self.work_order],
["Stock Entry", "docstatus", "=", 1],
["Stock Entry Detail", "docstatus", "=", 1],
],
order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc",
group_by="`tabStock Entry Detail`.`item_code`",
def get_items_from_manufacture_stock_entry(self):
SE = frappe.qb.DocType("Stock Entry")
SED = frappe.qb.DocType("Stock Entry Detail")
query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1)
common_fields = [
SED.item_code,
SED.item_name,
SED.description,
SED.stock_uom,
SED.uom,
SED.basic_rate,
SED.conversion_factor,
SED.is_finished_item,
SED.is_scrap_item,
SED.batch_no,
SED.serial_no,
SED.use_serial_batch_fields,
SED.s_warehouse,
SED.t_warehouse,
SED.bom_no,
]
if self.source_stock_entry:
return (
query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields)
.where(SE.name == self.source_stock_entry)
.orderby(SED.idx)
.run(as_dict=True)
)
return (
query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields)
.where(SE.purpose == "Manufacture")
.where(SE.work_order == self.work_order)
.groupby(SED.item_code)
.orderby(SED.idx)
.run(as_dict=True)
)
@frappe.whitelist()
@@ -2396,7 +2585,7 @@ class StockEntry(StockController):
return item_dict
def get_scrap_items_from_job_card(self):
if not self.pro_doc:
if not getattr(self, "pro_doc", None):
self.set_work_order_details()
if not self.pro_doc.operations:

View File

@@ -73,10 +73,6 @@
"auto_indent",
"column_break_27",
"reorder_email_notify",
"inter_warehouse_transfer_settings_section",
"allow_from_dn",
"column_break_31",
"allow_from_pr",
"stock_closing_tab",
"control_historical_stock_transactions_section",
"stock_frozen_upto",
@@ -223,23 +219,6 @@
"fieldtype": "Data",
"label": "Naming Series Prefix"
},
{
"fieldname": "inter_warehouse_transfer_settings_section",
"fieldtype": "Section Break",
"label": "Inter Warehouse Transfer Settings"
},
{
"default": "0",
"fieldname": "allow_from_dn",
"fieldtype": "Check",
"label": "Allow Material Transfer from Delivery Note to Sales Invoice"
},
{
"default": "0",
"fieldname": "allow_from_pr",
"fieldtype": "Check",
"label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice"
},
{
"description": "If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.",
"fieldname": "role_allowed_to_create_edit_back_dated_transactions",
@@ -287,10 +266,6 @@
"fieldname": "column_break_27",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_31",
"fieldtype": "Column Break"
},
{
"fieldname": "quality_inspection_settings_section",
"fieldtype": "Section Break",
@@ -553,7 +528,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-25 09:56:34.105949",
"modified": "2026-03-27 22:39:16.812184",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -26,8 +26,6 @@ class StockSettings(Document):
action_if_quality_inspection_is_not_submitted: DF.Literal["Stop", "Warn"]
action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"]
allow_existing_serial_no: DF.Check
allow_from_dn: DF.Check
allow_from_pr: DF.Check
allow_internal_transfer_at_arms_length_price: DF.Check
allow_negative_stock: DF.Check
allow_negative_stock_for_batch: DF.Check
@@ -235,9 +233,6 @@ class StockSettings(Document):
)
)
def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer()
def change_precision_for_for_sales(self):
doc_before_save = self.get_doc_before_save()
if doc_before_save and (
@@ -288,40 +283,6 @@ class StockSettings(Document):
validate_fields_for_doctype=False,
)
def toggle_warehouse_field_for_inter_warehouse_transfer(self):
make_property_setter(
"Sales Invoice Item",
"target_warehouse",
"hidden",
1 - cint(self.allow_from_dn),
"Check",
validate_fields_for_doctype=False,
)
make_property_setter(
"Delivery Note Item",
"target_warehouse",
"hidden",
1 - cint(self.allow_from_dn),
"Check",
validate_fields_for_doctype=False,
)
make_property_setter(
"Purchase Invoice Item",
"from_warehouse",
"hidden",
1 - cint(self.allow_from_pr),
"Check",
validate_fields_for_doctype=False,
)
make_property_setter(
"Purchase Receipt Item",
"from_warehouse",
"hidden",
1 - cint(self.allow_from_pr),
"Check",
validate_fields_for_doctype=False,
)
def clean_all_descriptions():
for item in frappe.get_all("Item", ["name", "description"]):

View File

@@ -32,8 +32,8 @@
class="btn btn-default btn-xs btn-edit"
style="margin: 4px 0; float: left;"
data-warehouse="{{ d.warehouse }}"
data-item="{{ escape(d.item_code) }}"
data-company="{{ escape(d.company) }}">
data-item="{{ d.item_code }}"
data-company="{{ d.company }}">
{{ __("Edit Capacity") }}
</button>
</div>

View File

@@ -248,12 +248,7 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
bin.warehouse,
item.valuation_method,
)
.where(
(item.is_stock_item == 1)
& (item.has_serial_no == 0)
& (warehouse.is_group == 0)
& (warehouse.company == filters.company)
)
.where((item.is_stock_item == 1) & (warehouse.is_group == 0) & (warehouse.company == filters.company))
)
if filters.item_code:

View File

@@ -4,16 +4,19 @@
import copy
import gzip
import json
from collections import deque
import frappe
from frappe import _, bold, scrub
from frappe.model.meta import get_field_precision
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
from frappe.utils import (
cint,
cstr,
flt,
format_date,
get_datetime,
get_link_to_form,
getdate,
now,
@@ -66,8 +69,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
from erpnext.controllers.stock_controller import future_sle_exists
if sl_entries:
cancel = sl_entries[0].get("is_cancelled")
if cancel:
cancelled = sl_entries[0].get("is_cancelled")
if cancelled:
validate_cancellation(sl_entries)
set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
@@ -75,10 +78,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
future_sle_exists(args, sl_entries)
for sle in sl_entries:
if sle.serial_no and not via_landed_cost_voucher:
validate_serial_no(sle)
if cancel:
if cancelled:
sle["actual_qty"] = -flt(sle.get("actual_qty"))
if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"):
@@ -155,35 +155,6 @@ def get_args_for_future_sle(row):
)
def validate_serial_no(sle):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
for sn in get_serial_nos(sle.serial_no):
args = copy.deepcopy(sle)
args.serial_no = sn
args.warehouse = ""
vouchers = []
for row in get_stock_ledger_entries(args, ">"):
voucher_type = frappe.bold(row.voucher_type)
voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no))
vouchers.append(f"{voucher_type} {voucher_no}")
if vouchers:
serial_no = frappe.bold(sn)
msg = (
f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first.
The list of the transactions are as below."""
+ "<br><br><ul><li>"
)
msg += "</li><li>".join(vouchers)
msg += "</li></ul>"
title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel"
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
def validate_cancellation(kargs):
if kargs[0].get("is_cancelled"):
repost_entry = frappe.db.get_value(
@@ -237,146 +208,96 @@ def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False):
def repost_future_sle(
args=None,
items_to_be_repost=None,
voucher_type=None,
voucher_no=None,
allow_negative_stock=None,
via_landed_cost_voucher=False,
doc=None,
):
if not args:
args = [] # set args to empty list if None to avoid enumerate error
reposting_data = {}
if not items_to_be_repost:
items_to_be_repost = get_items_to_be_repost(
voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data
)
if doc and doc.reposting_data_file:
reposting_data = get_reposting_data(doc.reposting_data_file)
items_to_be_repost = get_items_to_be_repost(
voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data
repost_affected_transaction = get_affected_transactions(doc, reposting_data) or set()
resume_item_wh_wise_last_posted_sle = (
get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data) or {}
)
if items_to_be_repost:
args = items_to_be_repost
distinct_item_warehouses = get_distinct_item_warehouse(args, doc, reposting_data=reposting_data)
affected_transactions = get_affected_transactions(doc, reposting_data=reposting_data)
i = get_current_index(doc) or 0
while i < len(args):
validate_item_warehouse(args[i])
if not items_to_be_repost:
return
index = get_current_index(doc) or 0
while index < len(items_to_be_repost):
obj = update_entries_after(
{
"item_code": args[i].get("item_code"),
"warehouse": args[i].get("warehouse"),
"posting_date": args[i].get("posting_date"),
"posting_time": args[i].get("posting_time"),
"creation": args[i].get("creation"),
"distinct_item_warehouses": distinct_item_warehouses,
"items_to_be_repost": args,
"current_index": i,
"item_code": items_to_be_repost[index].get("item_code"),
"warehouse": items_to_be_repost[index].get("warehouse"),
"posting_date": items_to_be_repost[index].get("posting_date"),
"posting_time": items_to_be_repost[index].get("posting_time"),
"creation": items_to_be_repost[index].get("creation"),
"current_idx": index,
"items_to_be_repost": items_to_be_repost,
"repost_doc": doc,
"repost_affected_transaction": repost_affected_transaction,
"item_wh_wise_last_posted_sle": resume_item_wh_wise_last_posted_sle,
},
allow_negative_stock=allow_negative_stock,
via_landed_cost_voucher=via_landed_cost_voucher,
)
affected_transactions.update(obj.affected_transactions)
key = (args[i].get("item_code"), args[i].get("warehouse"))
if distinct_item_warehouses.get(key):
distinct_item_warehouses[key].reposting_status = True
index += 1
if obj.new_items_found:
for _item_wh, data in distinct_item_warehouses.items():
if ("args_idx" not in data and not data.reposting_status) or (
data.sle_changed and data.reposting_status
):
data.args_idx = len(args)
args.append(data.sle)
elif data.sle_changed and not data.reposting_status:
args[data.args_idx] = data.sle
data.sle_changed = False
i += 1
if doc:
update_args_in_repost_item_valuation(
doc, i, args, distinct_item_warehouses, affected_transactions
)
resume_item_wh_wise_last_posted_sle = {}
repost_affected_transaction.update(obj.repost_affected_transaction)
update_args_in_repost_item_valuation(doc, index, items_to_be_repost, repost_affected_transaction)
def get_reposting_data(file_path) -> dict:
file_name = frappe.db.get_value(
"File",
def update_args_in_repost_item_valuation(
doc,
index,
items_to_be_repost,
repost_affected_transaction,
item_wh_wise_last_posted_sle=None,
only_affected_transaction=False,
):
file_name = ""
has_file = False
if not item_wh_wise_last_posted_sle:
item_wh_wise_last_posted_sle = {}
if doc.reposting_data_file:
has_file = True
if doc.reposting_data_file:
file_name = get_reposting_file_name(doc.doctype, doc.name)
# frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True)
doc.reposting_data_file = create_json_gz_file(
{
"file_url": file_path,
"attached_to_field": "reposting_data_file",
"repost_affected_transaction": repost_affected_transaction,
"item_wh_wise_last_posted_sle": {str(k): v for k, v in item_wh_wise_last_posted_sle.items()}
or {},
},
"name",
doc,
file_name,
)
if not file_name:
return frappe._dict()
attached_file = frappe.get_doc("File", file_name)
content = attached_file.get_content()
if isinstance(content, str):
content = content.encode("utf-8")
try:
data = gzip.decompress(content)
except Exception:
return frappe._dict()
if data := json.loads(data.decode("utf-8")):
data = data
return parse_json(data)
def validate_item_warehouse(args):
for field in ["item_code", "warehouse", "posting_date", "posting_time"]:
if args.get(field) in [None, ""]:
validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting"
frappe.throw(_(validation_msg))
def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehouses, affected_transactions):
if not doc.items_to_be_repost:
file_name = ""
if doc.reposting_data_file:
file_name = get_reposting_file_name(doc.doctype, doc.name)
# frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True)
doc.reposting_data_file = create_json_gz_file(
{
"items_to_be_repost": args,
"distinct_item_and_warehouse": {str(k): v for k, v in distinct_item_warehouses.items()},
"affected_transactions": affected_transactions,
},
doc,
file_name,
)
if not only_affected_transaction or not has_file:
doc.db_set(
{
"current_index": index,
"total_reposting_count": len(args),
"items_to_be_repost": frappe.as_json(items_to_be_repost),
"total_reposting_count": len(items_to_be_repost),
"reposting_data_file": doc.reposting_data_file,
}
)
else:
doc.db_set(
{
"items_to_be_repost": json.dumps(args, default=str),
"distinct_item_and_warehouse": json.dumps(
{str(k): v for k, v in distinct_item_warehouses.items()}, default=str
),
"current_index": index,
"affected_transactions": frappe.as_json(affected_transactions),
}
)
if not frappe.flags.in_test:
frappe.db.commit()
@@ -384,9 +305,8 @@ def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehou
"item_reposting_progress",
{
"name": doc.name,
"items_to_be_repost": json.dumps(args, default=str),
"current_index": index,
"total_reposting_count": len(args),
"total_reposting_count": len(items_to_be_repost),
},
doctype=doc.doctype,
docname=doc.name,
@@ -443,23 +363,27 @@ def create_file(doc, compressed_content):
return _file.file_url
def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None):
if not reposting_data and doc and doc.reposting_data_file:
reposting_data = get_reposting_data(doc.reposting_data_file)
def validate_item_warehouse(args):
for field in ["item_code", "warehouse", "posting_date", "posting_time"]:
if args.get(field) in [None, ""]:
validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting"
frappe.throw(_(validation_msg))
def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None):
if reposting_data and reposting_data.items_to_be_repost:
return reposting_data.items_to_be_repost
items_to_be_repost = []
if doc and doc.items_to_be_repost:
items_to_be_repost = json.loads(doc.items_to_be_repost) or []
items_to_be_repost = json.loads(doc.items_to_be_repost)
if not items_to_be_repost and voucher_type and voucher_no:
items_to_be_repost = frappe.db.get_all(
"Stock Ledger Entry",
filters={"voucher_type": voucher_type, "voucher_no": voucher_no},
fields=["item_code", "warehouse", "posting_date", "posting_time", "creation"],
fields=["item_code", "warehouse", "posting_date", "posting_time", "creation", "posting_datetime"],
order_by="creation asc",
group_by="item_code, warehouse",
)
@@ -467,51 +391,54 @@ def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposti
return items_to_be_repost or []
def get_distinct_item_warehouse(args=None, doc=None, reposting_data=None):
if not reposting_data and doc and doc.reposting_data_file:
reposting_data = get_reposting_data(doc.reposting_data_file)
if reposting_data and reposting_data.distinct_item_and_warehouse:
return parse_distinct_items_and_warehouses(reposting_data.distinct_item_and_warehouse)
distinct_item_warehouses = {}
if doc and doc.distinct_item_and_warehouse:
distinct_item_warehouses = json.loads(doc.distinct_item_and_warehouse)
distinct_item_warehouses = {
frappe.safe_eval(k): frappe._dict(v) for k, v in distinct_item_warehouses.items()
}
else:
for i, d in enumerate(args):
distinct_item_warehouses.setdefault(
(d.item_code, d.warehouse), frappe._dict({"reposting_status": False, "sle": d, "args_idx": i})
)
return distinct_item_warehouses
def parse_distinct_items_and_warehouses(distinct_items_and_warehouses):
new_dict = frappe._dict({})
# convert string keys to tuple
for k, v in distinct_items_and_warehouses.items():
new_dict[frappe.safe_eval(k)] = frappe._dict(v)
return new_dict
def get_affected_transactions(doc, reposting_data=None) -> set[tuple[str, str]]:
if not reposting_data and doc and doc.reposting_data_file:
reposting_data = get_reposting_data(doc.reposting_data_file)
if reposting_data and reposting_data.affected_transactions:
return {tuple(transaction) for transaction in reposting_data.affected_transactions}
if reposting_data and reposting_data.repost_affected_transaction:
return {tuple(transaction) for transaction in reposting_data.repost_affected_transaction}
if not doc.affected_transactions:
return set()
return set()
transactions = frappe.parse_json(doc.affected_transactions)
return {tuple(transaction) for transaction in transactions}
def get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data=None):
if not reposting_data and doc and doc.reposting_data_file:
reposting_data = get_reposting_data(doc.reposting_data_file)
if reposting_data and reposting_data.item_wh_wise_last_posted_sle:
return frappe._dict(reposting_data.item_wh_wise_last_posted_sle)
return frappe._dict()
def get_reposting_data(file_path) -> dict:
file_name = frappe.db.get_value(
"File",
{
"file_url": file_path,
"attached_to_field": "reposting_data_file",
},
"name",
)
if not file_name:
return frappe._dict()
attached_file = frappe.get_doc("File", file_name)
content = attached_file.get_content()
if isinstance(content, str):
content = content.encode("utf-8")
try:
data = gzip.decompress(content)
except Exception:
return frappe._dict()
if data := json.loads(data.decode("utf-8")):
data = data
return parse_json(data)
def get_current_index(doc=None):
@@ -547,6 +474,10 @@ class update_entries_after:
self.allow_zero_rate = allow_zero_rate
self.via_landed_cost_voucher = via_landed_cost_voucher
self.item_code = args.get("item_code")
self.stock_ledgers_to_repost = []
self.current_idx = args.get("current_idx", 0)
self.repost_doc = args.get("repost_doc") or None
self.items_to_be_repost = args.get("items_to_be_repost") or None
self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(
item_code=self.item_code
@@ -556,17 +487,20 @@ class update_entries_after:
if self.args.sle_id:
self.args["name"] = self.args.sle_id
self.prev_sle_dict = frappe._dict({})
self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company")
self.set_precision()
self.valuation_method = get_valuation_method(self.item_code)
self.repost_affected_transaction = args.get("repost_affected_transaction") or set()
self.new_items_found = False
self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict())
self.affected_transactions: set[tuple[str, str]] = set()
self.reserved_stock = self.get_reserved_stock()
self.data = frappe._dict()
self.initialize_previous_data(self.args)
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
self.initialize_previous_data(self.args)
self.build()
def get_reserved_stock(self):
@@ -613,7 +547,14 @@ class update_entries_after:
"""
self.data.setdefault(args.warehouse, frappe._dict())
warehouse_dict = self.data[args.warehouse]
if self.stock_ledgers_to_repost:
return
previous_sle = get_previous_sle_of_current_voucher(args)
if previous_sle:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = previous_sle
warehouse_dict.previous_sle = previous_sle
for key in ("qty_after_transaction", "valuation_rate", "stock_value"):
@@ -635,27 +576,182 @@ class update_entries_after:
if not future_sle_exists(self.args):
self.update_bin()
else:
entries_to_fix = self.get_future_entries_to_fix()
self.item_wh_wise_last_posted_sle = self.get_item_wh_wise_last_posted_sle()
_item_wh_sle = self.sort_sles(self.item_wh_wise_last_posted_sle.values())
i = 0
while i < len(entries_to_fix):
sle = entries_to_fix[i]
i += 1
while _item_wh_sle:
self.initialize_reposting()
sle_dict = _item_wh_sle.pop(0)
self.repost_stock_ledgers(sle_dict)
self.process_sle(sle)
self.update_bin_data(sle)
if sle.dependant_sle_voucher_detail_no:
entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle)
if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no):
# for repack entries, we need to repost both source and target warehouses
self.update_distinct_item_warehouses_for_repack(sle)
self.update_bin()
self.reset_vouchers_and_idx()
self.update_data_in_repost()
if self.exceptions:
self.raise_exceptions()
def update_distinct_item_warehouses_for_repack(self, sle):
sles = (
def initialize_reposting(self):
self._sles = []
self.distinct_sles = set()
self.distinct_dependant_item_wh = set()
self.prev_sle_dict = frappe._dict({})
def get_item_wh_wise_last_posted_sle(self):
if self.args and self.args.get("item_wh_wise_last_posted_sle"):
_sles = {}
for key, sle in self.args.get("item_wh_wise_last_posted_sle").items():
_sles[frappe.safe_eval(key)] = frappe._dict(sle)
return _sles
return {
(self.args.item_code, self.args.warehouse): frappe._dict(
{
"item_code": self.args.item_code,
"warehouse": self.args.warehouse,
"posting_datetime": get_combine_datetime(self.args.posting_date, self.args.posting_time),
"posting_date": self.args.posting_date,
"posting_time": self.args.posting_time,
"creation": self.args.creation,
}
)
}
def repost_stock_ledgers(self, sle_dict=None):
self._sles = self.get_future_entries_to_repost(sle_dict)
if not isinstance(self._sles, deque):
self._sles = deque(self._sles)
i = 0
while self._sles:
sle = self._sles.popleft()
i += 1
if sle.name in self.distinct_sles:
continue
item_wh_key = (sle.item_code, sle.warehouse)
if item_wh_key not in self.prev_sle_dict:
self.prev_sle_dict[item_wh_key] = get_previous_sle_of_current_voucher(sle)
self.repost_stock_ledger_entry(sle)
# To avoid duplicate reposting of same sle in case of multiple dependant sle
self.distinct_sles.add(sle.name)
if sle.dependant_sle_voucher_detail_no:
self.include_dependant_sle_in_reposting(sle)
self.update_item_wh_wise_last_posted_sle(sle)
if i % 1000 == 0:
self.update_data_in_repost(len(self._sles), i)
def sort_sles(self, sles):
return sorted(
sles,
key=lambda d: (
get_datetime(d.posting_datetime),
get_datetime(d.creation),
),
)
def include_dependant_sle_in_reposting(self, sle):
repost_dependant_sle = False
if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no):
repack_sles = self.get_sles_for_repack(sle)
for repack_sle in repack_sles:
if (repack_sle.item_code, repack_sle.warehouse) in self.distinct_dependant_item_wh:
continue
repost_dependant_sle = True
self.distinct_dependant_item_wh.add((repack_sle.item_code, repack_sle.warehouse))
self._sles.extend(self.get_future_entries_to_repost(repack_sle))
else:
dependant_sles = get_sle_by_voucher_detail_no(sle.dependant_sle_voucher_detail_no)
for depend_sle in dependant_sles:
if (depend_sle.item_code, depend_sle.warehouse) in self.distinct_dependant_item_wh:
continue
repost_dependant_sle = True
self.distinct_dependant_item_wh.add((depend_sle.item_code, depend_sle.warehouse))
self._sles.extend(self.get_future_entries_to_repost(depend_sle))
if repost_dependant_sle:
self._sles = deque(self.sort_sles(self._sles))
def repost_stock_ledger_entry(self, sle):
if isinstance(sle, dict):
sle = frappe._dict(sle)
self.process_sle(sle)
self.update_item_wh_wise_last_posted_sle(sle)
def update_item_wh_wise_last_posted_sle(self, sle):
if not self._sles:
self.item_wh_wise_last_posted_sle = frappe._dict()
return
self.item_wh_wise_last_posted_sle[(sle.item_code, sle.warehouse)] = frappe._dict(
{
"item_code": sle.item_code,
"warehouse": sle.warehouse,
"posting_date": sle.posting_date,
"posting_time": sle.posting_time,
"posting_datetime": sle.posting_datetime
or get_combine_datetime(sle.posting_date, sle.posting_time),
"creation": sle.creation,
}
)
def reset_vouchers_and_idx(self):
self.stock_ledgers_to_repost = []
self.prev_sle_dict = frappe._dict()
self.item_wh_wise_last_posted_sle = frappe._dict()
def update_data_in_repost(self, total_sles=None, index=None):
if not self.repost_doc:
return
values_to_update = {
"total_vouchers": cint(total_sles) + cint(index),
"vouchers_posted": index or 0,
}
self.repost_doc.db_set(values_to_update)
update_args_in_repost_item_valuation(
self.repost_doc,
self.current_idx,
self.items_to_be_repost,
self.repost_affected_transaction,
self.item_wh_wise_last_posted_sle,
only_affected_transaction=True,
)
if not frappe.flags.in_test:
# To maintain the state of the reposting, so if timeout happens, it can be resumed from the last posted voucher
frappe.db.commit() # nosemgrep
self.publish_real_time_progress(total_sles=total_sles, index=index)
def publish_real_time_progress(self, total_sles=None, index=None):
frappe.publish_realtime(
"item_reposting_progress",
{
"name": self.repost_doc.name,
"total_vouchers": cint(total_sles) + cint(index),
"vouchers_posted": index or 0,
},
doctype=self.repost_doc.doctype,
docname=self.repost_doc.name,
)
def get_future_entries_to_repost(self, kwargs):
return get_stock_ledger_entries(kwargs, ">=", "asc", for_update=True, check_serial_no=False)
def get_sles_for_repack(self, sle):
return (
frappe.get_all(
"Stock Ledger Entry",
filters={
@@ -663,16 +759,20 @@ class update_entries_after:
"voucher_no": sle.voucher_no,
"actual_qty": (">", 0),
"is_cancelled": 0,
"voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no),
"dependant_sle_voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no),
},
fields=["*"],
fields=[
"item_code",
"warehouse",
"posting_date",
"posting_time",
"posting_datetime",
"creation",
],
)
or []
)
for dependant_sle in sles:
self.update_distinct_item_warehouses(dependant_sle)
def has_stock_reco_with_serial_batch(self, sle):
if (
sle.voucher_type == "Stock Reconciliation"
@@ -683,35 +783,10 @@ class update_entries_after:
return False
def process_sle_against_current_timestamp(self):
sl_entries = self.get_sle_against_current_voucher()
sl_entries = get_sle_against_current_voucher(self.args)
for sle in sl_entries:
self.process_sle(sle)
def get_sle_against_current_voucher(self):
self.args["posting_datetime"] = get_combine_datetime(self.args.posting_date, self.args.posting_time)
return frappe.db.sql(
"""
select
*, posting_datetime as "timestamp"
from
`tabStock Ledger Entry`
where
item_code = %(item_code)s
and warehouse = %(warehouse)s
and is_cancelled = 0
and (
posting_datetime = %(posting_datetime)s
)
and creation = %(creation)s
order by
creation ASC
for update
""",
self.args,
as_dict=1,
)
def get_future_entries_to_fix(self):
# includes current entry!
args = self.data[self.args.warehouse].previous_sle or frappe._dict(
@@ -720,78 +795,8 @@ class update_entries_after:
return list(self.get_sle_after_datetime(args))
def get_dependent_entries_to_fix(self, entries_to_fix, sle):
dependant_sle = get_sle_by_voucher_detail_no(
sle.dependant_sle_voucher_detail_no, excluded_sle=sle.name
)
if not dependant_sle:
return entries_to_fix
elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse:
return entries_to_fix
elif dependant_sle.item_code != self.item_code:
self.update_distinct_item_warehouses(dependant_sle)
return entries_to_fix
elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data:
return entries_to_fix
else:
self.initialize_previous_data(dependant_sle)
self.update_distinct_item_warehouses(dependant_sle)
return entries_to_fix
def update_distinct_item_warehouses(self, dependant_sle):
key = (dependant_sle.item_code, dependant_sle.warehouse)
val = frappe._dict({"sle": dependant_sle})
if key not in self.distinct_item_warehouses:
self.distinct_item_warehouses[key] = val
self.new_items_found = True
else:
existing_sle = self.distinct_item_warehouses[key].get("sle", {})
if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date):
self.distinct_item_warehouses[key] = val
self.new_items_found = True
elif (
dependant_sle.actual_qty > 0
and dependant_sle.voucher_type == "Stock Entry"
and is_transfer_stock_entry(dependant_sle.voucher_no)
):
if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"):
return
val["transfer_entry_to_repost"] = True
self.distinct_item_warehouses[key] = val
self.new_items_found = True
def is_dependent_voucher_reposted(self, dependant_sle) -> bool:
# Return False if the dependent voucher is not reposted
if self.args.items_to_be_repost and self.args.current_index:
index = self.args.current_index
while index < len(self.args.items_to_be_repost):
if (
self.args.items_to_be_repost[index].get("item_code") == dependant_sle.item_code
and self.args.items_to_be_repost[index].get("warehouse") == dependant_sle.warehouse
):
if getdate(self.args.items_to_be_repost[index].get("posting_date")) > getdate(
dependant_sle.posting_date
):
self.args.items_to_be_repost[index]["posting_date"] = dependant_sle.posting_date
return False
index += 1
return True
def get_dependent_voucher_detail_nos(self, key):
if "dependent_voucher_detail_nos" not in self.distinct_item_warehouses[key]:
self.distinct_item_warehouses[key].dependent_voucher_detail_nos = []
return self.distinct_item_warehouses[key].dependent_voucher_detail_nos
def validate_previous_sle_qty(self, sle):
previous_sle = self.data[sle.warehouse].previous_sle
previous_sle = self.prev_sle_dict.get((sle.item_code, sle.warehouse))
if previous_sle and previous_sle.get("qty_after_transaction") < 0 and sle.get("actual_qty") > 0:
frappe.msgprint(
_(
@@ -810,10 +815,32 @@ class update_entries_after:
def process_sle(self, sle):
# previous sle data for this warehouse
self.wh_data = self.data[sle.warehouse]
key = (sle.item_code, sle.warehouse)
if key not in self.prev_sle_dict:
prev_sle = get_previous_sle_of_current_voucher(sle)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
if not self.prev_sle_dict.get(key):
self.prev_sle_dict[key] = frappe._dict(
{
"qty_after_transaction": 0.0,
"valuation_rate": 0.0,
"stock_value": 0.0,
"prev_stock_value": 0.0,
"stock_queue": [],
}
)
self.wh_data = self.prev_sle_dict.get(key)
if self.wh_data.stock_queue and isinstance(self.wh_data.stock_queue, str):
self.wh_data.stock_queue = json.loads(self.wh_data.stock_queue)
if not self.wh_data.prev_stock_value:
self.wh_data.prev_stock_value = self.wh_data.stock_value
self.validate_previous_sle_qty(sle)
self.affected_transactions.add((sle.voucher_type, sle.voucher_no))
if (sle.serial_no and not self.via_landed_cost_voucher) or not cint(self.allow_negative_stock):
# validate negative stock for serialized items, fifo valuation
@@ -915,7 +942,10 @@ class update_entries_after:
sle.stock_value = self.wh_data.stock_value
sle.stock_queue = json.dumps(self.wh_data.stock_queue)
old_stock_value_difference = sle.stock_value_difference
sle.stock_value_difference = stock_value_difference
if (
sle.is_adjustment_entry
and flt(sle.qty_after_transaction, self.flt_precision) == 0
@@ -940,11 +970,21 @@ class update_entries_after:
sle.modified = now()
frappe.get_doc(sle).db_update()
self.prev_sle_dict[key] = sle
if not self.args.get("sle_id") or (
sle.serial_and_batch_bundle and sle.auto_created_serial_and_batch_bundle
):
self.update_outgoing_rate_on_transaction(sle)
if flt(old_stock_value_difference, self.currency_precision) == flt(
sle.stock_value_difference, self.currency_precision
):
return
if self.args.item_code != sle.item_code or self.args.warehouse != sle.warehouse:
self.repost_affected_transaction.add((sle.voucher_type, sle.voucher_no))
def get_serialized_values(self, sle):
from erpnext.stock.serial_batch_bundle import SerialNoValuation
@@ -1713,15 +1753,42 @@ class update_entries_after:
def update_bin(self):
# update bin for each warehouse
for warehouse, data in self.data.items():
bin_name = get_or_make_bin(self.item_code, warehouse)
for (item_code, warehouse), data in self.prev_sle_dict.items():
bin_name = get_or_make_bin(item_code, warehouse)
updated_values = {"actual_qty": data.qty_after_transaction, "stock_value": data.stock_value}
updated_values = {
"actual_qty": flt(data.qty_after_transaction),
"stock_value": flt(data.stock_value),
}
if data.valuation_rate is not None:
updated_values["valuation_rate"] = data.valuation_rate
updated_values["valuation_rate"] = flt(data.valuation_rate)
frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True)
def get_sle_against_current_voucher(kwargs):
kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
doctype = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(doctype)
.select("*")
.where(
(doctype.item_code == kwargs.item_code)
& (doctype.warehouse == kwargs.warehouse)
& (doctype.is_cancelled == 0)
& (doctype.posting_datetime == kwargs.posting_datetime)
)
.orderby(doctype.creation, order=Order.asc)
.for_update()
)
if not kwargs.get("cancelled"):
query = query.where(doctype.creation == kwargs.creation)
return query.run(as_dict=True)
def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False):
"""get stock ledger entries filtered by specific posting datetime conditions"""
@@ -1874,23 +1941,15 @@ def get_stock_ledger_entries(
)
def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
return frappe.db.get_value(
def get_sle_by_voucher_detail_no(voucher_detail_no):
return frappe.get_all(
"Stock Ledger Entry",
{"voucher_detail_no": voucher_detail_no, "name": ["!=", excluded_sle], "is_cancelled": 0},
[
"item_code",
"warehouse",
"actual_qty",
"qty_after_transaction",
"posting_date",
"posting_time",
"voucher_detail_no",
"posting_datetime as timestamp",
"voucher_type",
"voucher_no",
],
as_dict=1,
filters={
"voucher_detail_no": voucher_detail_no,
"is_cancelled": 0,
"dependant_sle_voucher_detail_no": ("is", "not set"),
},
fields=["item_code", "warehouse", "posting_date", "posting_time", "posting_datetime", "creation"],
)