mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-11 02:45:08 +00:00
Merge pull request #54101 from frappe/version-15-hotfix
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
4
erpnext/regional/address_template/templates/croatia.html
Normal file
4
erpnext/regional/address_template/templates/croatia.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
|
||||
{{ pincode }} {{ city | upper }}<br>
|
||||
{{ country | upper }}
|
||||
@@ -148,6 +148,7 @@ def make_customer():
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test UAE Customer",
|
||||
"customer_type": "Company",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
)
|
||||
customer.insert()
|
||||
|
||||
@@ -115,6 +115,7 @@ def make_customer():
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test SA Customer",
|
||||
"customer_type": "Company",
|
||||
"customer_group": "Individual",
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user