mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 07:28:39 +00:00
Merge pull request #58024 from frappe/version-16-hotfix
chore: release v16
This commit is contained in:
@@ -71,4 +71,6 @@ def get_shipping_address(company, address=None):
|
||||
if address:
|
||||
address_as_dict = address[0]
|
||||
name, address_template = get_address_templates(address_as_dict)
|
||||
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
|
||||
return address_as_dict.get("name"), frappe.render_template(
|
||||
address_template, address_as_dict, restrict_globals=True
|
||||
)
|
||||
|
||||
@@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
|
||||
args: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message === false) {
|
||||
frm.set_value("company", "");
|
||||
frappe.throw(
|
||||
__(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
);
|
||||
} else {
|
||||
frm.trigger("refresh");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,7 +70,13 @@ def validate_company(company):
|
||||
frappe.throw(msg, title=_("Wrong Company"))
|
||||
|
||||
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
|
||||
return False
|
||||
frappe.throw(
|
||||
_(
|
||||
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
|
||||
)
|
||||
)
|
||||
|
||||
validate_user_perms(company)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -79,16 +85,22 @@ def import_coa(file_name, company):
|
||||
|
||||
# delete existing data for accounts
|
||||
frappe.has_permission("Company", "write", company, throw=True)
|
||||
unset_existing_data(company)
|
||||
|
||||
# create accounts
|
||||
file_doc, extension = get_file(file_name)
|
||||
validate_accounts(file_doc, extension)
|
||||
|
||||
if extension == "csv":
|
||||
data = generate_data_from_csv(file_doc)
|
||||
else:
|
||||
data = generate_data_from_excel(file_doc, extension)
|
||||
|
||||
validate_columns(data)
|
||||
|
||||
validate_company(company)
|
||||
|
||||
unset_existing_data(company)
|
||||
|
||||
frappe.local.flags.ignore_root_company_validation = True
|
||||
forest = build_forest(data)
|
||||
create_charts(company, custom_chart=forest, from_coa_importer=True)
|
||||
@@ -451,7 +463,6 @@ def get_mandatory_account_types():
|
||||
|
||||
def unset_existing_data(company):
|
||||
# remove accounts data from company
|
||||
|
||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||
linked = [{"fieldname": name} for name in fieldnames]
|
||||
update_values = {d.get("fieldname"): "" for d in linked}
|
||||
@@ -461,13 +472,30 @@ def unset_existing_data(company):
|
||||
# remove accounts data from various doctypes
|
||||
for doctype in [
|
||||
"Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
"Party Account",
|
||||
"Mode of Payment Account",
|
||||
"Tax Withholding Account",
|
||||
"Sales Taxes and Charges Template",
|
||||
"Purchase Taxes and Charges Template",
|
||||
]:
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||
frappe.get_query(doctype, delete=True, filters={"company": company}).run()
|
||||
|
||||
|
||||
def validate_user_perms(company):
|
||||
# User Permission Check for Account Deletion
|
||||
company_accounts_count = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}
|
||||
).run()[0][0]
|
||||
company_accounts_user_has_access_to = frappe.get_query(
|
||||
"Account", fields=[{"COUNT": "name"}], filters={"company": company}, ignore_permissions=False
|
||||
).run()[0][0]
|
||||
|
||||
if company_accounts_count != company_accounts_user_has_access_to:
|
||||
frappe.throw(
|
||||
_("Accounts cannot be removed, as user doesn't have access to all the accounts of {0}").format(
|
||||
frappe.bold(company)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_default_accounts(company):
|
||||
|
||||
@@ -162,7 +162,7 @@ class JournalEntry(AccountsController):
|
||||
|
||||
JournalTaxWithholding(self).on_validate()
|
||||
|
||||
if self.is_new() or not self.title:
|
||||
if not self.title or (self.is_new() and self.amended_from):
|
||||
self.title = self.get_title()
|
||||
|
||||
def validate_advance_accounts(self):
|
||||
@@ -906,6 +906,16 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
)
|
||||
|
||||
if reference_type == "Purchase Invoice" and invoice.invoice_is_blocked():
|
||||
msg = (
|
||||
_("{0} {1} is blocked and on hold until {2}.").format(
|
||||
invoice.doctype, invoice.name, invoice.release_date
|
||||
)
|
||||
if invoice.release_date
|
||||
else _("{0} {1} is blocked.").format(invoice.doctype, invoice.name)
|
||||
)
|
||||
frappe.throw(msg)
|
||||
|
||||
def set_against_account(self):
|
||||
accounts_debited, accounts_credited = [], []
|
||||
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowdate
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import get_inventory_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
|
||||
@@ -609,6 +609,69 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def make_jv_against_purchase_invoice(self, invoice, amount=100):
|
||||
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
|
||||
jv.accounts[0].party_type = "Supplier"
|
||||
jv.accounts[0].party = invoice.supplier
|
||||
jv.accounts[0].reference_type = "Purchase Invoice"
|
||||
jv.accounts[0].reference_name = invoice.name
|
||||
return jv
|
||||
|
||||
def test_jv_against_purchase_invoice_respects_hold_state(self):
|
||||
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
def never_held():
|
||||
return make_purchase_invoice()
|
||||
|
||||
def held_until_a_future_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
return invoice
|
||||
|
||||
def held_without_a_release_date():
|
||||
invoice = make_purchase_invoice()
|
||||
invoice.block_invoice(hold_comment="Under dispute")
|
||||
return invoice
|
||||
|
||||
def held_until_a_date_that_has_passed():
|
||||
invoice = held_until_a_future_date()
|
||||
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
|
||||
return invoice
|
||||
|
||||
def unblocked_again():
|
||||
invoice = held_until_a_future_date()
|
||||
invoice.unblock_invoice()
|
||||
return invoice
|
||||
|
||||
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
jv = self.make_jv_against_purchase_invoice(build_invoice())
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is blocked", jv.insert)
|
||||
|
||||
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
|
||||
with self.subTest(build_invoice.__name__):
|
||||
invoice = build_invoice()
|
||||
jv = self.make_jv_against_purchase_invoice(invoice)
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
|
||||
|
||||
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
|
||||
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -474,7 +474,7 @@ class PaymentRequest(Document):
|
||||
}
|
||||
|
||||
if self.message:
|
||||
return frappe.render_template(self.message, context)
|
||||
return frappe.render_template(self.message, context, restrict_globals=True)
|
||||
|
||||
def set_failed(self):
|
||||
pass
|
||||
|
||||
@@ -6,9 +6,10 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, flt, formatdate, getdate
|
||||
from frappe.query_builder.functions import Max, Sum
|
||||
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
|
||||
|
||||
from erpnext import is_perpetual_inventory_enabled
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
@@ -18,6 +19,8 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
|
||||
from erpnext.stock.utils import get_stock_value_on
|
||||
|
||||
|
||||
class PeriodClosingVoucher(AccountsController):
|
||||
@@ -139,6 +142,121 @@ class PeriodClosingVoucher(AccountsController):
|
||||
if account_currency != company_currency:
|
||||
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
|
||||
|
||||
def before_submit(self):
|
||||
if not self.has_stock_transactions():
|
||||
return
|
||||
|
||||
self.validate_stock_accounts_balance()
|
||||
self.validate_stock_closing_entry()
|
||||
|
||||
def has_stock_transactions(self):
|
||||
if not is_perpetual_inventory_enabled(self.company):
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"company": self.company,
|
||||
"is_cancelled": 0,
|
||||
"posting_date": ("<=", self.period_end_date),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_stock_accounts_balance(self):
|
||||
precision = frappe.get_precision("GL Entry", "debit")
|
||||
account_balance = flt(self.get_stock_accounts_balance(), precision)
|
||||
stock_value = flt(
|
||||
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
|
||||
)
|
||||
|
||||
if account_balance == stock_value:
|
||||
return
|
||||
|
||||
currency = frappe.get_cached_value("Company", self.company, "default_currency")
|
||||
frappe.throw(
|
||||
_(
|
||||
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
|
||||
).format(
|
||||
frappe.bold(fmt_money(account_balance, currency=currency)),
|
||||
frappe.bold(fmt_money(stock_value, currency=currency)),
|
||||
frappe.bold(formatdate(self.period_end_date)),
|
||||
),
|
||||
title=_("Stock Value Mismatch"),
|
||||
)
|
||||
|
||||
def get_stock_accounts_balance(self):
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
stock_accounts = (
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(
|
||||
(account.account_type == "Stock")
|
||||
& (account.company == self.company)
|
||||
& (account.is_group == 0)
|
||||
)
|
||||
)
|
||||
|
||||
balance = (
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.debit - gle.credit))
|
||||
.where(
|
||||
(gle.company == self.company)
|
||||
& (gle.is_cancelled == 0)
|
||||
& (gle.posting_date <= self.period_end_date)
|
||||
& gle.account.isin(stock_accounts)
|
||||
)
|
||||
).run()
|
||||
|
||||
return flt(balance[0][0]) if balance else 0.0
|
||||
|
||||
def validate_stock_closing_entry(self):
|
||||
closing_entry = frappe.db.get_value(
|
||||
"Stock Closing Entry",
|
||||
apply_unscoped_filters(
|
||||
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
|
||||
),
|
||||
["name", "status", "modified"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not closing_entry:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry Required"),
|
||||
)
|
||||
|
||||
if closing_entry.status != "Completed":
|
||||
frappe.throw(
|
||||
_(
|
||||
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
|
||||
).format(frappe.bold(formatdate(self.period_end_date))),
|
||||
title=_("Stock Closing Entry In Progress"),
|
||||
)
|
||||
|
||||
self.validate_stock_closing_entry_is_fresh(closing_entry)
|
||||
|
||||
def validate_stock_closing_entry_is_fresh(self, closing_entry):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
last_change = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Max(sle.modified))
|
||||
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
|
||||
).run()
|
||||
|
||||
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
|
||||
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
|
||||
title=_("Stock Closing Entry Outdated"),
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
@@ -307,6 +307,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
repost_doc.posting_date = today()
|
||||
repost_doc.save()
|
||||
|
||||
def test_stock_validations_before_period_closing(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Stock Closing Entry": [
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
"label": "Warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
se = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": pcv.period_start_date,
|
||||
"to_date": pcv.period_end_date,
|
||||
"warehouse": "Stores - TPC",
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
|
||||
|
||||
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name},
|
||||
["name", "stock_value_difference"],
|
||||
as_dict=1,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
"Test PCV Batch Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TPCVB.####",
|
||||
},
|
||||
)
|
||||
se1 = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=200,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-06-15",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
|
||||
outward = make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
from_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2022-04-01",
|
||||
batch_no=batch_no,
|
||||
)
|
||||
stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": outward.name, "is_cancelled": 0},
|
||||
"stock_value_difference",
|
||||
)
|
||||
self.assertEqual(flt(stock_value_difference, 2), -750.0)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"frozen",
|
||||
make_stock_entry,
|
||||
item_code=item.name,
|
||||
qty=1,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
|
||||
|
||||
def test_period_closing_blocks_stale_stock_closing_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-03-15",
|
||||
)
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
|
||||
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
qty=5,
|
||||
rate=100,
|
||||
to_warehouse="Stores - TPC",
|
||||
company="Test PCV Company",
|
||||
posting_date="2021-05-01",
|
||||
)
|
||||
|
||||
pcv.reload()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
|
||||
|
||||
self.rebuild_stock_closing_balance(sce)
|
||||
pcv.reload()
|
||||
pcv.submit()
|
||||
self.assertEqual(pcv.docstatus, 1)
|
||||
|
||||
def make_completed_stock_closing_entry(self, from_date, to_date):
|
||||
from unittest.mock import patch
|
||||
|
||||
sce = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Closing Entry",
|
||||
"company": "Test PCV Company",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
}
|
||||
).insert()
|
||||
|
||||
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
|
||||
sce.submit()
|
||||
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
return sce
|
||||
|
||||
def rebuild_stock_closing_balance(self, sce):
|
||||
sce.remove_stock_closing()
|
||||
sce.create_stock_closing_balance_entries()
|
||||
sce.db_set("status", "Completed")
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -238,6 +238,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -858,7 +859,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -240,10 +240,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
unblock_invoice() {
|
||||
const me = this;
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
|
||||
args: { name: me.frm.doc.name },
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
me.frm.call("unblock_invoice", null, () => {
|
||||
me.frm.reload_doc();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,15 +292,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
this.dialog.set_primary_action(__("Save"), function () {
|
||||
const dialog_data = me.dialog.get_values();
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
|
||||
args: {
|
||||
name: me.frm.doc.name,
|
||||
me.frm.call(
|
||||
"block_invoice",
|
||||
{
|
||||
hold_comment: dialog_data.hold_comment,
|
||||
release_date: dialog_data.release_date,
|
||||
},
|
||||
callback: (r) => me.frm.reload_doc(),
|
||||
});
|
||||
() => {
|
||||
me.frm.reload_doc();
|
||||
}
|
||||
);
|
||||
me.dialog.hide();
|
||||
});
|
||||
|
||||
@@ -341,10 +340,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
set_release_date(data) {
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
|
||||
args: data,
|
||||
callback: (r) => this.frm.reload_doc(),
|
||||
const me = this;
|
||||
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
|
||||
me.frm.reload_doc();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "eval:doc.on_hold",
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
"fieldname": "sb_14",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Hold Invoice"
|
||||
@@ -1702,7 +1703,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified": "2026-08-05 15:40:16.519774",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _, qb, throw
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.deferred_revenue import validate_service_stop_date
|
||||
@@ -309,6 +309,9 @@ class PurchaseInvoice(BuyingController):
|
||||
PurchaseTaxWithholding(self).on_validate()
|
||||
self.set_percentage_received()
|
||||
|
||||
if self.on_hold:
|
||||
self.validate_invoice_hold()
|
||||
|
||||
def set_percentage_received(self):
|
||||
total_billed_qty = 0.0
|
||||
total_received_qty = 0.0
|
||||
@@ -320,6 +323,13 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
|
||||
if self.docstatus < 1:
|
||||
frappe.throw(_("Purchase Invoice can be held after submitting."))
|
||||
|
||||
def validate_release_date(self):
|
||||
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
|
||||
frappe.throw(_("Release date must be in the future"))
|
||||
@@ -1901,14 +1911,38 @@ class PurchaseInvoice(BuyingController):
|
||||
def on_recurring(self, reference_doc, auto_repeat_doc):
|
||||
self.due_date = None
|
||||
|
||||
def block_invoice(self, hold_comment=None, release_date=None):
|
||||
self.db_set("on_hold", 1)
|
||||
self.db_set("hold_comment", cstr(hold_comment))
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
self.on_hold = 1
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def unblock_invoice(self):
|
||||
self.check_permission("write")
|
||||
self.db_set({"on_hold": 0, "release_date": None})
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
|
||||
self.check_permission("write")
|
||||
|
||||
if not self.on_hold:
|
||||
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
|
||||
|
||||
self.release_date = release_date
|
||||
self.validate_block_invoice()
|
||||
|
||||
self.db_set("release_date", release_date)
|
||||
|
||||
def unblock_invoice(self):
|
||||
self.db_set("on_hold", 0)
|
||||
self.db_set("release_date", None)
|
||||
def validate_block_invoice(self):
|
||||
self.validate_invoice_hold()
|
||||
if self.outstanding_amount <= 0:
|
||||
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
|
||||
|
||||
self.validate_release_date()
|
||||
|
||||
def set_status(self, update=False, status=None, update_modified=True):
|
||||
if self.is_new():
|
||||
@@ -2033,28 +2067,6 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name, release_date=None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def unblock_invoice(name):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.unblock_invoice()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def block_invoice(name, release_date, hold_comment=None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.block_invoice(hold_comment, release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_sales_invoice(source_name, target_doc=None):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
|
||||
|
||||
@@ -278,14 +278,166 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_purchase_invoice_explicit_block(self):
|
||||
pi = make_purchase_invoice()
|
||||
pi.block_invoice()
|
||||
release_date = add_days(nowdate(), 10)
|
||||
|
||||
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
|
||||
|
||||
self.assertEqual(pi.on_hold, 1)
|
||||
|
||||
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 1)
|
||||
self.assertEqual(hold_comment, "Waiting for the goods")
|
||||
self.assertEqual(getdate(saved_release_date), getdate(release_date))
|
||||
|
||||
pi.unblock_invoice()
|
||||
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
|
||||
on_hold, saved_release_date = frappe.db.get_value(
|
||||
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
|
||||
)
|
||||
self.assertEqual(on_hold, 0)
|
||||
self.assertIsNone(saved_release_date)
|
||||
|
||||
def test_purchase_invoice_cannot_be_held_before_submission(self):
|
||||
pi = make_purchase_invoice(do_not_save=True)
|
||||
pi.on_hold = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.save)
|
||||
|
||||
pi.on_hold = 0
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pi.block_invoice()
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
|
||||
|
||||
def test_return_purchase_invoice_cannot_be_held(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.on_hold = 1
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
|
||||
|
||||
return_pi.on_hold = 0
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
|
||||
|
||||
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
# a return has a negative outstanding amount, which must not be mistaken
|
||||
# for an invalid hold on a document that was never held
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.save()
|
||||
return_pi.submit()
|
||||
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
self.assertEqual(return_pi.on_hold, 0)
|
||||
self.assertLess(return_pi.outstanding_amount, 0)
|
||||
|
||||
def test_settled_purchase_invoice_cannot_be_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = nowdate()
|
||||
pe.save()
|
||||
pe.submit()
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.outstanding_amount, 0)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_release_date_of_held_invoice_must_be_in_future(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
|
||||
|
||||
def test_rejected_hold_does_not_partially_update_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
|
||||
|
||||
pi.reload()
|
||||
self.assertEqual(pi.on_hold, 0)
|
||||
self.assertIsNone(pi.release_date)
|
||||
|
||||
def test_change_release_date_of_held_invoice(self):
|
||||
pi = make_purchase_invoice()
|
||||
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
|
||||
|
||||
new_release_date = add_days(nowdate(), 20)
|
||||
pi.change_release_date(new_release_date)
|
||||
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
|
||||
getdate(new_release_date),
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
|
||||
|
||||
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
|
||||
pi = make_purchase_invoice()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Invoice is not blocked",
|
||||
pi.change_release_date,
|
||||
add_days(nowdate(), 10),
|
||||
)
|
||||
|
||||
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
|
||||
|
||||
def test_hold_methods_are_whitelisted_document_methods(self):
|
||||
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
|
||||
|
||||
pi = frappe.new_doc("Purchase Invoice")
|
||||
|
||||
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
|
||||
# raises if the method is not whitelisted for client side calls
|
||||
pi.is_whitelisted(method)
|
||||
|
||||
self.assertFalse(
|
||||
hasattr(purchase_invoice_module, method),
|
||||
f"{method} should only be exposed as a document method",
|
||||
)
|
||||
|
||||
def test_hold_methods_require_write_permission(self):
|
||||
pi = make_purchase_invoice()
|
||||
user = "test_pi_hold_permission@example.com"
|
||||
|
||||
if not frappe.db.exists("User", user):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": user,
|
||||
"first_name": "Test PI Hold",
|
||||
"roles": [{"role": "Employee"}],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.set_user(user)
|
||||
try:
|
||||
self.assertRaises(frappe.PermissionError, pi.block_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
|
||||
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
|
||||
|
||||
def test_gl_entries_with_perpetual_inventory_against_pr(self):
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -1017,7 +1018,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -1180,7 +1180,16 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
|
||||
// metadata, so this always reflects the original (Customize Form) hidden value.
|
||||
const hidden_by_customization = cint(
|
||||
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
|
||||
);
|
||||
frm.set_df_property(
|
||||
"update_stock",
|
||||
"hidden",
|
||||
cint(frm.doc.has_subcontracted) || hidden_by_customization
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.save()
|
||||
self.assertEqual(si.items[0].qty, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
|
||||
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
|
||||
invoice, since invoices post to the GL (unlike Sales Order)."""
|
||||
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
|
||||
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
def test_timestamp_change(self):
|
||||
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
|
||||
w.docstatus = 0
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -1036,7 +1037,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -254,6 +254,9 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
elif (
|
||||
@@ -605,6 +608,11 @@ class Subscription(Document):
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
# Snapshot before update_subscription_period() below can roll this forward,
|
||||
# so the cancel_at_period_end check further down still targets the period
|
||||
# that just ended, not the next one.
|
||||
current_period_end = self.current_invoice_end
|
||||
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
@@ -625,8 +633,8 @@ class Subscription(Document):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
getdate(posting_date) >= getdate(current_period_end)
|
||||
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
|
||||
@@ -614,6 +614,32 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_subscription_cancels_at_period_end_without_end_date(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
|
||||
# current_invoice_end forward to the next period before this check runs, so
|
||||
# with no end_date to fall back on, cancel_at_period_end must compare
|
||||
# against the period that just ended, not the (already advanced) next one.
|
||||
create_plan(
|
||||
plan_name="_Test plan name 11",
|
||||
cost=80,
|
||||
currency="INR",
|
||||
billing_interval="Day",
|
||||
billing_interval_count=3,
|
||||
)
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
cancel_at_period_end=1,
|
||||
generate_invoice_at="End of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 11", "qty": 1}],
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
period_end = subscription.current_invoice_end
|
||||
|
||||
subscription.process(posting_date=period_end)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
@@ -774,6 +800,38 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
|
||||
# https://github.com/frappe/erpnext/issues/57761
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
cancel_at_period_end=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
cancelation_date = getdate(subscription.cancelation_date)
|
||||
self.assertIsNotNone(cancelation_date)
|
||||
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
|
||||
|
||||
invoice_count = len(subscription.invoices)
|
||||
subscription.process()
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), invoice_count)
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
|
||||
@@ -269,9 +269,13 @@ def add_total_row_account(
|
||||
consolidated=False,
|
||||
add_blank_row=True,
|
||||
):
|
||||
name_key = "account" if consolidated else "section"
|
||||
parent_key = "parent_account" if consolidated else "parent_section"
|
||||
label_str = "'" + str(label) + "'"
|
||||
|
||||
total_row = {
|
||||
"section_name": "'" + _("{0}").format(label) + "'",
|
||||
"section": "'" + _("{0}").format(label) + "'",
|
||||
f"{name_key}_name": label_str,
|
||||
name_key: label_str,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
@@ -282,15 +286,15 @@ def add_total_row_account(
|
||||
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
|
||||
|
||||
for row in data:
|
||||
if row.get("parent_section"):
|
||||
if row.get(parent_key):
|
||||
for period in period_list:
|
||||
key = period if consolidated else period["key"]
|
||||
total_row.setdefault(key, 0.0)
|
||||
total_row[key] += row.get(key, 0.0)
|
||||
summary_data[label] += row.get(key)
|
||||
summary_data[label] += row.get(key) or 0.0
|
||||
|
||||
total_row.setdefault("total", 0.0)
|
||||
total_row["total"] += row["total"]
|
||||
total_row["total"] += row.get("total", 0.0)
|
||||
|
||||
out.append(total_row)
|
||||
|
||||
@@ -431,7 +435,6 @@ def get_opening_range_using_fiscal_year(company, period_list):
|
||||
|
||||
def get_report_summary(summary_data, currency):
|
||||
report_summary = []
|
||||
|
||||
for label, value in summary_data.items():
|
||||
report_summary.append({"value": value, "label": label, "datatype": "Currency", "currency": currency})
|
||||
|
||||
|
||||
@@ -160,7 +160,8 @@ def _execute(filters, additional_table_columns=None):
|
||||
row.update(
|
||||
{
|
||||
"debit": inv.base_grand_total,
|
||||
"credit": 0.0,
|
||||
# credits the invoice itself posts to the receivable (mirrors its GL)
|
||||
"credit": get_in_invoice_receivable_credit(inv),
|
||||
"outstanding_amount": flt(
|
||||
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
|
||||
),
|
||||
@@ -181,6 +182,14 @@ def _execute(filters, additional_table_columns=None):
|
||||
return columns, res, None, None, None, include_payments
|
||||
|
||||
|
||||
def get_in_invoice_receivable_credit(inv):
|
||||
# amount the invoice settles against its own receivable, matching the invoice's GL entries
|
||||
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
|
||||
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
|
||||
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
|
||||
return credit
|
||||
|
||||
|
||||
def get_columns(invoice_list, additional_table_columns, include_payments=False):
|
||||
"""return columns based on filters"""
|
||||
columns = [
|
||||
@@ -447,6 +456,11 @@ def get_invoices(filters, additional_query_columns):
|
||||
si.base_net_total,
|
||||
si.base_grand_total,
|
||||
si.base_rounded_total,
|
||||
si.is_pos,
|
||||
si.base_paid_amount,
|
||||
si.base_change_amount,
|
||||
si.base_write_off_amount,
|
||||
si.loyalty_amount,
|
||||
si.outstanding_amount,
|
||||
si.is_internal_customer,
|
||||
si.represents_company,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import frappe
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.sales_register.sales_register import execute
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
@@ -218,6 +219,46 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
|
||||
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
|
||||
self.assertDictEqual(result_output, expected_result)
|
||||
|
||||
def test_ledger_view_nets_pos_paid_invoice(self):
|
||||
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
|
||||
# and net to zero instead of showing a phantom outstanding.
|
||||
make_pos_profile()
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.is_pos = 1
|
||||
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
|
||||
si = si.save().submit()
|
||||
self.assertEqual(flt(si.outstanding_amount), 0.0)
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"company": self.company,
|
||||
"include_payments": True,
|
||||
"customer": self.customer,
|
||||
}
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
|
||||
|
||||
self.assertEqual(flt(inv_row.get("debit")), 100.0)
|
||||
self.assertEqual(flt(inv_row.get("credit")), 100.0)
|
||||
|
||||
# running balance is unchanged by a fully-paid POS invoice
|
||||
idx = rows.index(inv_row)
|
||||
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
|
||||
|
||||
def test_outstanding_currency_conversion(self):
|
||||
foreign_invoice = create_sales_invoice(
|
||||
customer="_Test Customer",
|
||||
|
||||
@@ -116,24 +116,39 @@ frappe.ui.form.on("Asset Repair", {
|
||||
},
|
||||
|
||||
repair_status: (frm) => {
|
||||
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
|
||||
args: {
|
||||
failure_date: frm.doc.failure_date,
|
||||
completion_date: frm.doc.completion_date,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("downtime", r.message + " Hrs");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
|
||||
frm.set_value("completion_date", frappe.datetime.now_datetime());
|
||||
}
|
||||
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
failure_date: (frm) => {
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
completion_date: (frm) => {
|
||||
frm.events.set_downtime(frm);
|
||||
},
|
||||
|
||||
set_downtime: (frm) => {
|
||||
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
|
||||
frm.set_value("downtime", null);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
|
||||
args: {
|
||||
failure_date: frm.doc.failure_date,
|
||||
completion_date: frm.doc.completion_date,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("downtime", r.message + " Hrs");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
stock_items_on_form_rendered() {
|
||||
|
||||
@@ -68,6 +68,7 @@ class AssetRepair(AccountsController):
|
||||
self.calculate_repair_cost()
|
||||
self.calculate_total_repair_cost()
|
||||
self.check_repair_status()
|
||||
self.set_downtime()
|
||||
|
||||
def validate_asset(self):
|
||||
if self.asset_doc.status in ("Sold", "Scrapped"):
|
||||
@@ -239,6 +240,13 @@ class AssetRepair(AccountsController):
|
||||
if self.repair_status == "Pending" and self.docstatus == 1:
|
||||
frappe.throw(_("Please update Repair Status."))
|
||||
|
||||
def set_downtime(self):
|
||||
# keep downtime in sync with the entered dates, regardless of edit order
|
||||
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
|
||||
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
|
||||
else:
|
||||
self.downtime = None
|
||||
|
||||
def update_asset_value(self):
|
||||
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost
|
||||
|
||||
|
||||
@@ -97,6 +97,21 @@ class TestAssetRepair(ERPNextTestSuite):
|
||||
asset_repair = create_asset_repair(submit=1)
|
||||
self.assertNotEqual(asset_repair.repair_status, "Pending")
|
||||
|
||||
def test_downtime_stays_in_sync_with_dates(self):
|
||||
asset = create_asset(submit=1)
|
||||
asset_repair = create_asset_repair(asset=asset)
|
||||
|
||||
asset_repair.failure_date = "2026-07-31 09:00:00"
|
||||
asset_repair.completion_date = "2026-07-31 11:00:00"
|
||||
asset_repair.repair_status = "Completed"
|
||||
asset_repair.save()
|
||||
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
|
||||
|
||||
# editing a date must refresh downtime, not leave a stale value
|
||||
asset_repair.completion_date = "2026-07-31 14:30:00"
|
||||
asset_repair.save()
|
||||
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
|
||||
|
||||
def test_stock_items(self):
|
||||
asset_repair = create_asset_repair(stock_consumption=1)
|
||||
self.assertTrue(asset_repair.stock_consumption)
|
||||
|
||||
@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po.save()
|
||||
self.assertEqual(po.items[1].qty, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
|
||||
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
|
||||
"""Use a negative rate to represent a credit while order quantities remain positive."""
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
|
||||
po.save()
|
||||
po.submit()
|
||||
self.assertEqual(po.docstatus, 1)
|
||||
self.assertTrue(po.base_grand_total < 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
|
||||
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
|
||||
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
|
||||
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
|
||||
def test_purchase_order_zero_qty(self):
|
||||
po = create_purchase_order(qty=0, do_not_save=True)
|
||||
|
||||
@@ -266,6 +288,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
|
||||
po.load_from_db()
|
||||
existing_ordered_qty = get_ordered_qty()
|
||||
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
|
||||
first_item_of_po = po.get("items")[0]
|
||||
|
||||
trans_item = json.dumps(
|
||||
@@ -276,16 +299,64 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
"qty": first_item_of_po.qty,
|
||||
"docname": first_item_of_po.name,
|
||||
},
|
||||
{"item_code": "_Test Item", "rate": 200, "qty": 7},
|
||||
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Purchase Order", trans_item, po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(len(po.get("items")), 2)
|
||||
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
|
||||
self.assertEqual(po.status, "To Receive and Bill")
|
||||
# ordered qty should increase on row addition
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
|
||||
# ordered qty should increase on row addition, in the warehouse passed for the new row
|
||||
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
|
||||
self.assertEqual(
|
||||
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
|
||||
existing_ordered_qty_in_new_warehouse + 7,
|
||||
)
|
||||
|
||||
def test_update_child_adding_new_item_without_any_default_warehouse(self):
|
||||
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
|
||||
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
|
||||
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
po.save()
|
||||
po.submit()
|
||||
first_item_of_po = po.get("items")[0]
|
||||
|
||||
stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse")
|
||||
frappe.db.set_single_value("Stock Settings", "default_warehouse", None)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default
|
||||
)
|
||||
|
||||
def get_trans_items(item_code):
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": first_item_of_po.item_code,
|
||||
"rate": first_item_of_po.rate,
|
||||
"qty": first_item_of_po.qty,
|
||||
"docname": first_item_of_po.name,
|
||||
},
|
||||
{"item_code": item_code, "rate": 200, "qty": 7},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"Cannot find a default warehouse",
|
||||
update_child_qty_rate,
|
||||
"Purchase Order",
|
||||
get_trans_items(stock_item),
|
||||
po.name,
|
||||
)
|
||||
|
||||
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
|
||||
|
||||
po.reload()
|
||||
self.assertEqual(po.get("items")[-1].item_code, service_item)
|
||||
self.assertFalse(po.get("items")[-1].warehouse)
|
||||
|
||||
def test_update_child_removing_item(self):
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
@@ -470,11 +541,13 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
"item_code": item,
|
||||
"rate": 100,
|
||||
"qty": 1,
|
||||
"warehouse": po.items[0].warehouse,
|
||||
}, # added item whose tax account head already exists in PO
|
||||
{
|
||||
"item_code": new_item_with_tax.name,
|
||||
"rate": 100,
|
||||
"qty": 1,
|
||||
"warehouse": po.items[0].warehouse,
|
||||
}, # added item whose tax account head is missing in PO
|
||||
]
|
||||
)
|
||||
|
||||
@@ -260,6 +260,7 @@
|
||||
"label": "UOM Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
@@ -953,7 +954,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:30:04.600510",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"label": "Conversion Factor",
|
||||
"oldfieldname": "conversion_factor",
|
||||
"oldfieldtype": "Currency",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -207,7 +208,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:26.235916",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Receipt Item Supplied",
|
||||
|
||||
@@ -328,14 +328,14 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
message_template = self.mfs_html if self.use_html else self.message_for_supplier
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
|
||||
rendered_message = frappe.render_template(message_template, doc_args)
|
||||
rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True)
|
||||
|
||||
subject_source = (
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation")
|
||||
)
|
||||
rendered_subject = frappe.render_template(subject_source, doc_args)
|
||||
rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True)
|
||||
if preview:
|
||||
return {
|
||||
"message": rendered_message,
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -261,7 +262,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-31 19:46:27.884592",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Item",
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "UOM Conversion Factor",
|
||||
"precision": "9",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
@@ -614,7 +615,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -77,6 +77,11 @@ from erpnext.stock.get_item_details import (
|
||||
get_item_tax_map,
|
||||
get_item_warehouse_,
|
||||
)
|
||||
from erpnext.stock.utils import (
|
||||
is_group_warehouse,
|
||||
validate_disabled_warehouse,
|
||||
validate_warehouse_company,
|
||||
)
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
@@ -236,6 +241,23 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
frappe.msgprint(msg)
|
||||
|
||||
def is_negative_grand_total_allowed(self) -> bool:
|
||||
"""Return True if this document may save with a negative grand total.
|
||||
|
||||
Sales Order and Purchase Order never post to the GL, so a negative
|
||||
total is safe there whenever the user has explicitly opted into
|
||||
negative rates via Selling/Buying Settings. Every other
|
||||
AccountsController doctype (invoices, delivery notes, receipts,
|
||||
quotations, ...) keeps relying on the `is_return` escape hatch only.
|
||||
"""
|
||||
if self.doctype == "Sales Order":
|
||||
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
|
||||
|
||||
if self.doctype == "Purchase Order":
|
||||
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
|
||||
|
||||
return False
|
||||
|
||||
def validate(self):
|
||||
if not self.get("is_return") and not self.get("is_debit_note"):
|
||||
self.validate_qty_is_not_zero()
|
||||
@@ -261,6 +283,7 @@ class AccountsController(TransactionBase):
|
||||
if self.is_return:
|
||||
self.validate_qty()
|
||||
else:
|
||||
self.clear_stale_deferred_fields()
|
||||
self.validate_deferred_start_and_end_date()
|
||||
|
||||
self.validate_inter_company_reference()
|
||||
@@ -285,7 +308,8 @@ class AccountsController(TransactionBase):
|
||||
self.calculate_taxes_and_totals()
|
||||
|
||||
if not self.meta.get_field("is_return") or not self.is_return:
|
||||
self.validate_value("base_grand_total", ">=", 0)
|
||||
if not self.is_negative_grand_total_allowed():
|
||||
self.validate_value("base_grand_total", ">=", 0)
|
||||
|
||||
validate_return(self)
|
||||
|
||||
@@ -644,6 +668,23 @@ class AccountsController(TransactionBase):
|
||||
if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date):
|
||||
frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
|
||||
|
||||
def clear_stale_deferred_fields(self):
|
||||
field_map = {
|
||||
"Sales Invoice": "deferred_revenue_account",
|
||||
"Purchase Invoice": "deferred_expense_account",
|
||||
}
|
||||
account_field = field_map.get(self.doctype)
|
||||
|
||||
for item in self.get("items"):
|
||||
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
|
||||
continue
|
||||
|
||||
item.service_start_date = None
|
||||
item.service_end_date = None
|
||||
item.service_stop_date = None
|
||||
if account_field:
|
||||
item.set(account_field, None)
|
||||
|
||||
def validate_deferred_start_and_end_date(self):
|
||||
for d in self.items:
|
||||
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
|
||||
@@ -3777,7 +3818,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
|
||||
child_item.stock_uom = item.stock_uom
|
||||
child_item.uom = trans_item.get("uom") or item.stock_uom
|
||||
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype)
|
||||
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
|
||||
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
|
||||
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
|
||||
@@ -3786,20 +3827,45 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
# Initialized value will update in parent validation
|
||||
child_item.base_rate = 1
|
||||
child_item.base_amount = 1
|
||||
if child_doctype == "Sales Order Item":
|
||||
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
if not child_item.warehouse:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
|
||||
).format(frappe.bold(item.item_code))
|
||||
)
|
||||
|
||||
set_child_tax_template_and_map(item, child_item, p_doc)
|
||||
add_taxes_from_tax_template(child_item, p_doc)
|
||||
return child_item
|
||||
|
||||
|
||||
def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None:
|
||||
"""Return the warehouse picked in the Update Items dialog, else the configured default.
|
||||
|
||||
Validates whichever warehouse was resolved, since a submitted parent skips validate().
|
||||
"""
|
||||
warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
|
||||
|
||||
if not warehouse:
|
||||
if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in Stock Settings."
|
||||
).format(frappe.bold(item.item_code))
|
||||
)
|
||||
return None
|
||||
|
||||
validate_warehouse_company(warehouse, p_doc.company)
|
||||
validate_disabled_warehouse(warehouse)
|
||||
is_group_warehouse(warehouse)
|
||||
return warehouse
|
||||
|
||||
|
||||
def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool:
|
||||
"""Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse."""
|
||||
if child_doctype == "Sales Order Item":
|
||||
return True
|
||||
|
||||
if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
|
||||
return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_child_on_delete(row, parent, ordered_item=None):
|
||||
"""Check if partially transacted item (row) is being deleted."""
|
||||
if parent.doctype == "Sales Order":
|
||||
|
||||
@@ -996,9 +996,8 @@ def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len,
|
||||
def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) -> list:
|
||||
table = frappe.qb.DocType(doctype)
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
frappe.get_query(table, filters=filters)
|
||||
.select(
|
||||
table.name,
|
||||
Concat("#", table.idx, ", ", table.item_code),
|
||||
)
|
||||
.orderby(table.idx)
|
||||
@@ -1006,10 +1005,6 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
|
||||
.limit(page_len)
|
||||
)
|
||||
|
||||
if filters:
|
||||
for field, value in filters.items():
|
||||
query = query.where(table[field] == value)
|
||||
|
||||
if txt:
|
||||
txt += "%"
|
||||
query = query.where(
|
||||
|
||||
@@ -253,7 +253,7 @@ class SellingController(StockController):
|
||||
|
||||
total += sales_person.allocated_percentage
|
||||
|
||||
if sales_team and total != 100.0:
|
||||
if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0:
|
||||
throw(_("Total allocated percentage for sales team should be 100"))
|
||||
|
||||
def validate_sales_team(self, sales_team):
|
||||
|
||||
@@ -264,6 +264,9 @@ class StatusUpdater(Document):
|
||||
|
||||
def validate_qty(self):
|
||||
"""Validates qty at row level"""
|
||||
selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note")
|
||||
buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt")
|
||||
|
||||
for args in self.status_updater:
|
||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
@@ -291,11 +294,8 @@ class StatusUpdater(Document):
|
||||
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
|
||||
|
||||
if (
|
||||
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
|
||||
) or (
|
||||
not buying_negative_rate_allowed
|
||||
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
|
||||
if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or (
|
||||
not buying_negative_rate_allowed and self.doctype in buying_doctypes
|
||||
):
|
||||
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
|
||||
frappe.throw(
|
||||
@@ -306,7 +306,7 @@ class StatusUpdater(Document):
|
||||
frappe.bold(_("`Allow Negative rates for Items`")),
|
||||
get_link_to_form(
|
||||
"Selling Settings"
|
||||
if self.doctype in ["Sales Invoice", "Delivery Note"]
|
||||
if self.doctype in selling_doctypes
|
||||
else "Buying Settings"
|
||||
),
|
||||
),
|
||||
|
||||
@@ -32,7 +32,7 @@ from erpnext.exceptions import (
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
|
||||
get_evaluated_inventory_dimension,
|
||||
@@ -268,7 +268,9 @@ class StockController(AccountsController):
|
||||
def use_item_inventory_account(self):
|
||||
return frappe.get_cached_value("Company", self.company, "enable_item_wise_inventory_account")
|
||||
|
||||
def get_inventory_account_dict(self, row, inventory_account_map, warehouse_field=None):
|
||||
def get_inventory_account_dict(
|
||||
self, row, inventory_account_map, warehouse_field=None, *, raise_error=True
|
||||
):
|
||||
account_dict = frappe._dict()
|
||||
|
||||
if isinstance(row, dict):
|
||||
@@ -297,8 +299,15 @@ class StockController(AccountsController):
|
||||
if not warehouse:
|
||||
warehouse = self.get(warehouse_field)
|
||||
|
||||
if warehouse and warehouse in inventory_account_map:
|
||||
account_dict = inventory_account_map[warehouse]
|
||||
if warehouse:
|
||||
account_dict = inventory_account_map.get(warehouse)
|
||||
if not account_dict and raise_error:
|
||||
account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse))
|
||||
account_dict = frappe._dict(
|
||||
account=account,
|
||||
account_currency=frappe.get_cached_value("Account", account, "account_currency"),
|
||||
)
|
||||
inventory_account_map[warehouse] = account_dict
|
||||
|
||||
return account_dict
|
||||
|
||||
@@ -2417,6 +2426,11 @@ def is_reposting_pending():
|
||||
)
|
||||
|
||||
|
||||
def invalidate_future_sle_cache(voucher_type, voucher_no):
|
||||
if hasattr(frappe.local, "future_sle"):
|
||||
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
|
||||
|
||||
|
||||
def future_sle_exists(args, sl_entries=None):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
@@ -901,8 +901,9 @@ class calculate_taxes_and_totals:
|
||||
item.net_amount = flt(
|
||||
item.net_amount + rounding_difference, item.precision("net_amount")
|
||||
)
|
||||
# net_amount went up by rounding_difference, so its discount share goes down
|
||||
item.distributed_discount_amount = flt(
|
||||
distributed_amount + rounding_difference,
|
||||
distributed_amount - rounding_difference,
|
||||
item.precision("distributed_discount_amount"),
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
@@ -60,6 +60,30 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
|
||||
self.assertEqual(so.grand_total, 1400)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_distributed_discount_amount_with_rounding_adjustment(self):
|
||||
so = make_sales_order(do_not_save=1)
|
||||
so.apply_discount_on = "Net Total"
|
||||
so.discount_amount = 10
|
||||
so.items[0].qty = 1
|
||||
so.items[0].rate = 100
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.save()
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
# the rounding adjustment lands on the second line
|
||||
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
|
||||
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
|
||||
|
||||
for item in so.items:
|
||||
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
|
||||
self.assertAlmostEqual(
|
||||
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
|
||||
)
|
||||
self.assertEqual(so.net_total, 290)
|
||||
|
||||
def test_100_percent_discount_with_inclusive_tax(self):
|
||||
"""Test that 100% discount with inclusive taxes results in zero net_total"""
|
||||
so = make_sales_order(do_not_save=1)
|
||||
|
||||
@@ -46,3 +46,61 @@ class TestReactivity(ERPNextTestSuite):
|
||||
with self.subTest(field=field):
|
||||
self.assertIsNotNone(itm.get(field[0]))
|
||||
si.save().submit()
|
||||
|
||||
def test_item_change_clears_stale_item_details(self):
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
old_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Nos"})
|
||||
new_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 0,
|
||||
"stock_uom": "Kg",
|
||||
"weight_per_unit": 2,
|
||||
"weight_uom": "Kg",
|
||||
}
|
||||
)
|
||||
sales_order = make_sales_order(item_code=old_item.name, do_not_submit=True)
|
||||
|
||||
item = sales_order.items[0]
|
||||
self.assertEqual(item.uom, "Nos")
|
||||
row_state = (item.qty, item.warehouse, item.delivery_date)
|
||||
|
||||
sales_order.ignore_pricing_rule = 1
|
||||
item.weight_per_unit = 10
|
||||
item.weight_uom = "Nos"
|
||||
item.barcode = "OLD-BARCODE"
|
||||
item.pricing_rules = "OLD-PRICING-RULE"
|
||||
item.item_code = new_item.name
|
||||
sales_order.process_item_selection(item.idx, reset_item_details=True)
|
||||
|
||||
self.assertEqual(item.uom, "Kg")
|
||||
self.assertEqual(item.stock_uom, "Kg")
|
||||
self.assertEqual(item.conversion_factor, 1)
|
||||
self.assertEqual(item.weight_per_unit, 2)
|
||||
self.assertEqual(item.weight_uom, "Kg")
|
||||
self.assertIsNone(item.barcode)
|
||||
self.assertFalse(item.pricing_rules)
|
||||
self.assertEqual((item.qty, item.warehouse, item.delivery_date), row_state)
|
||||
|
||||
def test_programmatic_item_selection_preserves_explicit_uom(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 0,
|
||||
"stock_uom": "Kg",
|
||||
"sales_uom": "Nos",
|
||||
"weight_per_unit": 2,
|
||||
"weight_uom": "Kg",
|
||||
},
|
||||
uoms=[{"uom": "Nos", "conversion_factor": 10}],
|
||||
)
|
||||
sales_invoice = create_sales_invoice(item_code=item.name, uom="Kg", do_not_save=True)
|
||||
|
||||
sales_invoice.process_item_selection(sales_invoice.items[0].idx)
|
||||
|
||||
self.assertEqual(sales_invoice.items[0].uom, "Kg")
|
||||
self.assertEqual(sales_invoice.items[0].conversion_factor, 1)
|
||||
self.assertEqual(sales_invoice.items[0].stock_qty, sales_invoice.items[0].qty)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.controllers.stock_controller import (
|
||||
@@ -75,3 +76,174 @@ class TestLedgerPreviewPermission(ERPNextTestSuite):
|
||||
|
||||
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
|
||||
self.assertTrue(stock_ledger_result.get("sl_data"))
|
||||
|
||||
|
||||
class TestStockControllerConversions(ERPNextTestSuite):
|
||||
@staticmethod
|
||||
def _cancel_and_delete(doctype, name):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
return
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=1)
|
||||
|
||||
def test_future_sle_exists_detects_later_entries(self):
|
||||
# A later SLE for the same item+warehouse must be reported as a future entry, which
|
||||
# exercises the GROUP BY query in future_sle_exists on both engines.
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item("_Test Future SLE Item", {"is_stock_item": 1}).name
|
||||
se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
|
||||
|
||||
# Pretend a different voucher posts a day earlier for the same item/warehouse: the existing
|
||||
# (later) SLE must be reported as a future entry.
|
||||
args = frappe._dict(
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="_TEST-NONEXISTENT-SE",
|
||||
posting_date=add_days(today(), -1),
|
||||
posting_time="00:00:00",
|
||||
)
|
||||
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
|
||||
|
||||
self.assertTrue(future_sle_exists(args, sl_entries))
|
||||
|
||||
def _make_opening_entry(self, item, warehouse):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
opening = make_stock_entry(
|
||||
item_code=item,
|
||||
target=warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(today(), -5),
|
||||
posting_time="01:00:00",
|
||||
)
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
|
||||
|
||||
return opening
|
||||
|
||||
def _later_sle(self, item, warehouse, opening):
|
||||
sle = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Stock Ledger Entry",
|
||||
"item_code": item,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": today(),
|
||||
"posting_time": "12:00:00",
|
||||
"voucher_type": "Stock Entry",
|
||||
"voucher_no": opening.name,
|
||||
"actual_qty": 7,
|
||||
"incoming_rate": 100,
|
||||
"qty_after_transaction": 107,
|
||||
"valuation_rate": 100,
|
||||
"stock_value": 10700,
|
||||
"company": opening.company,
|
||||
"stock_uom": "Nos",
|
||||
}
|
||||
)
|
||||
sle.flags.ignore_permissions = True
|
||||
sle.flags.ignore_links = True
|
||||
|
||||
return sle
|
||||
|
||||
def _submit_entry(self, item, warehouse, inject=None):
|
||||
from erpnext.stock import stock_ledger
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
original_make_entry = stock_ledger.make_entry
|
||||
injected = []
|
||||
|
||||
def make_entry_with_injection(*args, **kwargs):
|
||||
if inject is not None and not injected:
|
||||
injected.append(True)
|
||||
inject.submit()
|
||||
return original_make_entry(*args, **kwargs)
|
||||
|
||||
stock_ledger.make_entry = make_entry_with_injection
|
||||
try:
|
||||
entry = make_stock_entry(
|
||||
item_code=item,
|
||||
target=warehouse,
|
||||
qty=5,
|
||||
basic_rate=500,
|
||||
posting_date=today(),
|
||||
posting_time="06:00:00",
|
||||
)
|
||||
finally:
|
||||
stock_ledger.make_entry = original_make_entry
|
||||
|
||||
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
|
||||
if inject is not None:
|
||||
self.assertTrue(injected, "the later SL Entry was not written during the submit")
|
||||
|
||||
return entry
|
||||
|
||||
def _reposts_queued_for(self, item, warehouse, voucher_no):
|
||||
names = set(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
|
||||
pluck="name",
|
||||
)
|
||||
) | set(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "voucher_no": voucher_no},
|
||||
pluck="name",
|
||||
)
|
||||
)
|
||||
for name in names:
|
||||
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
|
||||
|
||||
return names
|
||||
|
||||
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
opening = self._make_opening_entry(item, warehouse)
|
||||
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
|
||||
|
||||
self.assertTrue(
|
||||
self._reposts_queued_for(item, warehouse, backdated.name),
|
||||
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
|
||||
)
|
||||
|
||||
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
|
||||
opening = self._make_opening_entry(item, warehouse)
|
||||
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
|
||||
|
||||
self.assertTrue(
|
||||
frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"docstatus": 1, "voucher_no": backdated.name},
|
||||
pluck="name",
|
||||
),
|
||||
"No voucher based Repost Item Valuation was queued",
|
||||
)
|
||||
|
||||
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
self._make_opening_entry(item, warehouse)
|
||||
entry = self._submit_entry(item, warehouse)
|
||||
|
||||
self.assertFalse(
|
||||
self._reposts_queued_for(item, warehouse, entry.name),
|
||||
"A Repost Item Valuation was queued for an entry with nothing posted after it",
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.model.document import Document
|
||||
from frappe.share import add_docshare
|
||||
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
|
||||
from frappe.utils.data import sha256_hash
|
||||
from frappe.utils.html_utils import escape_html
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
@@ -269,7 +270,11 @@ class Appointment(Document):
|
||||
if self.customer_details:
|
||||
lead.append(
|
||||
"notes",
|
||||
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
|
||||
{
|
||||
"note": escape_html(self.customer_details),
|
||||
"added_by": frappe.session.user,
|
||||
"added_on": now(),
|
||||
},
|
||||
)
|
||||
|
||||
self.party = lead.insert(ignore_permissions=True).name
|
||||
|
||||
@@ -30,7 +30,7 @@ class ContractTemplate(Document):
|
||||
|
||||
def validate(self):
|
||||
if self.contract_terms:
|
||||
validate_template(self.contract_terms)
|
||||
validate_template(self.contract_terms, restrict_globals=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -42,6 +42,6 @@ def get_contract_template(template_name, doc):
|
||||
contract_terms = None
|
||||
|
||||
if contract_template.contract_terms:
|
||||
contract_terms = frappe.render_template(contract_template.contract_terms, doc)
|
||||
contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True)
|
||||
|
||||
return {"contract_template": contract_template, "contract_terms": contract_terms}
|
||||
|
||||
@@ -171,8 +171,8 @@ def send_mail(entry, email_campaign):
|
||||
context = {"doc": frappe.get_doc("Email Group", recipient)}
|
||||
|
||||
# Render template
|
||||
subject = frappe.render_template(email_template.get("subject"), context)
|
||||
content = frappe.render_template(email_template.response_, context)
|
||||
subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True)
|
||||
content = frappe.render_template(email_template.response_, context, restrict_globals=True)
|
||||
|
||||
try:
|
||||
comm = make(
|
||||
|
||||
1498
erpnext/locale/ar.po
1498
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/bg.po
1496
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
1778
erpnext/locale/bs.po
1778
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/cs.po
1496
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/da.po
1498
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/de.po
1498
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/eo.po
1498
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/es.po
1498
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
1582
erpnext/locale/fa.po
1582
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/fr.po
1496
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/hi.po
1496
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
1672
erpnext/locale/hr.po
1672
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/hu.po
1496
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/id.po
1496
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/it.po
1496
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
1500
erpnext/locale/ko.po
1500
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/my.po
1496
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/nb.po
1496
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/nl.po
1498
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/pl.po
1496
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/pt.po
1496
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/ro.po
1496
erpnext/locale/ro.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/ru.po
1498
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
1496
erpnext/locale/sl.po
1496
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/sr.po
1498
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1678
erpnext/locale/sv.po
1678
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/th.po
1498
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/tr.po
1498
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/uz.po
1498
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
1498
erpnext/locale/vi.po
1498
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
18428
erpnext/locale/zh.po
18428
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -282,6 +282,7 @@ class BOM(WebsiteGenerator):
|
||||
self.clear_inspection()
|
||||
self.validate_main_item()
|
||||
self.validate_currency()
|
||||
self.set_operation_finished_goods()
|
||||
self.set_materials_based_on_operation_bom()
|
||||
self.set_conversion_rate()
|
||||
self.set_plc_conversion_rate()
|
||||
@@ -307,15 +308,42 @@ class BOM(WebsiteGenerator):
|
||||
if self.docstatus == 1:
|
||||
self.validate_raw_materials_of_operation()
|
||||
|
||||
def set_operation_finished_goods(self):
|
||||
"""Fill each operation's FG item where it is unambiguous: the final operation produces
|
||||
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
|
||||
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
|
||||
if not self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
for row in self.operations:
|
||||
if row.is_final_finished_good and not row.finished_good:
|
||||
row.finished_good = self.item
|
||||
elif row.bom_no and not row.finished_good:
|
||||
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
|
||||
|
||||
def validate_semi_finished_goods(self):
|
||||
if not self.track_semi_finished_goods or not self.operations:
|
||||
return
|
||||
|
||||
fg_items = []
|
||||
for row in self.operations:
|
||||
if not row.finished_good:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
|
||||
).format(row.idx, bold(row.operation)),
|
||||
)
|
||||
|
||||
if not row.is_final_finished_good:
|
||||
continue
|
||||
|
||||
if row.finished_good != self.item:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
|
||||
).format(row.idx, bold(row.operation), bold(self.item)),
|
||||
)
|
||||
|
||||
fg_items.append(row.finished_good)
|
||||
|
||||
if not fg_items:
|
||||
@@ -826,7 +854,7 @@ class BOM(WebsiteGenerator):
|
||||
self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty)
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_raw_materials(self, operation_row_id, items):
|
||||
def add_raw_materials(self, operation_row_id: str | int, items: str | list[dict]) -> None:
|
||||
if isinstance(items, str):
|
||||
items = parse_json(items)
|
||||
|
||||
@@ -836,17 +864,10 @@ class BOM(WebsiteGenerator):
|
||||
row.update(get_item_details(row.get("item_code")))
|
||||
row.operation_row_id = operation_row_id
|
||||
|
||||
item_row = None
|
||||
if row.name:
|
||||
item_row = self.get_item_data(row.name)
|
||||
item_row = self.get_item_data(row.item_code, operation_row_id)
|
||||
|
||||
if item_row:
|
||||
item_row.update(
|
||||
{
|
||||
"item_code": row.get("item_code"),
|
||||
"qty": row.get("qty"),
|
||||
}
|
||||
)
|
||||
item_row.qty = row.get("qty")
|
||||
else:
|
||||
row.idx = None
|
||||
row.name = None
|
||||
@@ -867,9 +888,9 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
return False
|
||||
|
||||
def get_item_data(self, name):
|
||||
def get_item_data(self, item_code, operation_row_id):
|
||||
for row in self.items:
|
||||
if row.item_code == name:
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
|
||||
return row
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1888,10 +1909,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
[IfNull(Field("end_of_life"), "3099-12-31"), ">", today()],
|
||||
]
|
||||
|
||||
or_cond_filters = {}
|
||||
or_cond_filters = []
|
||||
if txt:
|
||||
for s_field in searchfields:
|
||||
or_cond_filters[s_field] = ("like", f"%{txt}%")
|
||||
or_cond_filters.append([s_field, "like", f"%{txt}%"])
|
||||
|
||||
barcodes = frappe.get_all(
|
||||
"Item Barcode",
|
||||
@@ -1902,7 +1923,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
barcodes = [d.item_code for d in barcodes]
|
||||
if barcodes:
|
||||
or_cond_filters["name"] = ("in", barcodes)
|
||||
or_cond_filters.append(["name", "in", barcodes])
|
||||
|
||||
if filters and filters.get("item_code"):
|
||||
has_variants = frappe.get_cached_value("Item", filters.get("item_code"), "has_variants")
|
||||
|
||||
@@ -7,7 +7,7 @@ from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe.tests import timeout
|
||||
from frappe.utils import cstr, flt
|
||||
from frappe.utils import cint, cstr, flt
|
||||
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
set_backflush_based_on,
|
||||
@@ -486,6 +486,29 @@ class TestBOM(ERPNextTestSuite):
|
||||
self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results")
|
||||
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
|
||||
|
||||
@timeout
|
||||
def test_bom_item_query_matches_item_code_colliding_with_another_barcode(self):
|
||||
item = make_item(
|
||||
"_Test BOM Query 2.5MM",
|
||||
{"is_stock_item": 1, "item_name": "_Test BOM Query Sheet", "description": "sheet"},
|
||||
)
|
||||
make_item(
|
||||
"_Test BOM Query Barcode Holder",
|
||||
{"is_stock_item": 1},
|
||||
barcode=f"90{item.name}90",
|
||||
)
|
||||
|
||||
results = item_query(
|
||||
doctype="Item",
|
||||
txt=item.name,
|
||||
searchfield="name",
|
||||
start=0,
|
||||
page_len=20,
|
||||
filters={"is_stock_item": 1},
|
||||
)
|
||||
|
||||
self.assertIn(item.name, [d[0] for d in results])
|
||||
|
||||
@timeout
|
||||
def test_exclude_exploded_items_from_bom(self):
|
||||
bom_no = get_default_bom()
|
||||
@@ -811,6 +834,207 @@ class TestBOM(ERPNextTestSuite):
|
||||
for row in bom.items:
|
||||
self.assertEqual(row.stock_uom, "Kg")
|
||||
|
||||
@timeout
|
||||
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the first operation produces nothing derivable: no FG item, no BOM to take it from
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[0].finished_good = sfg_item
|
||||
bom.insert()
|
||||
|
||||
# the final operation's FG item is derived from the BOM's own item
|
||||
self.assertEqual(bom.operations[1].finished_good, fg_item)
|
||||
|
||||
@timeout
|
||||
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.insert()
|
||||
|
||||
def rows_for(item_code, operation_row_id):
|
||||
return [
|
||||
row
|
||||
for row in bom.items
|
||||
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
|
||||
]
|
||||
|
||||
# the item already used by operation 1 gets its own new row under operation 2
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
|
||||
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
|
||||
|
||||
# adding it again for the same operation updates the row instead of stacking another
|
||||
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
|
||||
self.assertEqual(len(rows_for(rm_item, 2)), 1)
|
||||
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
|
||||
|
||||
@timeout
|
||||
def test_operation_bom_materials_expand_on_single_pass_submit(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"bom_no": sfg_bom.name,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
bom.submit()
|
||||
|
||||
self.assertEqual(bom.docstatus, 1)
|
||||
self.assertEqual(bom.operations[0].finished_good, sfg_item)
|
||||
self.assertTrue(
|
||||
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
|
||||
)
|
||||
|
||||
@timeout
|
||||
def test_final_operation_must_produce_the_bom_item(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
sfg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
|
||||
make_workstation({"workstation": "_Test SFG Workstation"})
|
||||
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
|
||||
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.company = "_Test Company"
|
||||
bom.item = fg_item
|
||||
bom.quantity = 1
|
||||
bom.with_operations = 1
|
||||
bom.track_semi_finished_goods = 1
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": "_Test SFG Final Operation",
|
||||
"workstation": "_Test SFG Workstation",
|
||||
"time_in_mins": 30,
|
||||
"is_final_finished_good": 1,
|
||||
"finished_good": sfg_item,
|
||||
},
|
||||
)
|
||||
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
|
||||
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
|
||||
|
||||
# the final operation claims to produce the semi FG, not this BOM's item
|
||||
self.assertRaises(frappe.ValidationError, bom.insert)
|
||||
|
||||
bom.operations[1].finished_good = fg_item
|
||||
bom.insert()
|
||||
|
||||
|
||||
def get_default_bom(item_code="_Test FG Item 2"):
|
||||
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
|
||||
@@ -881,10 +1105,15 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
|
||||
warehouse_list = [warehouse_list]
|
||||
|
||||
if not warehouse_list:
|
||||
# Reconcile every warehouse the item has a non-zero balance in -- including
|
||||
# negative balances left by other tests. get_valuation_rate averages
|
||||
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
|
||||
# balance in one warehouse can cancel the reset qty elsewhere and make the
|
||||
# average collapse to 0, which is a source of flaky BOM-cost failures.
|
||||
warehouse_list = frappe.db.sql_list(
|
||||
"""
|
||||
select warehouse from `tabBin`
|
||||
where item_code=%s and actual_qty > 0
|
||||
where item_code=%s and actual_qty != 0
|
||||
""",
|
||||
item_code,
|
||||
)
|
||||
|
||||
@@ -140,7 +140,8 @@
|
||||
{
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor"
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fetch_from": "item_code.stock_uom",
|
||||
@@ -264,7 +265,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 21:15:55.187671",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Creator Item",
|
||||
|
||||
@@ -177,7 +177,8 @@
|
||||
{
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor"
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9"
|
||||
},
|
||||
{
|
||||
"fieldname": "rate_amount_section",
|
||||
@@ -327,7 +328,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 19:00:38.646539",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Item",
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "FG / Semi FG Item",
|
||||
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
|
||||
"options": "Item"
|
||||
},
|
||||
{
|
||||
@@ -307,7 +308,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-25 17:15:42.044630",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"non_negative": 1,
|
||||
"precision": "9",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -217,7 +218,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-16 16:49:19.000000",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Secondary Item",
|
||||
|
||||
@@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", {
|
||||
if (remaining_qty < frm.doc.pending_qty) {
|
||||
frm.doc.pending_qty = 0.0;
|
||||
refresh_field("pending_qty");
|
||||
frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty]));
|
||||
frappe.throw(
|
||||
__("Pending Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(remaining_qty, frm.doc.stock_uom),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty);
|
||||
@@ -99,7 +103,8 @@ frappe.ui.form.on("Job Card", {
|
||||
doc.docstatus === 1 &&
|
||||
!doc.is_subcontracted &&
|
||||
(doc.skip_material_transfer || doc.transferred_qty > 0) &&
|
||||
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity);
|
||||
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) <
|
||||
flt(doc.for_quantity) - flt(doc.pending_qty);
|
||||
|
||||
if (!can_make_stock_entry) return;
|
||||
|
||||
@@ -243,13 +248,15 @@ frappe.ui.form.on("Job Card", {
|
||||
const fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Qty to Manufacture"),
|
||||
label: __("Qty to Manufacture in this Cycle"),
|
||||
fieldname: "for_quantity",
|
||||
reqd: 1,
|
||||
default: pending_qty,
|
||||
description: __("Completed, Pending and Process Loss quantities must add up to this."),
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
dialog.set_value("completed_qty", dialog.get_value("for_quantity"));
|
||||
dialog.set_value("pending_qty", 0);
|
||||
dialog.set_value("process_loss_qty", 0);
|
||||
},
|
||||
},
|
||||
@@ -261,8 +268,23 @@ frappe.ui.form.on("Job Card", {
|
||||
default: pending_qty,
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty");
|
||||
if (remaining > 0 && remaining != dialog.get_value("pending_qty")) {
|
||||
const remaining =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
dialog.get_value("process_loss_qty");
|
||||
|
||||
if (remaining < 0) {
|
||||
const max_completed_qty =
|
||||
flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty"));
|
||||
dialog.set_value("completed_qty", max_completed_qty);
|
||||
frappe.throw(
|
||||
__("Completed Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(max_completed_qty, frm.doc.stock_uom),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -272,13 +294,28 @@ frappe.ui.form.on("Job Card", {
|
||||
label: __("Pending Quantity"),
|
||||
fieldname: "pending_qty",
|
||||
default: 0.0,
|
||||
description: __("Qty left for a later cycle or for another job card."),
|
||||
change() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
const process_loss_qty =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
dialog.get_value("pending_qty");
|
||||
if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) {
|
||||
|
||||
if (process_loss_qty < 0) {
|
||||
dialog.set_value("pending_qty", 0);
|
||||
frappe.throw(
|
||||
__("Pending Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(
|
||||
flt(dialog.get_value("for_quantity")) -
|
||||
flt(dialog.get_value("completed_qty")),
|
||||
frm.doc.stock_uom
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (process_loss_qty != dialog.get_value("process_loss_qty")) {
|
||||
dialog.set_value("process_loss_qty", process_loss_qty);
|
||||
}
|
||||
},
|
||||
@@ -287,13 +324,28 @@ frappe.ui.form.on("Job Card", {
|
||||
fieldtype: "Float",
|
||||
label: __("Process Loss Quantity"),
|
||||
fieldname: "process_loss_qty",
|
||||
description: __("Qty scrapped in this cycle, nobody will produce it."),
|
||||
onchange() {
|
||||
const dialog = frm.job_completion_dialog;
|
||||
const remaining =
|
||||
dialog.get_value("for_quantity") -
|
||||
dialog.get_value("completed_qty") -
|
||||
dialog.get_value("process_loss_qty");
|
||||
if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) {
|
||||
|
||||
if (remaining < 0) {
|
||||
dialog.set_value("process_loss_qty", 0);
|
||||
frappe.throw(
|
||||
__("Process Loss Quantity cannot be greater than {0}", [
|
||||
get_qty_with_uom(
|
||||
flt(dialog.get_value("for_quantity")) -
|
||||
flt(dialog.get_value("completed_qty")),
|
||||
frm.doc.stock_uom
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (remaining != dialog.get_value("pending_qty")) {
|
||||
dialog.set_value("pending_qty", remaining);
|
||||
}
|
||||
},
|
||||
@@ -357,9 +409,8 @@ frappe.ui.form.on("Job Card", {
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Enter Value"),
|
||||
__("Update"),
|
||||
__("Set Finished Good Quantity")
|
||||
__("Complete Job"),
|
||||
__("Update")
|
||||
);
|
||||
},
|
||||
|
||||
@@ -385,46 +436,6 @@ frappe.ui.form.on("Job Card", {
|
||||
});
|
||||
},
|
||||
|
||||
make_finished_good(frm) {
|
||||
const fields = [
|
||||
{
|
||||
fieldtype: "Float",
|
||||
label: __("Completed Quantity"),
|
||||
fieldname: "qty",
|
||||
reqd: 1,
|
||||
default: frm.doc.for_quantity - frm.doc.manufactured_qty,
|
||||
},
|
||||
{
|
||||
fieldtype: "Datetime",
|
||||
label: __("End Time"),
|
||||
fieldname: "end_time",
|
||||
default: frappe.datetime.now_datetime(),
|
||||
},
|
||||
];
|
||||
|
||||
frappe.prompt(
|
||||
fields,
|
||||
(data) => {
|
||||
if (data.qty <= 0) {
|
||||
frappe.throw(__("Quantity should be greater than 0"));
|
||||
}
|
||||
|
||||
frm.call({
|
||||
method: "make_finished_good",
|
||||
doc: frm.doc,
|
||||
args: { qty: data.qty, end_time: data.end_time },
|
||||
callback(r) {
|
||||
const doc = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Enter Value"),
|
||||
__("Update"),
|
||||
__("Set Finished Good Quantity")
|
||||
);
|
||||
},
|
||||
|
||||
setup_quality_inspection(frm) {
|
||||
const quality_inspection_field = frm.get_docfield("quality_inspection");
|
||||
quality_inspection_field.get_route_options_for_new_doc = function (frm) {
|
||||
@@ -587,8 +598,7 @@ frappe.ui.form.on("Job Card", {
|
||||
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
|
||||
const pending_transfer =
|
||||
has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
|
||||
const materials_ready =
|
||||
doc.skip_material_transfer || !pending_transfer || !doc.finished_good || !has_items;
|
||||
const materials_ready = doc.skip_material_transfer || doc.is_corrective_job_card || !pending_transfer;
|
||||
|
||||
let last_row = {};
|
||||
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;
|
||||
@@ -886,3 +896,7 @@ function get_last_completed_row(time_logs) {
|
||||
function get_last_row(time_logs) {
|
||||
return time_logs[time_logs.length - 1] || {};
|
||||
}
|
||||
|
||||
function get_qty_with_uom(qty, stock_uom) {
|
||||
return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
"work_order",
|
||||
"column_break_uqjq",
|
||||
"production_item",
|
||||
"bom_no",
|
||||
"column_break_qrpg",
|
||||
"for_quantity",
|
||||
"column_break_yecz",
|
||||
"bom_no",
|
||||
"stock_uom",
|
||||
"section_break_oisd",
|
||||
"company",
|
||||
"naming_series",
|
||||
@@ -164,6 +165,13 @@
|
||||
"in_preview": 1,
|
||||
"label": "Qty To Manufacture"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "Stock UOM",
|
||||
"options": "UOM",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "wip_warehouse",
|
||||
"fieldtype": "Link",
|
||||
@@ -695,7 +703,7 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-19 17:39:42.293242",
|
||||
"modified": "2026-08-01 14:22:19.926911",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -130,6 +130,7 @@ class JobCard(Document):
|
||||
"Cancelled",
|
||||
"Completed",
|
||||
]
|
||||
stock_uom: DF.Link | None
|
||||
sub_operations: DF.Table[JobCardOperation]
|
||||
target_warehouse: DF.Link | None
|
||||
time_logs: DF.Table[JobCardTimeLog]
|
||||
@@ -158,6 +159,7 @@ class JobCard(Document):
|
||||
|
||||
def before_validate(self):
|
||||
self.set_wip_warehouse()
|
||||
self.set_stock_uom()
|
||||
|
||||
def validate(self):
|
||||
self.validate_time_logs()
|
||||
@@ -845,6 +847,9 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
def validate_transfer_qty(self):
|
||||
if self.track_semi_finished_goods and self.skip_material_transfer:
|
||||
return
|
||||
|
||||
if (
|
||||
not self.finished_good
|
||||
and not self.is_corrective_job_card
|
||||
@@ -895,22 +900,21 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
total_completed_qty = flt(
|
||||
accounted_qty = flt(
|
||||
flt(self.total_completed_qty, precision)
|
||||
+ flt(self.process_loss_qty, precision)
|
||||
+ flt(self.pending_qty, precision)
|
||||
)
|
||||
|
||||
if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision):
|
||||
total_completed_qty_label = bold(_("Total Completed Qty"))
|
||||
qty_to_manufacture = bold(_("Qty to Manufacture"))
|
||||
|
||||
if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision):
|
||||
frappe.throw(
|
||||
_("The {0} ({1}) must be equal to {2} ({3})").format(
|
||||
total_completed_qty_label,
|
||||
bold(flt(total_completed_qty, precision)),
|
||||
qty_to_manufacture,
|
||||
bold(self.for_quantity),
|
||||
_(
|
||||
"Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(self.total_completed_qty)),
|
||||
bold(self.get_qty_with_uom(self.process_loss_qty)),
|
||||
bold(self.get_qty_with_uom(self.pending_qty)),
|
||||
bold(self.get_qty_with_uom(self.for_quantity)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1073,6 +1077,9 @@ class JobCard(Document):
|
||||
wo.calculate_operating_cost()
|
||||
wo.set_actual_dates()
|
||||
|
||||
if wo.track_semi_finished_goods:
|
||||
wo.set_process_loss_qty()
|
||||
|
||||
if time_data:
|
||||
wo.status = "In Process"
|
||||
|
||||
@@ -1162,7 +1169,10 @@ class JobCard(Document):
|
||||
_(
|
||||
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
|
||||
).format(
|
||||
row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
|
||||
row.idx,
|
||||
frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)),
|
||||
frappe.bold(row.item_code),
|
||||
ste_doc.job_card,
|
||||
),
|
||||
title=_("Excess Transfer"),
|
||||
exc=JobCardOverTransferError,
|
||||
@@ -1236,7 +1246,7 @@ class JobCard(Document):
|
||||
def set_status(self, update_status=False):
|
||||
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
|
||||
if self.finished_good and self.docstatus == 1:
|
||||
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
|
||||
if (self.manufactured_qty + self.process_loss_qty) >= self.get_qty_to_produce():
|
||||
self.status = "Completed"
|
||||
elif self.transferred_qty > 0 or self.skip_material_transfer:
|
||||
self.status = "Work In Progress"
|
||||
@@ -1267,7 +1277,8 @@ class JobCard(Document):
|
||||
self.status = "Work In Progress"
|
||||
|
||||
if self.docstatus == 1 and (
|
||||
self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
|
||||
self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty)
|
||||
or not self.items
|
||||
):
|
||||
self.status = "Completed"
|
||||
|
||||
@@ -1280,10 +1291,27 @@ class JobCard(Document):
|
||||
if self.workstation:
|
||||
self.update_workstation_status()
|
||||
|
||||
def get_qty_to_produce(self):
|
||||
"""Qty this job card is expected to produce, the pending qty is left to another job card."""
|
||||
return flt(self.for_quantity) - flt(self.pending_qty)
|
||||
|
||||
def get_qty_with_uom(self, qty, item_code=None):
|
||||
"""A quantity in a message reads as a count of nothing without the unit it is measured in."""
|
||||
uom = self.stock_uom
|
||||
if item_code:
|
||||
uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
|
||||
return f"{flt(qty, self.precision('total_completed_qty'))} {uom or ''}".strip()
|
||||
|
||||
def set_wip_warehouse(self):
|
||||
if not self.wip_warehouse:
|
||||
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
|
||||
|
||||
def set_stock_uom(self):
|
||||
item_code = self.finished_good or self.production_item
|
||||
if item_code:
|
||||
self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
|
||||
def validate_operation_id(self):
|
||||
if (
|
||||
self.get("operation_id")
|
||||
@@ -1346,9 +1374,9 @@ class JobCard(Document):
|
||||
|
||||
current_operation_qty += flt(self.total_completed_qty)
|
||||
|
||||
data = frappe.get_all(
|
||||
previous_operations = frappe.get_all(
|
||||
"Work Order Operation",
|
||||
fields=["operation", "status", "completed_qty", "sequence_id"],
|
||||
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
|
||||
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
|
||||
order_by="sequence_id, idx",
|
||||
)
|
||||
@@ -1357,7 +1385,19 @@ class JobCard(Document):
|
||||
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
|
||||
)
|
||||
|
||||
for row in data:
|
||||
if self.track_semi_finished_goods and previous_operations:
|
||||
manufactured_qty = self.get_manufactured_qty_per_operation(
|
||||
[row.name for row in previous_operations]
|
||||
)
|
||||
|
||||
for row in previous_operations:
|
||||
row.manufactured_qty = flt(manufactured_qty.get(row.name))
|
||||
|
||||
for row in previous_operations:
|
||||
if self.track_semi_finished_goods:
|
||||
self.validate_previous_operation_manufactured_qty(row, current_operation_qty)
|
||||
continue
|
||||
|
||||
if not row.completed_qty:
|
||||
frappe.throw(
|
||||
_("{0}, complete the operation {1} before the operation {2}.").format(
|
||||
@@ -1379,13 +1419,59 @@ class JobCard(Document):
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(current_operation_qty),
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(row.completed_qty),
|
||||
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
|
||||
def get_manufactured_qty_per_operation(self, operation_ids):
|
||||
job_card = frappe.qb.DocType("Job Card")
|
||||
|
||||
data = (
|
||||
frappe.qb.from_(job_card)
|
||||
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
|
||||
.where(
|
||||
(job_card.work_order == self.work_order)
|
||||
& (job_card.docstatus == 1)
|
||||
& (IfNull(job_card.is_corrective_job_card, 0) == 0)
|
||||
& (job_card.operation_id.isin(operation_ids))
|
||||
)
|
||||
.groupby(job_card.operation_id)
|
||||
).run()
|
||||
|
||||
return dict(data)
|
||||
|
||||
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
|
||||
manufactured_qty = flt(row.manufactured_qty)
|
||||
|
||||
if not manufactured_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}."
|
||||
).format(
|
||||
bold(self.name),
|
||||
bold(get_link_to_form("Work Order", self.work_order)),
|
||||
bold(row.operation),
|
||||
bold(self.operation),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
if manufactured_qty < current_operation_qty:
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(current_operation_qty)),
|
||||
bold(self.operation),
|
||||
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
|
||||
bold(row.operation),
|
||||
),
|
||||
OperationSequenceError,
|
||||
)
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
frappe.throw(_("You can't make any changes to Job Card since Work Order is closed."))
|
||||
@@ -1493,6 +1579,7 @@ class JobCard(Document):
|
||||
def start_timer(self, **kwargs):
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
@@ -1510,12 +1597,22 @@ class JobCard(Document):
|
||||
|
||||
frappe.has_permission("Job Card", "write", doc=self, throw=True)
|
||||
self.validate_docstatus()
|
||||
self.validate_transfer_qty()
|
||||
|
||||
if isinstance(kwargs, dict):
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
self.set_for_quantity(kwargs)
|
||||
self.validate_complete_job_card_qty(kwargs)
|
||||
|
||||
def set_for_quantity(self, kwargs):
|
||||
"""Qty to Manufacture of the completion dialog covers the current cycle only,
|
||||
so the qty completed by the earlier cycles of this job card is kept."""
|
||||
if not flt(kwargs.for_quantity):
|
||||
return
|
||||
|
||||
self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity)
|
||||
|
||||
def validate_docstatus(self):
|
||||
if self.docstatus == 2:
|
||||
frappe.throw(_("Cancelled Job Card cannot be processed."))
|
||||
@@ -1533,6 +1630,8 @@ class JobCard(Document):
|
||||
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
|
||||
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
|
||||
|
||||
self.validate_completion_qty_split(kwargs)
|
||||
|
||||
self.pending_qty = flt(kwargs.pending_qty)
|
||||
self.process_loss_qty = flt(kwargs.process_loss_qty)
|
||||
|
||||
@@ -1561,25 +1660,49 @@ class JobCard(Document):
|
||||
_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name))
|
||||
)
|
||||
|
||||
def validate_completion_qty_split(self, kwargs):
|
||||
if not flt(kwargs.for_quantity):
|
||||
return
|
||||
|
||||
precision = self.precision("total_completed_qty")
|
||||
accounted_qty = flt(
|
||||
flt(kwargs.qty, precision)
|
||||
+ flt(kwargs.pending_qty, precision)
|
||||
+ flt(kwargs.process_loss_qty, precision)
|
||||
)
|
||||
|
||||
if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision):
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
|
||||
).format(
|
||||
bold(self.get_qty_with_uom(kwargs.qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.pending_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.process_loss_qty)),
|
||||
bold(self.get_qty_with_uom(kwargs.for_quantity)),
|
||||
)
|
||||
)
|
||||
|
||||
def get_consumed_process_loss(self):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
|
||||
def get_consumed_process_loss():
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.process_loss_qty))
|
||||
.where(
|
||||
(table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
|
||||
)
|
||||
)
|
||||
return query.run()[0][0] or 0
|
||||
|
||||
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
|
||||
|
||||
consumed_process_loss = self.get_consumed_process_loss()
|
||||
ste = ManufactureEntry(
|
||||
{
|
||||
"for_quantity": self.for_quantity - self.manufactured_qty,
|
||||
"process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
|
||||
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
|
||||
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
|
||||
"job_card": self.name,
|
||||
"skip_material_transfer": self.skip_material_transfer,
|
||||
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.utils.data import add_to_date, now, today
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
JobCardOverTransferError,
|
||||
OperationMismatchError,
|
||||
OperationSequenceError,
|
||||
OverlapError,
|
||||
make_corrective_job_card,
|
||||
make_material_request,
|
||||
@@ -21,6 +22,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
from erpnext.patches.v16_0.set_stock_uom_in_job_card import execute as set_stock_uom_in_job_card
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -359,6 +361,31 @@ class TestJobCard(ERPNextTestSuite):
|
||||
# JC is Completed with excess transfer
|
||||
self.assertEqual(job_card.status, "Completed")
|
||||
|
||||
def test_job_card_actions_blocked_until_material_transfer(self):
|
||||
"Start and Complete must wait for the transfer when RMs move against Job Card."
|
||||
self.transfer_material_against = "Job Card"
|
||||
self.source_warehouse = "Stores - _TC"
|
||||
|
||||
self.generate_required_stock(self.work_order)
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, job_card.start_timer, start_time=now())
|
||||
self.assertRaises(frappe.ValidationError, job_card.complete_job_card, qty=2, for_quantity=2)
|
||||
|
||||
transfer_entry = make_stock_entry_from_jc(job_card.name)
|
||||
transfer_entry.insert()
|
||||
transfer_entry.submit()
|
||||
|
||||
job_card.reload()
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
|
||||
job_card.save()
|
||||
job_card.complete_job_card(
|
||||
qty=2, for_quantity=2, pending_qty=0, process_loss_qty=0, end_time="2024-03-01 09:00:00"
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 2)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
|
||||
def test_job_card_excess_material_transfer_block(self):
|
||||
self.transfer_material_against = "Job Card"
|
||||
@@ -888,6 +915,123 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def get_first_job_card(self, work_order):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
|
||||
def test_stock_uom_is_set_from_the_produced_item(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
item_code = job_card.finished_good or job_card.production_item
|
||||
|
||||
self.assertEqual(job_card.stock_uom, frappe.db.get_value("Item", item_code, "stock_uom"))
|
||||
|
||||
def test_stock_uom_patch_backfills_legacy_job_cards(self):
|
||||
suffix = random_string(8)
|
||||
finished_good = create_item(f"Stock UOM Patch FG {suffix}", stock_uom="Kg")
|
||||
production_item = create_item(f"Stock UOM Patch Product {suffix}", stock_uom="Nos")
|
||||
|
||||
finished_good_job_card = self.get_first_job_card(
|
||||
make_wo_order_test_record(item="_Test FG Item 2", qty=5).name
|
||||
)
|
||||
production_item_job_card = self.get_first_job_card(
|
||||
make_wo_order_test_record(item="_Test FG Item 2", qty=6).name
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Job Card",
|
||||
finished_good_job_card.name,
|
||||
{
|
||||
"finished_good": finished_good.name,
|
||||
"production_item": production_item.name,
|
||||
"stock_uom": None,
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Job Card",
|
||||
production_item_job_card.name,
|
||||
{"finished_good": None, "production_item": production_item.name, "stock_uom": None},
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
set_stock_uom_in_job_card()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Kg")
|
||||
self.assertEqual(frappe.db.get_value("Job Card", production_item_job_card.name, "stock_uom"), "Nos")
|
||||
|
||||
frappe.db.set_value(
|
||||
"Job Card", finished_good_job_card.name, "stock_uom", "Nos", update_modified=False
|
||||
)
|
||||
set_stock_uom_in_job_card()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Nos")
|
||||
|
||||
def test_completion_qty_reduces_for_quantity_without_process_loss(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=3,
|
||||
for_quantity=3,
|
||||
pending_qty=0,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-03-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 3)
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 3)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
def test_completion_qty_keeps_for_quantity_across_cycles(self):
|
||||
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
|
||||
|
||||
job_card = self.get_first_job_card(work_order.name)
|
||||
job_card.append("time_logs", {"from_time": "2024-03-02 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=3,
|
||||
for_quantity=5,
|
||||
pending_qty=2,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-03-02 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 5)
|
||||
self.assertEqual(flt(job_card.pending_qty), 2)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
job_card.append("time_logs", {"from_time": "2024-03-02 10:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=2,
|
||||
for_quantity=2,
|
||||
pending_qty=0,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-03-02 11:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 5)
|
||||
self.assertEqual(flt(job_card.total_completed_qty), 5)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
def test_op_cost_calculation(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
@@ -1265,6 +1409,440 @@ class TestJobCard(ERPNextTestSuite):
|
||||
8,
|
||||
)
|
||||
|
||||
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
|
||||
operation = {
|
||||
"operation": "Process Loss Rollup Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order.name},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=8,
|
||||
for_quantity=10,
|
||||
pending_qty=0,
|
||||
process_loss_qty=2,
|
||||
end_time="2024-05-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 2)
|
||||
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
|
||||
"""Loss booked by an earlier operation shrinks what the final operation can produce,
|
||||
so it has to show up on the work order even though the final operation loses nothing."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
|
||||
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
|
||||
operations = [
|
||||
{
|
||||
"operation": "Intermediate Loss Op A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"sequence_id": 1,
|
||||
},
|
||||
{
|
||||
"operation": "Intermediate Loss Op B",
|
||||
"finished_good": fg,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
},
|
||||
]
|
||||
|
||||
for row in operations:
|
||||
row.update(
|
||||
{
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good_qty": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
)
|
||||
make_workstation(row)
|
||||
make_operation(row)
|
||||
fg_bom.append("operations", row)
|
||||
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
for row in work_order.operations:
|
||||
row.time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
def get_job_card(operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card",
|
||||
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
|
||||
"name",
|
||||
),
|
||||
)
|
||||
|
||||
jc_a = get_job_card("Intermediate Loss Op A")
|
||||
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
|
||||
jc_a.save()
|
||||
jc_a.complete_job_card(
|
||||
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
|
||||
)
|
||||
jc_a.reload()
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
|
||||
jc_b = get_job_card("Intermediate Loss Op B")
|
||||
jc_b.for_quantity = 8
|
||||
for row in jc_b.items:
|
||||
row.required_qty = 8
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
|
||||
)
|
||||
jc_b.save()
|
||||
jc_b.submit()
|
||||
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(flt(work_order.process_loss_qty), 2)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
|
||||
def test_semi_fg_pending_qty_is_left_to_another_job_card(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
|
||||
operation = {
|
||||
"operation": "Pending Qty Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order.name},
|
||||
order_by="sequence_id, creation",
|
||||
limit=1,
|
||||
pluck="name",
|
||||
)[0],
|
||||
)
|
||||
job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"})
|
||||
job_card.save()
|
||||
|
||||
job_card.complete_job_card(
|
||||
qty=3,
|
||||
for_quantity=5,
|
||||
pending_qty=2,
|
||||
process_loss_qty=0,
|
||||
end_time="2024-04-01 09:00:00",
|
||||
)
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.for_quantity), 5)
|
||||
self.assertEqual(flt(job_card.pending_qty), 2)
|
||||
self.assertEqual(flt(job_card.process_loss_qty), 0)
|
||||
|
||||
job_card.submit()
|
||||
self.assertEqual(job_card.status, "Work In Progress")
|
||||
|
||||
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
|
||||
finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item)
|
||||
self.assertEqual(flt(finished_item.qty), 3)
|
||||
manufacturing_entry.submit()
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(flt(job_card.manufactured_qty), 3)
|
||||
self.assertEqual(job_card.status, "Completed")
|
||||
|
||||
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name
|
||||
sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name
|
||||
sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name
|
||||
fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
semi_fg_boms = {}
|
||||
for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)):
|
||||
bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1)
|
||||
bom.append("items", {"item_code": raw_material, "qty": 1})
|
||||
bom.insert()
|
||||
bom.submit()
|
||||
semi_fg_boms[semi_fg_item] = bom.name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
|
||||
operations = [
|
||||
{
|
||||
"operation": "Sequence Check Op A",
|
||||
"finished_good": sfg1,
|
||||
"bom_no": semi_fg_boms[sfg1],
|
||||
"sequence_id": 1,
|
||||
},
|
||||
{
|
||||
"operation": "Sequence Check Op B",
|
||||
"finished_good": sfg2,
|
||||
"bom_no": semi_fg_boms[sfg2],
|
||||
"sequence_id": 1,
|
||||
},
|
||||
{
|
||||
"operation": "Sequence Check Op C",
|
||||
"finished_good": fg,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
},
|
||||
]
|
||||
|
||||
for row in operations:
|
||||
row.update(
|
||||
{
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good_qty": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
)
|
||||
|
||||
make_workstation(row)
|
||||
make_operation(row)
|
||||
fg_bom.append("operations", row)
|
||||
|
||||
fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3})
|
||||
fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
|
||||
for row in work_order.operations:
|
||||
row.time_in_mins = 60
|
||||
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
def get_job_card(operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card",
|
||||
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
|
||||
"name",
|
||||
),
|
||||
)
|
||||
|
||||
def add_time_log(job_card, day, qty):
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": f"2024-01-{day} 08:00:00",
|
||||
"to_time": f"2024-01-{day} 09:00:00",
|
||||
"completed_qty": qty,
|
||||
},
|
||||
)
|
||||
|
||||
jc_a = get_job_card("Sequence Check Op A")
|
||||
jc_a.for_quantity = 3
|
||||
add_time_log(jc_a, "01", 3)
|
||||
jc_a.submit()
|
||||
|
||||
jc_b = get_job_card("Sequence Check Op B")
|
||||
add_time_log(jc_b, "02", jc_b.for_quantity)
|
||||
jc_b.submit()
|
||||
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
jc_c = get_job_card("Sequence Check Op C")
|
||||
jc_c.for_quantity = 3
|
||||
add_time_log(jc_c, "03", 3)
|
||||
self.assertRaises(OperationSequenceError, jc_c.save)
|
||||
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
jc_c.reload()
|
||||
jc_c.for_quantity = 4
|
||||
add_time_log(jc_c, "03", 4)
|
||||
self.assertRaises(OperationSequenceError, jc_c.save)
|
||||
|
||||
jc_c.reload()
|
||||
jc_c.for_quantity = 3
|
||||
add_time_log(jc_c, "03", 3)
|
||||
jc_c.submit()
|
||||
|
||||
self.assertEqual(jc_c.docstatus, 1)
|
||||
|
||||
def test_semi_fg_batch_auto_pull_on_manufacture(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1402,6 +1980,299 @@ class TestJobCard(ERPNextTestSuite):
|
||||
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
|
||||
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
|
||||
|
||||
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm1 = make_item("PL Scope RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": "PL Scope Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": "PL Scope Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
jc_a = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
|
||||
),
|
||||
)
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
me_a.submit()
|
||||
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
|
||||
|
||||
jc_b = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
|
||||
),
|
||||
)
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 2
|
||||
jc_b.submit()
|
||||
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
# operation A's loss must not leak into operation B's entry
|
||||
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
|
||||
fg_row = next(row for row in me_b.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 3.0)
|
||||
me_b.submit()
|
||||
|
||||
def make_semi_fg_work_order(self, prefix, qty=5):
|
||||
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
|
||||
consumes it. Both operations skip material transfer; stock is pre-seeded."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm1 = make_item(f"{prefix} RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
|
||||
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
|
||||
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
operation1 = {
|
||||
"operation": f"{prefix} Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": f"{prefix} Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=qty,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
)
|
||||
|
||||
for item_code in (rm1, rm2, sfg):
|
||||
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
return work_order
|
||||
|
||||
def get_semi_fg_job_card(self, work_order, operation):
|
||||
return frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
|
||||
)
|
||||
|
||||
def test_partial_manufacture_entry_then_finish(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Partial")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
|
||||
# so it accounts for 3 of 5 and its materials are trimmed to the same share
|
||||
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in first.items if row.is_finished_item)
|
||||
fg_row.qty = 1
|
||||
for row in first.items:
|
||||
if row.s_warehouse and not row.is_finished_item:
|
||||
row.qty = flt(row.qty) * 3 / 5
|
||||
first.save()
|
||||
first.submit()
|
||||
|
||||
# the follow-up entry must be generated net of the already-booked loss and still submit
|
||||
jc_b.reload()
|
||||
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
fg_row = next(row for row in second.items if row.is_finished_item)
|
||||
self.assertEqual(flt(fg_row.qty), 2.0)
|
||||
self.assertEqual(flt(second.process_loss_qty), 0.0)
|
||||
second.submit()
|
||||
|
||||
jc_b.reload()
|
||||
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
|
||||
|
||||
# across both entries, consumption adds up to the job card's requirement of 5, no more
|
||||
consumed = frappe.get_all(
|
||||
"Stock Entry Detail",
|
||||
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
|
||||
fields=["item_code", {"SUM": "qty", "as": "qty"}],
|
||||
group_by="item_code",
|
||||
)
|
||||
self.assertTrue(consumed)
|
||||
for row in consumed:
|
||||
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
|
||||
|
||||
def test_update_after_submit_keeps_manufacture_entry_intact(self):
|
||||
work_order = self.make_semi_fg_work_order("PL Update")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_a.pending_qty = 0
|
||||
jc_a.process_loss_qty = 2
|
||||
jc_a.submit()
|
||||
|
||||
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
entry.submit()
|
||||
|
||||
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
|
||||
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
|
||||
|
||||
entry.reload()
|
||||
entry.select_print_heading = "_Test SFG Heading"
|
||||
entry.save()
|
||||
|
||||
entry.reload()
|
||||
self.assertEqual(flt(entry.process_loss_qty), 2.0)
|
||||
|
||||
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
|
||||
work_order = self.make_semi_fg_work_order("PL NoBom")
|
||||
|
||||
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
jc_a.submit()
|
||||
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
|
||||
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
|
||||
)
|
||||
jc_b.pending_qty = 0
|
||||
jc_b.process_loss_qty = 2
|
||||
jc_b.submit()
|
||||
|
||||
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
draft_one.submit()
|
||||
|
||||
stale = frappe.get_doc("Stock Entry", draft_two.name)
|
||||
self.assertRaises(frappe.ValidationError, stale.submit)
|
||||
|
||||
def test_semi_fg_auto_pull_with_uom_conversion(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -1817,6 +2688,54 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(s.additional_costs[2].amount, 480)
|
||||
self.assertEqual(s.additional_costs[3].amount, 480)
|
||||
|
||||
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.track_semi_finished_goods = 1
|
||||
jc.skip_material_transfer = 1
|
||||
jc.for_quantity = 10
|
||||
jc.transferred_qty = 0
|
||||
jc.append("items", {"item_code": "_Test Item"})
|
||||
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
# with transfer enabled, a legacy card without an FG item keeps the strict check
|
||||
jc.skip_material_transfer = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
jc.finished_good = "_Test Item"
|
||||
jc.validate_transfer_qty()
|
||||
|
||||
jc.finished_good = None
|
||||
jc.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
|
||||
|
||||
def test_qty_in_messages_carries_the_uom(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.stock_uom = "Nos"
|
||||
|
||||
self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos")
|
||||
self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos")
|
||||
|
||||
def test_completion_qty_split_must_add_up(self):
|
||||
jc = frappe.new_doc("Job Card")
|
||||
jc.for_quantity = 5
|
||||
|
||||
jc.validate_completion_qty_split(
|
||||
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc.validate_completion_qty_split,
|
||||
frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0),
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
jc.validate_completion_qty_split,
|
||||
frappe._dict(for_quantity=1, qty=0.3334, pending_qty=0.3334, process_loss_qty=0.3334),
|
||||
)
|
||||
|
||||
|
||||
def create_bom_with_multiple_operations():
|
||||
"Create a BOM with multiple operations and Material Transfer against Job Card"
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
"label": "Conversion Factor",
|
||||
"precision": "9",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -266,7 +267,7 @@
|
||||
"grid_page_length": 50,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-30 17:01:25.996352",
|
||||
"modified": "2026-08-07 17:31:31.732720",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Material Request Plan Item",
|
||||
|
||||
@@ -1478,8 +1478,6 @@ def get_material_request_items(
|
||||
)
|
||||
)
|
||||
|
||||
required_qty = required_qty / row["conversion_factor"]
|
||||
|
||||
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
@@ -1498,10 +1496,11 @@ def get_material_request_items(
|
||||
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
return {
|
||||
"item_code": row.item_code,
|
||||
"item_name": row.item_name,
|
||||
"quantity": required_qty / conversion_factor,
|
||||
"quantity": flt(required_qty / conversion_factor, precision),
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
@@ -1910,7 +1909,7 @@ def get_materials_from_other_locations(
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
item["quantity"] = required_qty / item.get("conversion_factor")
|
||||
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
|
||||
|
||||
new_mr_items.append(item)
|
||||
|
||||
|
||||
@@ -1366,6 +1366,29 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(row.uom, "Nos")
|
||||
self.assertEqual(row.qty, 1)
|
||||
|
||||
def test_material_request_item_quantity_rounded_to_precision(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
|
||||
bom_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
|
||||
).name
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
|
||||
doc = frappe.get_doc("Item", bom_item)
|
||||
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
|
||||
doc.save()
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1"
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
self.assertEqual(len(pln.mr_items), 1)
|
||||
self.assertEqual(pln.mr_items[0].quantity, flt(10 / 3, precision))
|
||||
|
||||
def test_material_request_for_sub_assembly_items(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
@@ -2079,6 +2102,40 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertEqual(row.get("uom"), "Nos")
|
||||
self.assertEqual(row.get("conversion_factor"), 10.0)
|
||||
|
||||
def test_remaining_purchase_qty_rounded_to_precision(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
|
||||
bom_item = make_item(
|
||||
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
|
||||
).name
|
||||
|
||||
store_warehouse = create_warehouse("Store Warehouse", company="_Test Company")
|
||||
rm_warehouse = create_warehouse("RM Warehouse", company="_Test Company")
|
||||
|
||||
make_stock_entry(item_code=bom_item, qty=4, target=store_warehouse, rate=100)
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
|
||||
doc = frappe.get_doc("Item", bom_item)
|
||||
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
|
||||
doc.save()
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
|
||||
|
||||
pln = create_production_plan(
|
||||
item_code=fg_item, planned_qty=30, stock_uom="_Test UOM 1", do_not_submit=1
|
||||
)
|
||||
pln.for_warehouse = rm_warehouse
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": store_warehouse}])
|
||||
|
||||
rows_by_type = {row.get("material_request_type"): row for row in items}
|
||||
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
self.assertEqual(rows_by_type["Purchase"].get("quantity"), flt(26 / 3, precision))
|
||||
|
||||
def test_unreserve_qty_on_closing_of_pp(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
@@ -4839,6 +4839,24 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
|
||||
self.assertEqual(flt(row.qty, 6), 3.0)
|
||||
|
||||
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
|
||||
wo = frappe.new_doc("Work Order")
|
||||
wo.track_semi_finished_goods = 1
|
||||
wo.skip_transfer = 0
|
||||
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
|
||||
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
wo.wip_warehouse = "_Test Warehouse - _TC"
|
||||
wo.validate_warehouse()
|
||||
|
||||
# the top-level target warehouse stays optional; operations may carry their own
|
||||
wo.fg_warehouse = None
|
||||
wo.validate_warehouse()
|
||||
|
||||
wo.track_semi_finished_goods = 0
|
||||
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
|
||||
|
||||
|
||||
def get_reserved_entries(voucher_no, warehouse=None):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Work-in-Progress Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods",
|
||||
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -739,7 +739,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 21:35:34.175667",
|
||||
"modified": "2026-08-08 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -883,6 +883,12 @@ class WorkOrder(Document):
|
||||
return flt(query.run()[0][0])
|
||||
|
||||
def set_process_loss_qty(self):
|
||||
self.db_set("process_loss_qty", self._process_loss_qty())
|
||||
|
||||
def _process_loss_qty(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return flt(sum(flt(row.process_loss_qty) for row in self.operations))
|
||||
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
process_loss_qty = (
|
||||
frappe.qb.from_(table)
|
||||
@@ -892,7 +898,7 @@ class WorkOrder(Document):
|
||||
)
|
||||
).run()[0][0]
|
||||
|
||||
self.db_set("process_loss_qty", flt(process_loss_qty))
|
||||
return flt(process_loss_qty)
|
||||
|
||||
def update_production_plan_status(self):
|
||||
production_plan = frappe.get_doc("Production Plan", self.production_plan)
|
||||
@@ -915,12 +921,9 @@ class WorkOrder(Document):
|
||||
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
|
||||
|
||||
def validate_warehouse(self):
|
||||
if self.track_semi_finished_goods:
|
||||
return
|
||||
|
||||
if not self.wip_warehouse and not self.skip_transfer:
|
||||
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
|
||||
if not self.fg_warehouse:
|
||||
if not self.fg_warehouse and not self.track_semi_finished_goods:
|
||||
frappe.throw(_("Target Warehouse is required before Submit"))
|
||||
|
||||
def before_submit(self):
|
||||
@@ -2822,6 +2825,7 @@ def get_operation_details(name, work_order, parent_bom):
|
||||
for row in work_order.operations:
|
||||
if row.name == name:
|
||||
return {
|
||||
"idx": row.idx,
|
||||
"workstation": row.workstation,
|
||||
"workstation_type": row.workstation_type,
|
||||
"source_warehouse": row.source_warehouse,
|
||||
|
||||
@@ -232,7 +232,6 @@ class Workstation(Document):
|
||||
for row in doc.time_logs:
|
||||
if not row.to_time:
|
||||
row.to_time = to_time
|
||||
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
|
||||
row.completed_qty = qty
|
||||
|
||||
doc.save()
|
||||
|
||||
@@ -24,7 +24,8 @@ def get_exploded_items(bom, data, indent=0, qty=1):
|
||||
fields=[
|
||||
"qty",
|
||||
"bom_no",
|
||||
"qty",
|
||||
"bom_no.quantity as child_bom_qty",
|
||||
"stock_qty",
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
@@ -51,7 +52,12 @@ def get_exploded_items(bom, data, indent=0, qty=1):
|
||||
}
|
||||
)
|
||||
if item.bom_no:
|
||||
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
|
||||
get_exploded_items(
|
||||
item.bom_no,
|
||||
data,
|
||||
indent=indent + 1,
|
||||
qty=qty * item.stock_qty / item.child_bom_qty,
|
||||
)
|
||||
|
||||
|
||||
def get_columns():
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.report.bom_explorer.bom_explorer import get_exploded_items
|
||||
|
||||
|
||||
class TestBOMExplorer(unittest.TestCase):
|
||||
def test_nested_bom_normalizes_and_accumulates_qty(self):
|
||||
def item(item_code, qty, stock_qty, bom_no="", uom="Nos", child_bom_qty=None):
|
||||
return frappe._dict(
|
||||
item_code=item_code,
|
||||
item_name=item_code,
|
||||
description="",
|
||||
qty=qty,
|
||||
stock_qty=stock_qty,
|
||||
bom_no=bom_no,
|
||||
child_bom_qty=child_bom_qty,
|
||||
uom=uom,
|
||||
idx=1,
|
||||
is_phantom_item=0,
|
||||
)
|
||||
|
||||
children = {
|
||||
"root": [item("parent", 2, 20, "parent-bom", "Box", 5)],
|
||||
"parent-bom": [item("child", 3, 12, "child-bom", "Pack", 4)],
|
||||
"child-bom": [item("raw-material", 2, 2, uom="Kg")],
|
||||
}
|
||||
|
||||
def get_items(_doctype, filters, **kwargs):
|
||||
self.assertIn("bom_no.quantity as child_bom_qty", kwargs["fields"])
|
||||
return children[filters["parent"]]
|
||||
|
||||
data = []
|
||||
with patch.object(frappe, "get_all", side_effect=get_items):
|
||||
get_exploded_items("root", data)
|
||||
|
||||
self.assertEqual([row["qty"] for row in data], [2, 12, 24])
|
||||
@@ -496,3 +496,6 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v16_0.fix_subcontracting_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.rename_italy_customer_name_fields
|
||||
erpnext.patches.v16_0.set_stock_uom_in_job_card
|
||||
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import frappe
|
||||
from frappe import qb
|
||||
|
||||
|
||||
@@ -13,5 +14,8 @@ def execute():
|
||||
"Payment Reconciliation Allocation",
|
||||
]
|
||||
for x in doctypes:
|
||||
# child tables may not exist yet on sites where this pre-model-sync patch runs first
|
||||
if not frappe.db.table_exists(x):
|
||||
continue
|
||||
dt = qb.DocType(x)
|
||||
qb.from_(dt).delete().run()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
get_billed_amount_against_po,
|
||||
get_billed_amount_against_pr,
|
||||
get_purchase_receipts_against_po_details,
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
purchase_order_items = get_affected_purchase_order_items()
|
||||
if not purchase_order_items:
|
||||
return
|
||||
|
||||
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
|
||||
for purchase_receipt in set(updated_purchase_receipts):
|
||||
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))
|
||||
|
||||
|
||||
def get_affected_purchase_order_items() -> list[str]:
|
||||
purchase_order_items = get_candidate_purchase_order_items()
|
||||
if purchase_order_items:
|
||||
purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts(
|
||||
purchase_order_items
|
||||
)
|
||||
|
||||
if not purchase_order_items:
|
||||
return []
|
||||
|
||||
purchase_receipt_items = get_purchase_receipts_against_po_details(purchase_order_items)
|
||||
direct_billed_amounts = get_billed_amount_against_pr([item.name for item in purchase_receipt_items])
|
||||
po_billed_amounts = get_billed_amount_against_po(purchase_order_items)
|
||||
|
||||
current_billed_amounts = defaultdict(float)
|
||||
available_billed_amounts = defaultdict(float)
|
||||
for purchase_order_item, billed_details in po_billed_amounts.items():
|
||||
available_billed_amounts[purchase_order_item] = flt(billed_details["billed_amt"])
|
||||
|
||||
for item in purchase_receipt_items:
|
||||
current_billed_amounts[item.purchase_order_item] += flt(item.billed_amt)
|
||||
available_billed_amounts[item.purchase_order_item] += flt(direct_billed_amounts.get(item.name))
|
||||
|
||||
precision = frappe.get_precision("Purchase Receipt Item", "billed_amt") or 2
|
||||
return [
|
||||
purchase_order_item
|
||||
for purchase_order_item in purchase_order_items
|
||||
if flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_amt")) > 0
|
||||
and flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_qty")) > 0
|
||||
and flt(
|
||||
current_billed_amounts[purchase_order_item] - available_billed_amounts[purchase_order_item],
|
||||
precision,
|
||||
)
|
||||
> 0
|
||||
]
|
||||
|
||||
|
||||
def exclude_purchase_order_items_with_invoice_created_receipts(purchase_order_items: list[str]) -> list[str]:
|
||||
invoice_created_receipt_items = set(
|
||||
frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={
|
||||
"purchase_order_item": ("in", purchase_order_items),
|
||||
"purchase_invoice_item": ("is", "set"),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="purchase_order_item",
|
||||
)
|
||||
)
|
||||
return [item for item in purchase_order_items if item not in invoice_created_receipt_items]
|
||||
|
||||
|
||||
def get_candidate_purchase_order_items() -> list[str]:
|
||||
purchase_receipt = frappe.qb.DocType("Purchase Receipt")
|
||||
purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item")
|
||||
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
|
||||
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
|
||||
purchase_order_items_with_multiple_receipts = (
|
||||
frappe.qb.from_(purchase_receipt_item)
|
||||
.inner_join(purchase_receipt)
|
||||
.on(purchase_receipt_item.parent == purchase_receipt.name)
|
||||
.select(purchase_receipt_item.purchase_order_item)
|
||||
.where(
|
||||
(purchase_receipt.docstatus == 1)
|
||||
& (purchase_receipt.is_return == 0)
|
||||
& purchase_receipt_item.purchase_order_item.isnotnull()
|
||||
)
|
||||
.groupby(purchase_receipt_item.purchase_order_item)
|
||||
.having(Count(purchase_receipt_item.name) > 1)
|
||||
)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(purchase_invoice_item)
|
||||
.inner_join(purchase_invoice)
|
||||
.on(purchase_invoice_item.parent == purchase_invoice.name)
|
||||
.select(purchase_invoice_item.po_detail)
|
||||
.distinct()
|
||||
.where(
|
||||
(purchase_invoice.docstatus == 1)
|
||||
& (purchase_invoice.update_stock == 0)
|
||||
& purchase_invoice_item.pr_detail.isnull()
|
||||
& purchase_invoice_item.po_detail.isin(purchase_order_items_with_multiple_receipts)
|
||||
)
|
||||
).run(pluck=True)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user