mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-30 18:34:48 +00:00
Merge pull request #55316 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2018-12-28 22:29:21.828090",
|
||||
"default": null,
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 15,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "fax",
|
||||
"label": "Tax Category",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2018-12-28 22:29:21.828090",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-tax_category",
|
||||
"no_copy": 0,
|
||||
"options": "Tax Category",
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
},
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2020-10-14 17:41:40.878179",
|
||||
"default": "0",
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Address",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "is_your_company_address",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"hide_border": 0,
|
||||
"hide_days": 0,
|
||||
"hide_seconds": 0,
|
||||
"idx": 20,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_preview": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "linked_with",
|
||||
"label": "Is Your Company Address",
|
||||
"length": 0,
|
||||
"mandatory_depends_on": null,
|
||||
"modified": "2020-10-14 17:41:40.878179",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Address-is_your_company_address",
|
||||
"no_copy": 0,
|
||||
"options": null,
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"read_only_depends_on": null,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
}
|
||||
],
|
||||
"custom_perms": [],
|
||||
"doctype": "Address",
|
||||
"property_setters": [],
|
||||
"sync_on_migrate": 1
|
||||
}
|
||||
@@ -822,11 +822,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||
if (!frm.doc.received_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
} else if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value(
|
||||
"received_amount",
|
||||
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
frm.trigger("reset_received_amount");
|
||||
@@ -843,15 +846,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
if (!frm.doc.paid_amount) {
|
||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
||||
if (frm.doc.target_exchange_rate) {
|
||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||
}
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
} else if (frm.doc.source_exchange_rate) {
|
||||
frm.set_value(
|
||||
"paid_amount",
|
||||
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Count, Sum
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import nowdate
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -94,6 +95,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
posting_date = nowdate()
|
||||
|
||||
sinv = create_sales_invoice(
|
||||
posting_date=posting_date,
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
company=self.company,
|
||||
@@ -535,3 +537,82 @@ class TestPaymentLedgerEntry(FrappeTestCase):
|
||||
# with references removed, deletion should be possible
|
||||
so.delete()
|
||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
|
||||
invoice_posting_date = add_days(nowdate(), -5)
|
||||
gle = qb.DocType("GL Entry")
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
|
||||
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
|
||||
|
||||
gles_before = (
|
||||
qb.from_(gle)
|
||||
.select(
|
||||
Count(gle.name),
|
||||
)
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
ples_before = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
|
||||
si.cancel()
|
||||
|
||||
gles_after = (
|
||||
qb.from_(gle)
|
||||
.select(Count(gle.account))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(gles_after, gles_before * 2)
|
||||
|
||||
ples_after = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
Count(ple.name),
|
||||
)
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||
.run()[0][0]
|
||||
)
|
||||
self.assertEqual(ples_after, ples_before * 2)
|
||||
|
||||
# assert debit/credit are reversed
|
||||
gl_entries = (
|
||||
qb.from_(gle)
|
||||
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
|
||||
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||
.groupby(gle.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for gl in gl_entries:
|
||||
with self.subTest(gl=gl):
|
||||
self.assertEqual(gl.total_debit, gl.total_credit)
|
||||
|
||||
# assert amounts are reversed
|
||||
pl_entries = (
|
||||
qb.from_(ple)
|
||||
.select(ple.account, Sum(ple.amount).as_("total_amount"))
|
||||
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
|
||||
.groupby(ple.account)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
for pl in pl_entries:
|
||||
with self.subTest(pl=pl):
|
||||
self.assertEqual(pl.total_amount, 0)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"Payment Ledger Entry",
|
||||
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import change_settings
|
||||
from frappe.utils import 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
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -351,6 +353,51 @@ class TestPeriodClosingVoucher(unittest.TestCase):
|
||||
|
||||
return pcv
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"enable_immutable_ledger": 1},
|
||||
)
|
||||
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||
frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'")
|
||||
frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'")
|
||||
|
||||
company = create_company()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
jv = make_journal_entry(
|
||||
posting_date="2021-03-15",
|
||||
amount=400,
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
select sum(debit) as total_debit, sum(credit) as total_credit
|
||||
from `tabGL Entry`
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
||||
""",
|
||||
("Journal Entry", jv.name),
|
||||
as_dict=True,
|
||||
)[0]
|
||||
|
||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.get_doc(
|
||||
|
||||
@@ -134,9 +134,10 @@ def pause_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
# If a date is stuck in 'Running' state, this will allow it to procced.
|
||||
if queued_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
@@ -170,6 +171,9 @@ def resume_pcv_processing(docname: str):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||
start_pcv_processing(docname)
|
||||
else:
|
||||
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
|
||||
schedule_next_date(docname)
|
||||
|
||||
|
||||
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
||||
@@ -285,7 +289,21 @@ def schedule_next_date(docname: str):
|
||||
)
|
||||
# Ensure both normal and opening balances are processed for all dates
|
||||
if total_no_of_dates == completed:
|
||||
summarize_and_post_ledger_entries(docname)
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
is_job_running,
|
||||
)
|
||||
|
||||
job_name = f"summarize_{docname}"
|
||||
if not is_job_running(job_name):
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
)
|
||||
|
||||
|
||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||
|
||||
@@ -8,10 +8,13 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_default_address
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import cstr
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups
|
||||
from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups
|
||||
|
||||
|
||||
class IncorrectCustomerGroup(frappe.ValidationError):
|
||||
@@ -174,38 +177,44 @@ def get_party_details(party, party_type, args=None):
|
||||
def get_tax_template(posting_date, args):
|
||||
"""Get matching tax rule"""
|
||||
args = frappe._dict(args)
|
||||
conditions = []
|
||||
|
||||
TaxRule = DocType("Tax Rule")
|
||||
query = frappe.qb.from_(TaxRule).select("*")
|
||||
|
||||
if posting_date:
|
||||
conditions.append(
|
||||
f"""(from_date is null or from_date <= '{posting_date}')
|
||||
and (to_date is null or to_date >= '{posting_date}')"""
|
||||
query = query.where(
|
||||
(TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date))
|
||||
& (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date))
|
||||
)
|
||||
else:
|
||||
conditions.append("(from_date is null) and (to_date is null)")
|
||||
query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull())
|
||||
|
||||
conditions.append(
|
||||
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
|
||||
)
|
||||
if "tax_category" in args.keys():
|
||||
del args["tax_category"]
|
||||
def get_group_ancestors(doctype, get_parents, value):
|
||||
if not value:
|
||||
value = get_root_of(doctype)
|
||||
return [""] + [d.name for d in get_parents(value)]
|
||||
|
||||
group_fields = {
|
||||
"customer_group": ("Customer Group", get_parent_customer_groups),
|
||||
"supplier_group": ("Supplier Group", get_parent_supplier_groups),
|
||||
}
|
||||
|
||||
args.setdefault("tax_category", "")
|
||||
|
||||
for key, value in args.items():
|
||||
if key == "use_for_shopping_cart":
|
||||
conditions.append(f"use_for_shopping_cart = {1 if value else 0}")
|
||||
elif key == "customer_group":
|
||||
if not value:
|
||||
value = get_root_of("Customer Group")
|
||||
customer_group_condition = get_customer_group_condition(value)
|
||||
conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})")
|
||||
query = query.where(TaxRule.use_for_shopping_cart == value)
|
||||
elif key == "tax_category":
|
||||
query = query.where(IfNull(TaxRule.tax_category, "") == (value or ""))
|
||||
elif key in group_fields:
|
||||
doctype, get_parents = group_fields[key]
|
||||
query = query.where(
|
||||
IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value))
|
||||
)
|
||||
else:
|
||||
conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})")
|
||||
query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""]))
|
||||
|
||||
tax_rule = frappe.db.sql(
|
||||
"""select * from `tabTax Rule`
|
||||
where {}""".format(" and ".join(conditions)),
|
||||
as_dict=True,
|
||||
)
|
||||
tax_rule = query.run(as_dict=True)
|
||||
|
||||
if not tax_rule:
|
||||
return None
|
||||
@@ -234,11 +243,3 @@ def get_tax_template(posting_date, args):
|
||||
return None
|
||||
|
||||
return tax_template
|
||||
|
||||
|
||||
def get_customer_group_condition(customer_group):
|
||||
condition = ""
|
||||
customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)]
|
||||
if customer_groups:
|
||||
condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups))
|
||||
return condition
|
||||
|
||||
@@ -72,6 +72,117 @@ class TestTaxRule(unittest.TestCase):
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
def test_for_parent_supplier_group(self):
|
||||
purchase_template = "_Test Purchase Taxes and Charges Template - _TC"
|
||||
if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Purchase Taxes and Charges Template",
|
||||
"title": "_Test Purchase Taxes and Charges Template",
|
||||
"company": "_Test Company",
|
||||
"taxes": [
|
||||
{
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"charge_type": "On Net Total",
|
||||
"description": "VAT",
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
"cost_center": "Main - _TC",
|
||||
"rate": 6,
|
||||
}
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
make_tax_rule(
|
||||
supplier_group="All Supplier Groups",
|
||||
tax_type="Purchase",
|
||||
purchase_tax_template=purchase_template,
|
||||
priority=1,
|
||||
use_for_shopping_cart=0,
|
||||
from_date="2015-01-01",
|
||||
save=1,
|
||||
)
|
||||
|
||||
# "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{
|
||||
"supplier_group": "_Test Supplier Group",
|
||||
"tax_type": "Purchase",
|
||||
"use_for_shopping_cart": 0,
|
||||
},
|
||||
),
|
||||
purchase_template,
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_filter(self):
|
||||
city = "Test Cart City"
|
||||
# higher priority ensures this rule wins when use_for_shopping_cart is not filtered
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0,
|
||||
priority=2,
|
||||
save=1,
|
||||
)
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
use_for_shopping_cart=1,
|
||||
priority=1,
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) filters to cart rules only
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template 1 - _TC",
|
||||
)
|
||||
|
||||
# Non-cart request omits use_for_shopping_cart — no filter is applied, both rules
|
||||
# are candidates; non-cart rule wins by higher priority
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
def test_use_for_shopping_cart_default(self):
|
||||
city = "Test Default Cart City"
|
||||
# use_for_shopping_cart not set — Check field defaults to 0
|
||||
make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
billing_city=city,
|
||||
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
|
||||
use_for_shopping_cart=0, # Default is set to 1.
|
||||
save=1,
|
||||
)
|
||||
|
||||
# Non-cart request (no use_for_shopping_cart in args) matches the rule
|
||||
self.assertEqual(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city},
|
||||
),
|
||||
"_Test Sales Taxes and Charges Template - _TC",
|
||||
)
|
||||
|
||||
# Cart request (use_for_shopping_cart=1) does not match — rule has default 0
|
||||
self.assertIsNone(
|
||||
get_tax_template(
|
||||
"2015-01-01",
|
||||
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
|
||||
)
|
||||
)
|
||||
|
||||
def test_conflict_with_overlapping_dates(self):
|
||||
tax_rule1 = make_tax_rule(
|
||||
customer="_Test Customer",
|
||||
|
||||
@@ -700,7 +700,12 @@ def make_reverse_gl_entries(
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
# Partial cancel is only used by `Advance` in separate account feature.
|
||||
# Only cancel GL entries for unlinked reference using `voucher_detail_no`
|
||||
|
||||
@@ -743,7 +743,7 @@ def set_taxes(
|
||||
args.update({"tax_type": "Purchase"})
|
||||
|
||||
if use_for_shopping_cart:
|
||||
args.update({"use_for_shopping_cart": use_for_shopping_cart})
|
||||
args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)})
|
||||
|
||||
return get_tax_template(posting_date, args)
|
||||
|
||||
|
||||
@@ -406,7 +406,13 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
|
||||
# Opening for filtered account
|
||||
data.append(totals.opening)
|
||||
|
||||
if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
|
||||
if not filters.get("categorize_by"):
|
||||
all_entries = []
|
||||
for acc_dict in gle_map.values():
|
||||
all_entries.extend(acc_dict.entries)
|
||||
data += all_entries
|
||||
|
||||
elif filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
|
||||
for _acc, acc_dict in gle_map.items():
|
||||
# acc
|
||||
if acc_dict.entries:
|
||||
|
||||
@@ -1934,8 +1934,9 @@ def create_payment_ledger_entry(
|
||||
ple = frappe.get_doc(entry)
|
||||
|
||||
if cancel:
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
if is_immutable_ledger_enabled():
|
||||
if not is_immutable_ledger_enabled():
|
||||
delink_original_entry(ple, partial_cancel=partial_cancel)
|
||||
else:
|
||||
ple.delinked = 0
|
||||
ple.posting_date = frappe.form_dict.get("posting_date") or getdate()
|
||||
|
||||
@@ -2027,6 +2028,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
qb.update(ple)
|
||||
.set(ple.modified, now())
|
||||
.set(ple.modified_by, frappe.session.user)
|
||||
.set(ple.delinked, True)
|
||||
.where(
|
||||
(ple.company == pl_entry.company)
|
||||
& (ple.account_type == pl_entry.account_type)
|
||||
@@ -2043,9 +2045,6 @@ def delink_original_entry(pl_entry, partial_cancel=False):
|
||||
if partial_cancel:
|
||||
query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no)
|
||||
|
||||
if not is_immutable_ledger_enabled():
|
||||
query = query.set(ple.delinked, True)
|
||||
|
||||
query.run()
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
||||
from erpnext.stock.get_item_details import (
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_bin_details,
|
||||
get_conversion_factor,
|
||||
get_item_details,
|
||||
get_item_tax_map,
|
||||
@@ -3704,6 +3705,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True)
|
||||
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")))
|
||||
|
||||
if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
|
||||
# Initialized value will update in parent validation
|
||||
|
||||
@@ -209,7 +209,9 @@ def create_variant(item, args, use_template_image=False):
|
||||
variant_attributes = []
|
||||
|
||||
for d in template.attributes:
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(d.attribute)})
|
||||
attribute_value = args.get(_(d.attribute)) or args.get(d.attribute)
|
||||
if attribute_value:
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": attribute_value})
|
||||
|
||||
variant.set("attributes", variant_attributes)
|
||||
copy_attributes_to_variant(template, variant)
|
||||
@@ -228,6 +230,12 @@ def enqueue_multiple_variant_creation(item, args, use_template_image=False):
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
if isinstance(args, str):
|
||||
variants = json.loads(args)
|
||||
else:
|
||||
variants = args
|
||||
variants = {key: values for key, values in variants.items() if values}
|
||||
if not variants:
|
||||
frappe.throw(_("Please select at least one attribute value"))
|
||||
|
||||
total_variants = 1
|
||||
for key in variants:
|
||||
total_variants *= len(variants[key])
|
||||
@@ -251,6 +259,7 @@ def create_multiple_variants(item, args, use_template_image=False):
|
||||
count = 0
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
|
||||
template_item = frappe.get_doc("Item", item)
|
||||
args_set = generate_keyed_value_combinations(args)
|
||||
@@ -285,6 +294,9 @@ def generate_keyed_value_combinations(args):
|
||||
|
||||
"""
|
||||
# Return empty list if empty
|
||||
if not args:
|
||||
return []
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
if not args:
|
||||
return []
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from pypika import Order
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
|
||||
# searches for active employees
|
||||
@@ -476,6 +477,13 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
|
||||
.limit(page_len)
|
||||
)
|
||||
|
||||
if not filters.get("is_inward"):
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
query = query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
query = query.where((batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull()))
|
||||
|
||||
@@ -529,6 +537,13 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
|
||||
.limit(page_len)
|
||||
)
|
||||
|
||||
if not filters.get("is_inward"):
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
bundle_query = bundle_query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
bundle_query = bundle_query.where(
|
||||
(batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull())
|
||||
|
||||
@@ -240,10 +240,10 @@ class StatusUpdater(Document):
|
||||
|
||||
# get unique transactions to update
|
||||
for d in self.get_all_children():
|
||||
if hasattr(d, "qty") and d.qty < 0 and not self.get("is_return"):
|
||||
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
|
||||
frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code))
|
||||
|
||||
if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"):
|
||||
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 frappe.db.get_single_value("Selling Settings", "allow_negative_rates_for_items"):
|
||||
|
||||
@@ -3,7 +3,11 @@ import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code
|
||||
from erpnext.controllers.item_variant import (
|
||||
copy_attributes_to_variant,
|
||||
generate_keyed_value_combinations,
|
||||
make_variant_item_code,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import set_item_variant_settings
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection_parameter,
|
||||
@@ -17,6 +21,19 @@ class TestItemVariant(unittest.TestCase):
|
||||
variant = make_item_variant()
|
||||
self.assertEqual(variant.get("quality_inspection_template"), "_Test QC Template")
|
||||
|
||||
def test_generate_keyed_value_combinations_ignores_empty_attributes(self):
|
||||
combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": ["Small", "Large"], "Test Fit": []}
|
||||
)
|
||||
|
||||
self.assertEqual(len(combinations), 4)
|
||||
self.assertNotIn("Test Fit", combinations[0])
|
||||
|
||||
single_attribute_combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": []}
|
||||
)
|
||||
self.assertEqual(single_attribute_combinations, [{"Test Colour": "Red"}, {"Test Colour": "Blue"}])
|
||||
|
||||
|
||||
def create_variant_with_tables(item, args):
|
||||
if isinstance(args, str):
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"custom_fields": [
|
||||
{
|
||||
"_assign": null,
|
||||
"_comments": null,
|
||||
"_liked_by": null,
|
||||
"_user_tags": null,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"collapsible_depends_on": null,
|
||||
"columns": 0,
|
||||
"creation": "2019-12-02 11:00:03.432994",
|
||||
"default": null,
|
||||
"depends_on": null,
|
||||
"description": null,
|
||||
"docstatus": 0,
|
||||
"dt": "Contact",
|
||||
"fetch_from": null,
|
||||
"fetch_if_empty": 0,
|
||||
"fieldname": "is_billing_contact",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"idx": 27,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"insert_after": "is_primary_contact",
|
||||
"label": "Is Billing Contact",
|
||||
"length": 0,
|
||||
"modified": "2019-12-02 11:00:03.432994",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Contact-is_billing_contact",
|
||||
"no_copy": 0,
|
||||
"options": null,
|
||||
"owner": "Administrator",
|
||||
"parent": null,
|
||||
"parentfield": null,
|
||||
"parenttype": null,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": null,
|
||||
"read_only": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"translatable": 0,
|
||||
"unique": 0,
|
||||
"width": null
|
||||
}
|
||||
],
|
||||
"custom_perms": [],
|
||||
"doctype": "Contact",
|
||||
"property_setters": [],
|
||||
"sync_on_migrate": 1
|
||||
}
|
||||
@@ -1367,18 +1367,71 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
|
||||
|
||||
|
||||
def add_operations_cost(stock_entry, work_order=None, expense_account=None):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import (
|
||||
get_consumed_operating_cost,
|
||||
get_operating_cost_per_unit,
|
||||
)
|
||||
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
|
||||
if operating_cost_per_unit:
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
{
|
||||
def append_operating_cost(amount, operation=None, qty=None):
|
||||
if amount:
|
||||
row = {
|
||||
"expense_account": expense_account,
|
||||
"description": _("Operating Cost as per Work Order / BOM"),
|
||||
"amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
},
|
||||
"amount": flt(
|
||||
amount,
|
||||
frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
|
||||
),
|
||||
"has_operating_cost": 1,
|
||||
}
|
||||
if operation:
|
||||
row["operation_id"] = operation.name
|
||||
if qty is not None:
|
||||
row["qty"] = qty
|
||||
stock_entry.append(
|
||||
"additional_costs",
|
||||
row,
|
||||
)
|
||||
|
||||
if (
|
||||
work_order
|
||||
and stock_entry.bom_no
|
||||
and frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies")
|
||||
and work_order.get("use_multi_level_bom")
|
||||
):
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
append_operating_cost(
|
||||
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
qty=flt(stock_entry.fg_completed_qty),
|
||||
)
|
||||
elif work_order and work_order.get("operations"):
|
||||
for operation in work_order.get("operations"):
|
||||
qty = flt(stock_entry.fg_completed_qty)
|
||||
amount = 0
|
||||
|
||||
if flt(operation.completed_qty):
|
||||
consumed_cost = get_consumed_operating_cost(
|
||||
work_order.name, stock_entry.bom_no, operation.name
|
||||
)
|
||||
remaining_cost = flt(
|
||||
flt(operation.actual_operating_cost) - flt(consumed_cost.get("consumed_cost")),
|
||||
operation.precision("actual_operating_cost"),
|
||||
)
|
||||
remaining_qty = flt(operation.completed_qty) - flt(consumed_cost.get("consumed_qty"))
|
||||
|
||||
if remaining_cost <= 0 or remaining_qty <= 0:
|
||||
continue
|
||||
|
||||
qty = min(remaining_qty, flt(stock_entry.fg_completed_qty))
|
||||
amount = remaining_cost / remaining_qty * qty
|
||||
elif work_order.qty:
|
||||
amount = flt(operation.planned_operating_cost) / flt(work_order.qty) * qty
|
||||
|
||||
append_operating_cost(amount, operation=operation, qty=qty)
|
||||
else:
|
||||
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
|
||||
append_operating_cost(
|
||||
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
|
||||
qty=flt(stock_entry.fg_completed_qty),
|
||||
)
|
||||
|
||||
if work_order and work_order.additional_operating_cost and work_order.qty:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2018-07-09 17:23:29.518745",
|
||||
"doctype": "DocType",
|
||||
@@ -135,6 +136,7 @@
|
||||
"fieldname": "wip_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "WIP Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -511,7 +513,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 15:47:54.514290",
|
||||
"modified": "2026-05-12 12:17:17.750857",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Literal
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import random_string
|
||||
from frappe.utils import flt, random_string
|
||||
from frappe.utils.data import add_to_date, now, today
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
@@ -697,6 +697,403 @@ class TestJobCard(FrappeTestCase):
|
||||
self.assertEqual(wo_doc.process_loss_qty, 2)
|
||||
self.assertEqual(wo_doc.status, "Completed")
|
||||
|
||||
def test_op_cost_calculation(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
setup_bom,
|
||||
setup_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
suffix = random_string(5)
|
||||
workstation = make_workstation(
|
||||
workstation_name=f"Test Workstation Z {suffix}", hour_rate_rent=240, hour_rate_labour=0
|
||||
)
|
||||
workstation.update(
|
||||
{
|
||||
"hour_rate_rent": 240,
|
||||
"hour_rate_labour": 0,
|
||||
"hour_rate_electricity": 0,
|
||||
"hour_rate_consumable": 0,
|
||||
}
|
||||
)
|
||||
workstation.save()
|
||||
operations = [
|
||||
{
|
||||
"operation": f"Test Operation A1 {suffix}",
|
||||
"workstation": workstation.name,
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
]
|
||||
|
||||
warehouse = create_warehouse(f"Test Warehouse 123 for Job Card {suffix}")
|
||||
setup_operations(operations)
|
||||
|
||||
item_code = f"Test Job Card Process Qty Item {suffix}"
|
||||
for item in [item_code, item_code + "RM 1", item_code + "RM 2"]:
|
||||
if not frappe.db.exists("Item", item):
|
||||
make_item(
|
||||
item,
|
||||
{
|
||||
"item_name": item,
|
||||
"stock_uom": "Nos",
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
)
|
||||
|
||||
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
|
||||
bom_doc = setup_bom(
|
||||
item_code=item_code,
|
||||
routing=routing_doc.name,
|
||||
raw_materials=[item_code + "RM 1", item_code + "RM 2"],
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
for row in bom_doc.items:
|
||||
make_stock_entry(
|
||||
item_code=row.item_code,
|
||||
target=row.source_warehouse,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=item_code,
|
||||
bom_no=bom_doc.name,
|
||||
qty=10,
|
||||
skip_transfer=1,
|
||||
wip_warehouse=warehouse,
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
first_job_card = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 1},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", first_job_card)
|
||||
from_time = "2025-01-01 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, minutes=1),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
s1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4))
|
||||
s1.submit()
|
||||
|
||||
wo_doc.reload()
|
||||
precision = s1.additional_costs[0].precision("amount")
|
||||
self.assertEqual(
|
||||
flt(s1.additional_costs[0].amount, precision),
|
||||
flt(wo_doc.operations[0].actual_operating_cost, precision),
|
||||
)
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 6,
|
||||
"pending_qty": 6,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-01 10:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, minutes=2),
|
||||
"completed_qty": 6,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 6
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
|
||||
wo_doc.reload()
|
||||
precision = s2.additional_costs[0].precision("amount")
|
||||
self.assertEqual(
|
||||
flt(s2.additional_costs[0].amount, precision),
|
||||
flt(wo_doc.operations[0].actual_operating_cost - s1.additional_costs[0].amount, precision),
|
||||
)
|
||||
|
||||
@change_settings("Manufacturing Settings", {"overproduction_percentage_for_work_order": 100})
|
||||
def test_operating_cost_with_overproduction(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
setup_bom,
|
||||
setup_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
make_stock_entry as make_stock_entry_for_wo,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
suffix = random_string(5)
|
||||
workstation = make_workstation(
|
||||
workstation_name=f"Test Workstation for Overproduction {suffix}",
|
||||
hour_rate_rent=10,
|
||||
hour_rate_labour=10,
|
||||
)
|
||||
workstation.update(
|
||||
{
|
||||
"hour_rate_rent": 10,
|
||||
"hour_rate_labour": 10,
|
||||
"hour_rate_electricity": 0,
|
||||
"hour_rate_consumable": 0,
|
||||
}
|
||||
)
|
||||
workstation.save()
|
||||
operations = [
|
||||
{"operation": f"Test Operation 1 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
|
||||
{"operation": f"Test Operation 2 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
|
||||
]
|
||||
warehouse = create_warehouse(f"Test Warehouse for Overproduction {suffix}")
|
||||
setup_operations(operations)
|
||||
|
||||
fg = make_item(f"Test FG for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
|
||||
rm = make_item(f"Test RM for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
|
||||
|
||||
routing_doc = create_routing(routing_name=f"Testing Route {suffix}", operations=operations)
|
||||
bom_doc = setup_bom(
|
||||
item_code=fg.name,
|
||||
routing=routing_doc.name,
|
||||
raw_materials=[rm.name],
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
for row in bom_doc.items:
|
||||
make_stock_entry(
|
||||
item_code=row.item_code,
|
||||
target=row.source_warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
wo_doc = make_wo_order_test_record(
|
||||
production_item=fg.name,
|
||||
bom_no=bom_doc.name,
|
||||
qty=10,
|
||||
skip_transfer=1,
|
||||
source_warehouse=warehouse,
|
||||
)
|
||||
|
||||
first_operation = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 1},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", first_operation)
|
||||
from_time = "2025-01-02 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
second_operation = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": wo_doc.name, "sequence_id": 2},
|
||||
fields=["name"],
|
||||
order_by="sequence_id",
|
||||
limit=1,
|
||||
)[0].name
|
||||
|
||||
jc = frappe.get_doc("Job Card", second_operation)
|
||||
from_time = "2025-01-05 09:00:00"
|
||||
for _ in jc.scheduled_time_logs:
|
||||
jc.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 4,
|
||||
},
|
||||
)
|
||||
jc.for_quantity = 4
|
||||
jc.save()
|
||||
jc.submit()
|
||||
|
||||
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) # overproduction
|
||||
s.submit()
|
||||
|
||||
def assert_operating_costs(stock_entry, qty, previous_entries):
|
||||
wo_doc.reload()
|
||||
for idx, operation in enumerate(wo_doc.operations):
|
||||
consumed_cost = sum(
|
||||
entry.additional_costs[idx].amount for entry in previous_entries if entry.docstatus == 1
|
||||
)
|
||||
consumed_qty = sum(
|
||||
entry.additional_costs[idx].qty for entry in previous_entries if entry.docstatus == 1
|
||||
)
|
||||
remaining_cost = operation.actual_operating_cost - consumed_cost
|
||||
remaining_qty = operation.completed_qty - consumed_qty
|
||||
precision = stock_entry.additional_costs[idx].precision("amount")
|
||||
expected_cost = flt(remaining_cost / remaining_qty * min(remaining_qty, qty), precision)
|
||||
|
||||
self.assertEqual(flt(stock_entry.additional_costs[idx].amount, precision), expected_cost)
|
||||
|
||||
assert_operating_costs(s, 6, [])
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-09 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[1].name,
|
||||
"operation": operations[1]["operation"],
|
||||
"workstation": wo_doc.operations[1].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-12 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 1))
|
||||
s2.submit()
|
||||
|
||||
assert_operating_costs(s2, 1, [s])
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[0].name,
|
||||
"operation": operations[0]["operation"],
|
||||
"workstation": wo_doc.operations[0].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-16 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=1),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
make_job_card(
|
||||
wo_doc.name,
|
||||
[
|
||||
{
|
||||
"name": wo_doc.operations[1].name,
|
||||
"operation": operations[1]["operation"],
|
||||
"workstation": wo_doc.operations[1].workstation,
|
||||
"qty": 2,
|
||||
"pending_qty": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
|
||||
from_time = "2025-01-19 09:00:00"
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": from_time,
|
||||
"to_time": add_to_date(from_time, days=2),
|
||||
"completed_qty": 2,
|
||||
},
|
||||
)
|
||||
job_card.for_quantity = 2
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
s3 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2))
|
||||
s3.submit()
|
||||
|
||||
assert_operating_costs(s3, 2, [s, s2])
|
||||
|
||||
s2.cancel()
|
||||
|
||||
s4 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3))
|
||||
s4.submit()
|
||||
|
||||
assert_operating_costs(s4, 3, [s, s3])
|
||||
|
||||
|
||||
def create_bom_with_multiple_operations():
|
||||
"Create a BOM with multiple operations and Material Transfer against Job Card"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2018-07-09 17:20:44.737289",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -33,6 +34,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -105,7 +107,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-22 18:50:00.003444",
|
||||
"modified": "2026-05-12 12:22:18.506904",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card Item",
|
||||
@@ -115,4 +117,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,10 @@ frappe.ui.form.on("Work Order", {
|
||||
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
|
||||
|
||||
// Set query for warehouses
|
||||
frm.set_query("wip_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("source_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.events.set_company_filters(frm, "wip_warehouse");
|
||||
frm.events.set_company_filters(frm, "source_warehouse");
|
||||
frm.events.set_company_filters(frm, "fg_warehouse");
|
||||
frm.events.set_company_filters(frm, "scrap_warehouse");
|
||||
|
||||
frm.set_query("source_warehouse", "required_items", function () {
|
||||
return {
|
||||
@@ -44,24 +33,6 @@ frappe.ui.form.on("Work Order", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("fg_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("scrap_warehouse", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Set query for BOM
|
||||
frm.set_query("bom_no", function () {
|
||||
if (frm.doc.production_item) {
|
||||
@@ -118,6 +89,16 @@ frappe.ui.form.on("Work Order", {
|
||||
});
|
||||
},
|
||||
|
||||
set_company_filters(frm, fieldname) {
|
||||
frm.set_query(fieldname, () => {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.status) frm.doc.status = "Draft";
|
||||
|
||||
@@ -315,7 +296,7 @@ frappe.ui.form.on("Work Order", {
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "name",
|
||||
label: __("Operation Id"),
|
||||
label: __("Operation ID"),
|
||||
},
|
||||
{
|
||||
fieldtype: "Float",
|
||||
@@ -385,6 +366,7 @@ frappe.ui.form.on("Work Order", {
|
||||
|
||||
if (pending_qty) {
|
||||
dialog.fields_dict.operations.df.data.push({
|
||||
__checked: 1,
|
||||
name: data.name,
|
||||
operation: data.operation,
|
||||
workstation: data.workstation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_import": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2025-04-09 12:09:40.634472",
|
||||
@@ -249,6 +250,7 @@
|
||||
"fieldname": "wip_warehouse",
|
||||
"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",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
@@ -257,6 +259,7 @@
|
||||
"fieldname": "fg_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Target Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -269,6 +272,7 @@
|
||||
"fieldname": "scrap_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Scrap Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -498,6 +502,7 @@
|
||||
"fieldname": "source_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -602,7 +607,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-21 00:55:45.916224",
|
||||
"modified": "2026-05-19 12:20:38.102403",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2016-04-18 07:38:26.314642",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -46,6 +47,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Source Warehouse",
|
||||
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
@@ -151,7 +153,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-02 11:16:05.081613",
|
||||
"modified": "2026-05-12 12:05:16.687866",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Work Order Item",
|
||||
|
||||
@@ -434,3 +434,4 @@ erpnext.patches.v16_0.add_portal_redirects
|
||||
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
|
||||
erpnext.patches.v16_0.depends_on_inv_dimensions
|
||||
erpnext.patches.v16_0.clear_procedures_from_receivable_report
|
||||
erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.setup.install import create_address_and_contact_custom_fields
|
||||
|
||||
|
||||
def execute():
|
||||
"""Replace fixture-based custom fields on Address and Contact with programmatic ones."""
|
||||
for custom_field in (
|
||||
"Address-tax_category",
|
||||
"Address-is_your_company_address",
|
||||
"Contact-is_billing_contact",
|
||||
):
|
||||
if frappe.db.exists("Custom Field", custom_field):
|
||||
frappe.delete_doc("Custom Field", custom_field, ignore_missing=True, force=True)
|
||||
|
||||
create_address_and_contact_custom_fields()
|
||||
@@ -176,6 +176,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
"label": "Customer",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "customer",
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Customer",
|
||||
@@ -190,6 +191,7 @@
|
||||
"fieldname": "sales_order",
|
||||
"fieldtype": "Link",
|
||||
"label": "Sales Order",
|
||||
"no_copy": 1,
|
||||
"options": "Sales Order"
|
||||
},
|
||||
{
|
||||
@@ -462,7 +464,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"max_attachments": 4,
|
||||
"modified": "2025-08-21 17:57:58.314809",
|
||||
"modified": "2026-05-22 16:45:50.762759",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project",
|
||||
|
||||
@@ -472,6 +472,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
warehouse:
|
||||
this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse,
|
||||
is_inward: is_inward,
|
||||
posting_date: this.frm.doc.posting_date,
|
||||
posting_time: this.frm.doc.posting_time,
|
||||
include_expired_batches: include_expired_batches,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -385,7 +385,7 @@ class SalesOrder(SellingController):
|
||||
and not cint(d.delivered_by_supplier)
|
||||
):
|
||||
frappe.throw(
|
||||
_("Delivery warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
|
||||
_("Source warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
|
||||
)
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
|
||||
@@ -77,13 +77,11 @@ class CustomerGroup(NestedSet):
|
||||
|
||||
def get_parent_customer_groups(customer_group):
|
||||
lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"])
|
||||
|
||||
return frappe.db.sql(
|
||||
"""select name from `tabCustomer Group`
|
||||
where lft <= %s and rgt >= %s
|
||||
order by lft asc""",
|
||||
(lft, rgt),
|
||||
as_dict=True,
|
||||
return frappe.get_all(
|
||||
"Customer Group",
|
||||
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
|
||||
fields=["name"],
|
||||
order_by="lft asc",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -70,3 +70,13 @@ class SupplierGroup(NestedSet):
|
||||
def on_trash(self):
|
||||
NestedSet.validate_if_child_exists(self)
|
||||
frappe.utils.nestedset.update_nsm(self)
|
||||
|
||||
|
||||
def get_parent_supplier_groups(supplier_group):
|
||||
lft, rgt = frappe.db.get_value("Supplier Group", supplier_group, ["lft", "rgt"])
|
||||
return frappe.get_all(
|
||||
"Supplier Group",
|
||||
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
|
||||
fields=["name"],
|
||||
order_by="lft asc",
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ def after_install():
|
||||
|
||||
set_single_defaults()
|
||||
create_print_setting_custom_fields()
|
||||
create_address_and_contact_custom_fields()
|
||||
create_custom_company_links()
|
||||
add_all_roles_to("Administrator")
|
||||
create_default_success_action()
|
||||
@@ -132,6 +133,37 @@ def create_print_setting_custom_fields():
|
||||
)
|
||||
|
||||
|
||||
def create_address_and_contact_custom_fields():
|
||||
create_custom_fields(
|
||||
{
|
||||
"Address": [
|
||||
{
|
||||
"label": _("Tax Category"),
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"options": "Tax Category",
|
||||
"insert_after": "fax",
|
||||
},
|
||||
{
|
||||
"label": _("Is Your Company Address"),
|
||||
"fieldname": "is_your_company_address",
|
||||
"fieldtype": "Check",
|
||||
"default": "0",
|
||||
"insert_after": "linked_with",
|
||||
},
|
||||
],
|
||||
"Contact": [
|
||||
{
|
||||
"label": _("Is Billing Contact"),
|
||||
"fieldname": "is_billing_contact",
|
||||
"fieldtype": "Check",
|
||||
"insert_after": "is_primary_contact",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_custom_company_links():
|
||||
"""Add link fields to Company in Email Account and Communication.
|
||||
|
||||
|
||||
@@ -30,11 +30,20 @@ class DeprecatedSerialNoValuation:
|
||||
def get_incoming_value_for_serial_nos(self, serial_nos):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
do_not_fetch_rate = frappe.db.get_single_value(
|
||||
"Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no"
|
||||
)
|
||||
|
||||
# get rate from serial nos within same company
|
||||
incoming_values = 0.0
|
||||
for serial_no in serial_nos:
|
||||
sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1)
|
||||
if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company:
|
||||
if (
|
||||
sn_details
|
||||
and sn_details.purchase_rate
|
||||
and sn_details.company == self.sle.company
|
||||
and (not frappe.flags.through_repost_item_valuation or not do_not_fetch_rate)
|
||||
):
|
||||
self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate)
|
||||
incoming_values += self.serial_no_incoming_rate[serial_no]
|
||||
continue
|
||||
|
||||
@@ -226,13 +226,6 @@ frappe.ui.form.on("Item", {
|
||||
});
|
||||
frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0);
|
||||
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
|
||||
frm.set_query("item_group", () => {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
validate: function (frm) {
|
||||
@@ -411,12 +404,6 @@ $.extend(erpnext.item, {
|
||||
};
|
||||
};
|
||||
|
||||
frm.fields_dict["item_group"].get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Item Group", "docstatus", "!=", 2]],
|
||||
};
|
||||
};
|
||||
|
||||
frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function (
|
||||
doc,
|
||||
cdt,
|
||||
@@ -594,11 +581,10 @@ $.extend(erpnext.item, {
|
||||
default: 0,
|
||||
onchange: function () {
|
||||
let selected_attributes = get_selected_attributes();
|
||||
let lengths = [];
|
||||
Object.keys(selected_attributes).map((key) => {
|
||||
lengths.push(selected_attributes[key].length);
|
||||
let lengths = Object.keys(selected_attributes).map((key) => {
|
||||
return selected_attributes[key].length;
|
||||
});
|
||||
if (lengths.includes(0)) {
|
||||
if (!lengths.length) {
|
||||
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
|
||||
me.multiple_variant_dialog.disable_primary_action();
|
||||
} else {
|
||||
@@ -635,7 +621,7 @@ $.extend(erpnext.item, {
|
||||
fieldtype: "HTML",
|
||||
fieldname: "help",
|
||||
options: `<label class="control-label">
|
||||
${__("Select at least one value from each of the attributes.")}
|
||||
${__("Select at least one attribute value.")}
|
||||
</label>`,
|
||||
},
|
||||
]
|
||||
@@ -693,6 +679,9 @@ $.extend(erpnext.item, {
|
||||
selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
|
||||
}
|
||||
});
|
||||
if (!selected_attributes[attribute_name].length) {
|
||||
delete selected_attributes[attribute_name];
|
||||
}
|
||||
});
|
||||
|
||||
return selected_attributes;
|
||||
|
||||
@@ -855,8 +855,13 @@ class Item(Document):
|
||||
if disabled:
|
||||
frappe.throw(_("Attribute {0} is disabled.").format(frappe.bold(d.attribute)))
|
||||
|
||||
if not numeric_values and not frappe.db.exists(
|
||||
"Item Attribute Value", {"parent": d.attribute, "attribute_value": d.attribute_value}
|
||||
if (
|
||||
not numeric_values
|
||||
and d.attribute_value
|
||||
and not frappe.db.exists(
|
||||
"Item Attribute Value",
|
||||
{"parent": d.attribute, "attribute_value": d.attribute_value},
|
||||
)
|
||||
):
|
||||
frappe.throw(
|
||||
_("Attribute Value {0} is not valid for the selected attribute {1}.").format(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2014-07-11 11:51:00.453717",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -12,7 +13,10 @@
|
||||
"col_break3",
|
||||
"amount",
|
||||
"base_amount",
|
||||
"has_corrective_cost"
|
||||
"has_corrective_cost",
|
||||
"has_operating_cost",
|
||||
"operation_id",
|
||||
"qty"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -70,12 +74,36 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Corrective Cost",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "has_operating_cost",
|
||||
"fieldtype": "Check",
|
||||
"label": "Has Operating Cost",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "operation_id",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Operation ID",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Qty",
|
||||
"no_copy": 1,
|
||||
"non_negative": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-20 12:22:03.455762",
|
||||
"modified": "2026-05-19 12:21:07.953801",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Landed Cost Taxes and Charges",
|
||||
@@ -83,4 +111,4 @@
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ class LandedCostTaxesandCharges(Document):
|
||||
exchange_rate: DF.Float
|
||||
expense_account: DF.Link | None
|
||||
has_corrective_cost: DF.Check
|
||||
has_operating_cost: DF.Check
|
||||
operation_id: DF.Data | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
qty: DF.Float
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
@@ -1250,7 +1250,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc):
|
||||
.on(parent_table.name == table.parent)
|
||||
.select(
|
||||
table.pr_detail,
|
||||
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
|
||||
fn.Sum(table.base_net_amount).as_("amount"),
|
||||
fn.Sum(table.qty).as_("qty"),
|
||||
)
|
||||
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
|
||||
@@ -1296,7 +1296,7 @@ def get_billed_qty_amount_against_purchase_order(pr_doc):
|
||||
.select(
|
||||
table.po_detail,
|
||||
fn.Sum(table.qty).as_("qty"),
|
||||
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
|
||||
fn.Sum(table.base_net_amount).as_("amount"),
|
||||
)
|
||||
.where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
|
||||
.groupby(table.po_detail)
|
||||
|
||||
@@ -5170,6 +5170,157 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
)
|
||||
|
||||
@change_settings(
|
||||
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
|
||||
)
|
||||
def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self):
|
||||
"""
|
||||
When 'Set Landed Cost Based on PI Rate' is enabled and PI has an inclusive tax:
|
||||
- PR: qty=2, rate=1000 INR → base_net_amount=2000
|
||||
- PI: rate changed to 2000, 5% tax included in basic rate
|
||||
→ PI base_net_amount = 2 * 2000 / 1.05 ≈ 3809.52
|
||||
|
||||
The system must use PI's base_net_amount (not amount=4000) so that
|
||||
SRBNB credit on PR = 3809.52, not 4000.
|
||||
"""
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
cost_center = "Main - TCP1"
|
||||
|
||||
item_code = make_item(
|
||||
"Test Item for SRBNB Inclusive Tax Rate Change",
|
||||
{"is_stock_item": 1},
|
||||
).name
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
rate=1000,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
cost_center=cost_center,
|
||||
)
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.items[0].rate = 2000
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - TCP1",
|
||||
"category": "Total",
|
||||
"add_deduct_tax": "Add",
|
||||
"included_in_print_rate": 1,
|
||||
"rate": 5,
|
||||
"description": "Test Inclusive Tax",
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
|
||||
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) = 2 * (2000 / 1.05)
|
||||
pi_base_net_amount = flt(2 * 2000 / 1.05, 2)
|
||||
pr_base_net_amount = flt(pr.items[0].amount, 2) # 2 * 1000 = 2000
|
||||
expected_diff = flt(pi_base_net_amount - pr_base_net_amount, 2)
|
||||
|
||||
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
|
||||
|
||||
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
|
||||
srbnb_account = "Stock Received But Not Billed - TCP1"
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
@change_settings(
|
||||
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
|
||||
)
|
||||
def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self):
|
||||
"""
|
||||
When 'Set Landed Cost Based on PI Rate' is enabled, PI has an inclusive tax, and only
|
||||
the exchange rate changes on the PI (rate stays the same):
|
||||
- PR: qty=2, rate=100 USD, conversion_rate=70 → base_net_amount=14000 INR
|
||||
- PI: same rate=100 USD, conversion_rate changed to 90, 5% tax included in basic rate
|
||||
→ PI base_net_amount = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
|
||||
|
||||
The system must use PI's base_net_amount (not amount = 2*100*90 = 18000) so that
|
||||
SRBNB credit on PR = 17142.86, not 18000.
|
||||
"""
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
cost_center = "Main - TCP1"
|
||||
|
||||
party_account = create_account(
|
||||
account_name="USD Payable For SRBNB Exchange Rate Test",
|
||||
parent_account="Accounts Payable - TCP1",
|
||||
account_type="Payable",
|
||||
company=company,
|
||||
account_currency="USD",
|
||||
)
|
||||
|
||||
supplier = create_supplier(
|
||||
supplier_name="_Test USD Supplier for SRBNB Exchange Rate",
|
||||
default_currency="USD",
|
||||
party_account=party_account,
|
||||
).name
|
||||
|
||||
item_code = make_item(
|
||||
"Test Item for SRBNB Inclusive Tax Exchange Rate Change",
|
||||
{"is_stock_item": 1},
|
||||
).name
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=2,
|
||||
rate=100,
|
||||
currency="USD",
|
||||
conversion_rate=70,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
supplier=supplier,
|
||||
)
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 90
|
||||
pi.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - TCP1",
|
||||
"category": "Total",
|
||||
"add_deduct_tax": "Add",
|
||||
"included_in_print_rate": 1,
|
||||
"rate": 5,
|
||||
"description": "Test Inclusive Tax",
|
||||
"cost_center": cost_center,
|
||||
},
|
||||
)
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
|
||||
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) * new_conversion_rate
|
||||
# = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
|
||||
# PR base_net_amount = qty * rate * pr_conversion_rate = 2 * 100 * 70 = 14000 INR
|
||||
tax_amount_pr = (200 - flt(200 / 1.05, 2)) * 90
|
||||
|
||||
pi_base_net_amount = flt(2 * 100 * 90) - flt(tax_amount_pr)
|
||||
pr_base_net_amount = flt(2 * 100 * 70)
|
||||
expected_diff = flt(pi_base_net_amount - pr_base_net_amount)
|
||||
|
||||
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
|
||||
|
||||
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
|
||||
srbnb_account = "Stock Received But Not Billed - TCP1"
|
||||
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -1174,13 +1174,21 @@ class StockEntry(StockController):
|
||||
)
|
||||
|
||||
def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost):
|
||||
finished_items = [d.item_code for d in self.get("items") if d.is_finished_item]
|
||||
finished_items = [
|
||||
d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually
|
||||
]
|
||||
if len(finished_items) == 1:
|
||||
return flt(outgoing_items_cost / finished_item_qty)
|
||||
else:
|
||||
unique_finished_items = set(finished_items)
|
||||
if len(unique_finished_items) == 1:
|
||||
total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item])
|
||||
if unique_finished_items:
|
||||
total_fg_qty = sum(
|
||||
[
|
||||
flt(d.transfer_qty)
|
||||
for d in self.items
|
||||
if d.is_finished_item and not d.set_basic_rate_manually
|
||||
]
|
||||
)
|
||||
return flt(outgoing_items_cost / total_fg_qty)
|
||||
|
||||
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
|
||||
@@ -3362,6 +3370,33 @@ def get_work_order_details(work_order, company):
|
||||
}
|
||||
|
||||
|
||||
def get_consumed_operating_cost(work_order, bom_no, operation_id=None):
|
||||
table = frappe.qb.DocType("Stock Entry")
|
||||
child_table = frappe.qb.DocType("Landed Cost Taxes and Charges")
|
||||
query = (
|
||||
frappe.qb.from_(child_table)
|
||||
.join(table)
|
||||
.on(child_table.parent == table.name)
|
||||
.select(
|
||||
Sum(child_table.amount).as_("consumed_cost"),
|
||||
Sum(child_table.qty).as_("consumed_qty"),
|
||||
)
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (table.work_order == work_order)
|
||||
& (table.purpose == "Manufacture")
|
||||
& (table.bom_no == bom_no)
|
||||
& (child_table.has_operating_cost == 1)
|
||||
)
|
||||
)
|
||||
|
||||
if operation_id:
|
||||
query = query.where(child_table.operation_id == operation_id)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
return data[0] if data else frappe._dict()
|
||||
|
||||
|
||||
def get_operating_cost_per_unit(work_order=None, bom_no=None):
|
||||
operating_cost_per_unit = 0
|
||||
if work_order:
|
||||
|
||||
@@ -567,15 +567,18 @@ class StockReconciliation(StockController):
|
||||
|
||||
def calculate_difference_amount(self, item, item_dict):
|
||||
qty_precision = item.precision("qty")
|
||||
val_precision = item.precision("valuation_rate")
|
||||
amount_precision = item.precision("amount")
|
||||
|
||||
new_qty = flt(item.qty, qty_precision)
|
||||
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"), val_precision)
|
||||
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"))
|
||||
|
||||
current_qty = flt(item_dict.get("qty"), qty_precision)
|
||||
current_valuation_rate = flt(item_dict.get("rate"), val_precision)
|
||||
current_valuation_rate = flt(item_dict.get("rate"))
|
||||
|
||||
self.difference_amount += (new_qty * new_valuation_rate) - (current_qty * current_valuation_rate)
|
||||
new_amount = flt(new_qty * new_valuation_rate, amount_precision)
|
||||
current_amount = flt(current_qty * current_valuation_rate, amount_precision)
|
||||
|
||||
self.difference_amount += new_amount - current_amount
|
||||
|
||||
def validate_data(self):
|
||||
def _get_msg(row_num, msg):
|
||||
@@ -875,7 +878,7 @@ class StockReconciliation(StockController):
|
||||
"company": self.company,
|
||||
"stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"),
|
||||
"is_cancelled": 1 if self.docstatus == 2 else 0,
|
||||
"valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")),
|
||||
"valuation_rate": flt(row.valuation_rate),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1035,86 +1038,6 @@ class StockReconciliation(StockController):
|
||||
else:
|
||||
self._cancel()
|
||||
|
||||
def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False):
|
||||
from erpnext.stock.stock_ledger import get_valuation_rate
|
||||
|
||||
for row in self.items:
|
||||
if voucher_detail_no != row.name:
|
||||
continue
|
||||
|
||||
if row.current_qty < 0:
|
||||
return
|
||||
|
||||
val_rate = 0.0
|
||||
current_qty = 0.0
|
||||
if row.current_serial_and_batch_bundle:
|
||||
current_qty = self.get_current_qty_for_serial_or_batch(row, sle_creation)
|
||||
elif row.serial_no:
|
||||
item_dict = get_stock_balance_for(
|
||||
row.item_code,
|
||||
row.warehouse,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
row=row,
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
current_qty = item_dict.get("qty")
|
||||
row.current_serial_no = item_dict.get("serial_nos")
|
||||
row.current_valuation_rate = item_dict.get("rate")
|
||||
val_rate = item_dict.get("rate")
|
||||
elif row.batch_no:
|
||||
current_qty = get_batch_qty_for_stock_reco(
|
||||
row.item_code,
|
||||
row.warehouse,
|
||||
row.batch_no,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
self.name,
|
||||
sle_creation,
|
||||
)
|
||||
|
||||
precesion = row.precision("current_qty")
|
||||
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
|
||||
if not row.serial_no:
|
||||
val_rate = get_incoming_rate(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"qty": current_qty * -1,
|
||||
"serial_and_batch_bundle": row.current_serial_and_batch_bundle,
|
||||
"batch_no": row.batch_no,
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"company": self.company,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
row.current_valuation_rate = val_rate
|
||||
row.current_qty = current_qty
|
||||
row.db_set(
|
||||
{
|
||||
"current_qty": row.current_qty,
|
||||
"current_valuation_rate": row.current_valuation_rate,
|
||||
"current_amount": flt(row.current_qty * row.current_valuation_rate),
|
||||
}
|
||||
)
|
||||
|
||||
if add_new_sle and not frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
|
||||
"name",
|
||||
):
|
||||
if not row.current_serial_and_batch_bundle:
|
||||
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
|
||||
row.reload()
|
||||
|
||||
self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation)
|
||||
|
||||
def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation):
|
||||
if row.current_qty == 0:
|
||||
return
|
||||
|
||||
@@ -1039,7 +1039,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
||||
|
||||
sr.reload()
|
||||
self.assertTrue(sr.items[0].serial_and_batch_bundle)
|
||||
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
|
||||
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
|
||||
|
||||
def test_not_reconcile_all_batch(self):
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2021-10-01 10:56:30.814787",
|
||||
@@ -13,6 +14,7 @@
|
||||
"end_time",
|
||||
"limits_dont_apply_on",
|
||||
"item_based_reposting",
|
||||
"do_not_fetch_incoming_rate_from_serial_no",
|
||||
"errors_notification_section",
|
||||
"notify_reposting_error_to_role"
|
||||
],
|
||||
@@ -65,12 +67,19 @@
|
||||
"fieldname": "errors_notification_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Errors Notification"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "For legacy serial nos, do not fetch incoming rate from serial no and calculate it based on the inward transaction",
|
||||
"fieldname": "do_not_fetch_incoming_rate_from_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Do not fetch incoming rate from Serial No"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-08 11:27:46.659056",
|
||||
"modified": "2026-05-15 12:59:34.392491",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reposting Settings",
|
||||
|
||||
@@ -16,6 +16,7 @@ class StockRepostingSettings(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
do_not_fetch_incoming_rate_from_serial_no: DF.Check
|
||||
end_time: DF.Time | None
|
||||
item_based_reposting: DF.Check
|
||||
limit_reposting_timeslot: DF.Check
|
||||
|
||||
@@ -294,9 +294,8 @@ def clean_all_descriptions():
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_enable_stock_uom_editing():
|
||||
return frappe.get_cached_value(
|
||||
return frappe.get_single_value(
|
||||
"Stock Settings",
|
||||
None,
|
||||
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,11 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
FIFOSlots,
|
||||
format_report_data,
|
||||
get_average_age,
|
||||
)
|
||||
|
||||
|
||||
class TestStockAgeing(FrappeTestCase):
|
||||
@@ -868,6 +872,560 @@ class TestStockAgeing(FrappeTestCase):
|
||||
range_valuations = range_values[1::2]
|
||||
self.assertEqual(range_valuations, [15, 7.5, 20, 5])
|
||||
|
||||
def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self):
|
||||
item_details = {
|
||||
"Batch Mixed Item": {
|
||||
"details": frappe._dict(
|
||||
name="Batch Mixed Item",
|
||||
item_name="Batch Mixed Item",
|
||||
description="Batch Mixed Item",
|
||||
item_group=None,
|
||||
brand=None,
|
||||
has_batch_no=True,
|
||||
stock_uom="Nos",
|
||||
),
|
||||
"fifo_queue": [
|
||||
["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0],
|
||||
[3.0, "2021-12-02", 30.0],
|
||||
],
|
||||
"has_serial_no": False,
|
||||
"total_qty": 8.0,
|
||||
}
|
||||
}
|
||||
|
||||
report_data = format_report_data(self.filters, item_details, self.filters["to_date"])
|
||||
|
||||
self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
def test_average_age_accepts_batchwise_valuation_slots(self):
|
||||
fifo_queue = [["SA-BATCH-SLOT", 1, 5.0, "2021-12-01", 50.0]]
|
||||
|
||||
self.assertEqual(get_average_age(fifo_queue, self.filters["to_date"]), 9.0)
|
||||
|
||||
def test_serial_transfer_replay_preserves_serial_slots(self):
|
||||
fifo_slots = FIFOSlots(self.filters, [])
|
||||
transfer_key = ("001", "Serial Item", "WH 1")
|
||||
fifo_slots.transferred_item_details[transfer_key] = [[2, "2021-12-01", 20]]
|
||||
|
||||
row = frappe._dict(
|
||||
name="Serial Item",
|
||||
actual_qty=2,
|
||||
stock_value_difference=20,
|
||||
posting_date="2021-12-05",
|
||||
has_serial_no=True,
|
||||
)
|
||||
fifo_queue = []
|
||||
|
||||
fifo_slots._compute_incoming_stock(row, fifo_queue, transfer_key, ["SN-A", "SN-B"], [])
|
||||
|
||||
self.assertEqual(fifo_queue, [["SN-A", "2021-12-01", 10.0], ["SN-B", "2021-12-01", 10.0]])
|
||||
self.assertFalse(fifo_slots.transferred_item_details[transfer_key])
|
||||
|
||||
def test_batch_transfer_replay_removes_zeroed_negative_slot(self):
|
||||
fifo_slots = FIFOSlots(self.filters, [])
|
||||
fifo_queue = [["SA-ZERO-BATCH", 1, -4, "2021-12-01", -40]]
|
||||
|
||||
fifo_slots._add_transfer_slot_to_fifo_queue(fifo_queue, ["SA-ZERO-BATCH", 1, 4, "2021-12-02", 40])
|
||||
|
||||
self.assertEqual(fifo_queue, [])
|
||||
|
||||
def test_batchwise_valuation(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Valuation",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id, use_batchwise_valuation):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation)
|
||||
|
||||
batchwise_above_90 = "SA-BATCHWISE-ABOVE-90"
|
||||
non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90"
|
||||
batchwise_61_90 = "SA-BATCHWISE-61-90"
|
||||
non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90"
|
||||
batchwise_31_60 = "SA-BATCHWISE-31-60"
|
||||
non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60"
|
||||
batchwise_0_30 = "SA-BATCHWISE-0-30"
|
||||
non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30"
|
||||
|
||||
for batch_id, use_batchwise_valuation in {
|
||||
batchwise_above_90: 1,
|
||||
non_batchwise_above_90: 0,
|
||||
batchwise_61_90: 1,
|
||||
non_batchwise_61_90: 0,
|
||||
batchwise_31_60: 1,
|
||||
non_batchwise_31_60: 0,
|
||||
batchwise_0_30: 1,
|
||||
non_batchwise_0_30: 0,
|
||||
}.items():
|
||||
make_batch(batch_id, use_batchwise_valuation)
|
||||
|
||||
qty_after_transaction = 0
|
||||
|
||||
def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference):
|
||||
nonlocal qty_after_transaction
|
||||
|
||||
qty_after_transaction += actual_qty
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after_transaction,
|
||||
stock_value_difference=stock_value_difference,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-08-01", "001", batchwise_above_90, 50, 500),
|
||||
make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600),
|
||||
make_sle("2021-08-20", "003", batchwise_above_90, -10, -100),
|
||||
make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150),
|
||||
make_sle("2021-09-20", "005", batchwise_61_90, 40, 400),
|
||||
make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500),
|
||||
make_sle("2021-09-30", "007", batchwise_61_90, -5, -50),
|
||||
make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200),
|
||||
make_sle("2021-10-20", "009", batchwise_31_60, 30, 300),
|
||||
make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400),
|
||||
make_sle("2021-10-30", "011", batchwise_31_60, -8, -80),
|
||||
make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250),
|
||||
make_sle("2021-11-20", "013", batchwise_0_30, 20, 200),
|
||||
make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300),
|
||||
make_sle("2021-11-30", "015", batchwise_0_30, -6, -60),
|
||||
make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 221.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[batchwise_above_90, 1, 40.0, "2021-08-01", 400.0],
|
||||
[batchwise_61_90, 1, 35.0, "2021-09-20", 350.0],
|
||||
[non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0],
|
||||
[batchwise_31_60, 1, 22.0, "2021-10-20", 220.0],
|
||||
[non_batchwise_31_60, 0, 40, "2021-10-25", 400],
|
||||
[batchwise_0_30, 1, 14.0, "2021-11-20", 140.0],
|
||||
[non_batchwise_0_30, 0, 30, "2021-11-25", 300],
|
||||
],
|
||||
)
|
||||
|
||||
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
range_values = report_data[0][7:15]
|
||||
self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0])
|
||||
|
||||
def test_batchwise_valuation_same_voucher_transfer(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Transfer",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
def make_batch(batch_id):
|
||||
if not frappe.db.exists("Batch", batch_id):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_id,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1)
|
||||
|
||||
source_batch = "SA-BATCHWISE-TRANSFER-SOURCE"
|
||||
target_batch = "SA-BATCHWISE-TRANSFER-TARGET"
|
||||
make_batch(source_batch)
|
||||
make_batch(target_batch)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=20,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=200,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-09-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=source_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-15,
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=-150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-10-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=source_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=10,
|
||||
qty_after_transaction=15,
|
||||
stock_value_difference=100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-10-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=target_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["total_qty"], 15.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[source_batch, 1, 5.0, "2021-09-01", 50.0],
|
||||
[target_batch, 1, 10.0, "2021-09-01", 100.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
|
||||
[[5.0, "2021-09-01", 50.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_negative_stock_same_voucher(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Stock",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-STOCK"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]]
|
||||
)
|
||||
|
||||
sle.append(
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=-4,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
)
|
||||
)
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]])
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("001", item_code, "WH 1")],
|
||||
[[4.0, "2021-12-01", 40.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_neutralizes_non_head_negative_batch(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Non Head",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER"
|
||||
negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD"
|
||||
for batch_no in [buffer_batch, negative_batch]:
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=5,
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=50,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-30",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=buffer_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-5,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=negative_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=1,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=negative_batch,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
fifo_slots = FIFOSlots(self.filters, sle)
|
||||
slots = fifo_slots.generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"],
|
||||
[
|
||||
[buffer_batch, 1, 5, "2021-11-30", 50],
|
||||
[negative_batch, 1, -4.0, "2021-12-01", -40.0],
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
|
||||
[[4.0, "2021-12-01", 40.0]],
|
||||
)
|
||||
|
||||
def test_batchwise_valuation_negative_stock_later_voucher(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batchwise Negative Later Voucher",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
|
||||
batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Batch",
|
||||
"batch_id": batch_no,
|
||||
"item": item_code,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
),
|
||||
frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=6,
|
||||
qty_after_transaction=-4,
|
||||
stock_value_difference=60,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-11-10",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
valuation_rate=10,
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], -4.0)
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
|
||||
|
||||
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
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_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
suffix = frappe.generate_hash(length=8).upper()
|
||||
item_code = make_item(
|
||||
f"Test Stock Ageing Batch Reco {suffix}",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": f"SA-RECO-{suffix}-.###",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
base_date = nowdate()
|
||||
|
||||
opening_reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=12,
|
||||
rate=10,
|
||||
posting_date=add_days(base_date, -2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=10,
|
||||
batch_no=batch_no,
|
||||
posting_date=add_days(base_date, -1),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date=base_date,
|
||||
ranges=["30", "60", "90"],
|
||||
item_code=item_code,
|
||||
)
|
||||
slots = FIFOSlots(filters).generate()
|
||||
item_result = slots[item_code]
|
||||
|
||||
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
|
||||
self.assertEqual(item_result["total_qty"], 5.0)
|
||||
self.assertEqual(
|
||||
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
|
||||
)
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
@@ -15,7 +15,11 @@ from frappe.utils.nestedset import get_descendants_of
|
||||
import erpnext
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
FIFOSlots,
|
||||
get_average_age,
|
||||
normalize_fifo_queue,
|
||||
)
|
||||
from erpnext.stock.utils import add_additional_uom_columns
|
||||
|
||||
|
||||
@@ -123,6 +127,7 @@ class StockBalanceReport:
|
||||
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
|
||||
if opening_fifo_queue:
|
||||
fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func)
|
||||
fifo_queue = normalize_fifo_queue(fifo_queue)
|
||||
if not fifo_queue:
|
||||
continue
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ from frappe import _
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import cint, flt, getdate
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
FIFOSlots,
|
||||
get_average_age,
|
||||
normalize_fifo_queue,
|
||||
)
|
||||
from erpnext.stock.report.stock_analytics.stock_analytics import (
|
||||
get_item_details,
|
||||
get_items,
|
||||
@@ -68,6 +72,7 @@ def execute(filters=None):
|
||||
fifo_queue = item_ageing[item]["fifo_queue"]
|
||||
average_age = 0.00
|
||||
if fifo_queue:
|
||||
fifo_queue = normalize_fifo_queue(fifo_queue)
|
||||
average_age = get_average_age(fifo_queue, filters["to_date"])
|
||||
|
||||
row += [average_age]
|
||||
|
||||
@@ -866,15 +866,6 @@ class update_entries_after:
|
||||
if not self.args.get("sle_id"):
|
||||
self.get_dynamic_incoming_outgoing_rate(sle)
|
||||
|
||||
if (
|
||||
sle.voucher_type == "Stock Reconciliation"
|
||||
and (sle.serial_and_batch_bundle)
|
||||
and sle.voucher_detail_no
|
||||
and not self.args.get("sle_id")
|
||||
and sle.is_cancelled == 0
|
||||
):
|
||||
self.reset_actual_qty_for_stock_reco(sle)
|
||||
|
||||
if (
|
||||
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
|
||||
and sle.voucher_detail_no
|
||||
@@ -1051,31 +1042,6 @@ class update_entries_after:
|
||||
if not allow_zero_rate:
|
||||
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
|
||||
|
||||
def reset_actual_qty_for_stock_reco(self, sle):
|
||||
doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no)
|
||||
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0)
|
||||
|
||||
if sle.actual_qty < 0:
|
||||
doc.reload()
|
||||
|
||||
sle.actual_qty = (
|
||||
flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"))
|
||||
* -1
|
||||
)
|
||||
|
||||
if abs(sle.actual_qty) == 0.0:
|
||||
sle.is_cancelled = 1
|
||||
|
||||
if sle.serial_and_batch_bundle:
|
||||
for row in doc.items:
|
||||
if row.name == sle.voucher_detail_no:
|
||||
row.db_set("current_serial_and_batch_bundle", "")
|
||||
|
||||
sabb_doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
|
||||
sabb_doc.voucher_detail_no = None
|
||||
sabb_doc.voucher_no = None
|
||||
sabb_doc.cancel()
|
||||
|
||||
def calculate_valuation_for_serial_batch_bundle(self, sle):
|
||||
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user