mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-29 06:38:24 +00:00
Merge pull request #58396 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,13 +89,14 @@
|
||||
"enable_fuzzy_matching",
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"disable_include_dimensions",
|
||||
"column_break_lvjk",
|
||||
"receivable_payable_remarks_length",
|
||||
"general_ledger_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"receivable_payable_remarks_length",
|
||||
"legacy_section",
|
||||
"ignore_is_opening_check_for_reporting",
|
||||
"payment_request_settings",
|
||||
@@ -483,7 +484,7 @@
|
||||
{
|
||||
"fieldname": "remarks_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Remarks Column Length"
|
||||
"label": "General Ledger Report"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -566,7 +567,7 @@
|
||||
{
|
||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounts Receivable / Payable Tuning"
|
||||
"label": "Accounts Receivable / Payable Report"
|
||||
},
|
||||
{
|
||||
"fieldname": "legacy_section",
|
||||
@@ -665,6 +666,12 @@
|
||||
"fieldname": "default_ageing_range",
|
||||
"fieldtype": "Data",
|
||||
"label": "Default Ageing Range"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_include_dimensions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable \"Consider Accounting Dimension\" Filter"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -672,7 +679,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified": "2026-08-14 13:12:47.895908",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -44,6 +44,7 @@ class AccountsSettings(Document):
|
||||
default_ageing_range: DF.Data | None
|
||||
delete_linked_ledger_entries: DF.Check
|
||||
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
|
||||
disable_include_dimensions: DF.Check
|
||||
enable_common_party_accounting: DF.Check
|
||||
enable_fuzzy_matching: DF.Check
|
||||
enable_immutable_ledger: DF.Check
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_8",
|
||||
@@ -187,12 +187,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-11-25 11:10:10.945027",
|
||||
"modified": "2026-05-01 00:38:53.368737",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Advance Taxes and Charges",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC"
|
||||
}
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdvanceTaxesandCharges(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
project: DF.Link | None
|
||||
rate: DF.Float
|
||||
row_id: DF.Data | None
|
||||
tax_amount: DF.Currency
|
||||
|
||||
@@ -252,6 +252,7 @@ def get_import_status(docname):
|
||||
import_status = {}
|
||||
|
||||
data_import = frappe.get_doc("Bank Statement Import", docname)
|
||||
data_import.check_permission()
|
||||
import_status["status"] = data_import.status
|
||||
|
||||
logs = frappe.get_all(
|
||||
|
||||
@@ -98,13 +98,13 @@ class Budget(Document):
|
||||
frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account))
|
||||
elif account_details.company != self.company:
|
||||
frappe.throw(
|
||||
_("Account {0} does not belongs to company {1}").format(d.account, self.company)
|
||||
_("Account {0} does not belong to company {1}").format(d.account, self.company)
|
||||
)
|
||||
elif account_details.report_type != "Profit and Loss":
|
||||
frappe.throw(
|
||||
_(
|
||||
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
|
||||
).format(self.account)
|
||||
).format(d.account)
|
||||
)
|
||||
|
||||
if d.account in account_list:
|
||||
|
||||
@@ -357,6 +357,16 @@ class TestBudget(unittest.TestCase):
|
||||
|
||||
self.assertRaises(BudgetError, jv.submit)
|
||||
|
||||
def test_budget_against_balance_sheet_account(self):
|
||||
budget = frappe.new_doc("Budget")
|
||||
budget.budget_against = "Cost Center"
|
||||
budget.cost_center = "_Test Cost Center - _TC"
|
||||
budget.company = "_Test Company"
|
||||
budget.fiscal_year = get_fiscal_year(nowdate())[0]
|
||||
budget.append("accounts", {"account": "_Test Bank - _TC", "budget_amount": 200000})
|
||||
|
||||
self.assertRaisesRegex(frappe.ValidationError, "_Test Bank - _TC", budget.insert)
|
||||
|
||||
|
||||
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
|
||||
if budget_against_field == "project":
|
||||
|
||||
@@ -47,3 +47,12 @@ frappe.ui.form.on("Item Tax Template", {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Item Tax Template Detail", {
|
||||
not_applicable: function (frm, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
if (row.not_applicable) {
|
||||
frappe.model.set_value(cdt, cdn, "tax_rate", 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,8 +27,15 @@ class ItemTaxTemplate(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.set_zero_rate_for_not_applicable_tax()
|
||||
self.validate_tax_accounts()
|
||||
|
||||
def set_zero_rate_for_not_applicable_tax(self):
|
||||
"""Ensure tax_rate is 0 for any row marked as not applicable."""
|
||||
for row in self.get("taxes"):
|
||||
if row.not_applicable:
|
||||
row.tax_rate = 0
|
||||
|
||||
def autoname(self):
|
||||
if self.company and self.title:
|
||||
abbr = frappe.get_cached_value("Company", self.company, "abbr")
|
||||
|
||||
@@ -8,6 +8,6 @@ def get_data():
|
||||
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
|
||||
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
|
||||
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
|
||||
{"label": _("Stock"), "items": ["Item Group", "Item"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"tax_type",
|
||||
"tax_rate"
|
||||
"tax_rate",
|
||||
"not_applicable"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -21,12 +22,21 @@
|
||||
"fieldname": "tax_rate",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Tax Rate"
|
||||
"label": "Tax Rate",
|
||||
"read_only_depends_on": "eval:doc.not_applicable"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Check if this tax is not applicable to items (distinct from 0% rate)",
|
||||
"fieldname": "not_applicable",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Not Applicable"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-30 23:49:27.020639",
|
||||
"modified": "2026-04-30 23:59:22.020639",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Item Tax Template Detail",
|
||||
|
||||
@@ -14,6 +14,7 @@ class ItemTaxTemplateDetail(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
not_applicable: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -65,7 +65,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1) {
|
||||
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
|
||||
frm.add_custom_button(
|
||||
__("Reverse Journal Entry"),
|
||||
function () {
|
||||
@@ -516,7 +516,7 @@ $.extend(erpnext.journal_entry, {
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => field.df.fieldname != "posting_date")
|
||||
.filter((field) => !["posting_date", "user_remark"].includes(field.df.fieldname))
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _, msgprint, scrub
|
||||
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, getdate, nowdate
|
||||
|
||||
import erpnext
|
||||
@@ -1892,7 +1893,21 @@ def make_inter_company_journal_entry(name, voucher_type, company):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name, target_doc=None):
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Document | None = None) -> Document:
|
||||
# `get_mapped_doc` checks this as well, but the guard below discloses which entry
|
||||
# reverses which, so read access has to be settled before it runs
|
||||
if not frappe.has_permission("Journal Entry", doc=source_name):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
|
||||
if reversal_of:
|
||||
frappe.throw(
|
||||
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
|
||||
get_link_to_form("Journal Entry", source_name),
|
||||
get_link_to_form("Journal Entry", reversal_of),
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
|
||||
@@ -249,6 +249,27 @@ class TestJournalEntry(unittest.TestCase):
|
||||
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_disallow_reversal_of_a_reversal_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
|
||||
|
||||
rjv = make_reverse_journal_entry(jv.name)
|
||||
rjv.posting_date = nowdate()
|
||||
rjv.submit()
|
||||
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"is already a Reverse Journal Entry",
|
||||
make_reverse_journal_entry,
|
||||
rjv.name,
|
||||
)
|
||||
|
||||
# the guard must not disclose the reversal to a user who cannot read the entry
|
||||
frappe.set_user("Guest")
|
||||
self.addCleanup(frappe.set_user, "Administrator")
|
||||
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
|
||||
|
||||
def test_disallow_change_in_account_currency_for_a_party(self):
|
||||
# create jv in USD
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)
|
||||
|
||||
@@ -291,6 +291,7 @@ class PurchaseInvoice(BuyingController):
|
||||
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
|
||||
self.set_status()
|
||||
self.validate_purchase_receipt_if_update_stock()
|
||||
self.validate_exchange_rate_with_purchase_receipt()
|
||||
validate_inter_company_party(
|
||||
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
|
||||
)
|
||||
@@ -313,6 +314,47 @@ class PurchaseInvoice(BuyingController):
|
||||
if total_billed_qty and total_received_qty:
|
||||
self.per_received = total_received_qty / total_billed_qty * 100
|
||||
|
||||
def validate_exchange_rate_with_purchase_receipt(self):
|
||||
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
receipts = {
|
||||
item.purchase_receipt
|
||||
for item in self.items
|
||||
if item.purchase_receipt and item.item_code in stock_items
|
||||
}
|
||||
if not receipts:
|
||||
return
|
||||
|
||||
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
|
||||
return
|
||||
|
||||
mismatched = [
|
||||
f"{frappe.bold(row.name)} ({row.conversion_rate})"
|
||||
for row in frappe.get_all(
|
||||
"Purchase Receipt",
|
||||
filters={"name": ("in", list(receipts))},
|
||||
fields=["name", "currency", "conversion_rate"],
|
||||
)
|
||||
if row.currency == self.currency
|
||||
and flt(row.conversion_rate)
|
||||
and flt(row.conversion_rate) != flt(self.conversion_rate)
|
||||
]
|
||||
if not mismatched:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
|
||||
).format(
|
||||
frappe.bold(self.conversion_rate),
|
||||
", ".join(mismatched),
|
||||
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
|
||||
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoice_hold(self):
|
||||
if self.is_return:
|
||||
frappe.throw(_("Return Purchase Invoice cannot be held."))
|
||||
@@ -396,6 +438,9 @@ class PurchaseInvoice(BuyingController):
|
||||
self.party_account_currency = account.account_currency
|
||||
|
||||
def check_on_hold_or_closed_status(self):
|
||||
if self.get("is_return"):
|
||||
return
|
||||
|
||||
check_list = []
|
||||
|
||||
for d in self.get("items"):
|
||||
@@ -1375,7 +1420,20 @@ class PurchaseInvoice(BuyingController):
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
@@ -1400,7 +1458,20 @@ class PurchaseInvoice(BuyingController):
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
|
||||
stock_asset_rbnb = (
|
||||
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
fallback_account = (
|
||||
(item.expense_account or stock_asset_rbnb)
|
||||
if self.is_return
|
||||
else (stock_asset_rbnb or item.expense_account)
|
||||
)
|
||||
cost_of_goods_sold_account = (
|
||||
self.get_company_default("default_expense_account", ignore_validation=True)
|
||||
or fallback_account
|
||||
)
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
|
||||
@@ -513,6 +513,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
)
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company="_Test Company with perpetual inventory",
|
||||
@@ -524,25 +530,15 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pi.insert)
|
||||
|
||||
pi.conversion_rate = 70
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[0].base_net_amount - pr.items[0].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
frappe.db.set_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
@@ -550,11 +546,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
# Creating Purchase Invoice with USD currency
|
||||
original_value = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Buying Settings",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
original_value,
|
||||
)
|
||||
|
||||
pr = frappe.new_doc("Purchase Receipt")
|
||||
pr.currency = "USD"
|
||||
pr.company = "_Test Company with perpetual inventory"
|
||||
pr.conversion_rate = (70,)
|
||||
pr.conversion_rate = 80
|
||||
pr.supplier = "_Test Supplier USD"
|
||||
pr.append(
|
||||
"items",
|
||||
@@ -564,34 +570,20 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
"rate": 100,
|
||||
},
|
||||
)
|
||||
pr.append(
|
||||
"items",
|
||||
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
|
||||
)
|
||||
pr.insert()
|
||||
pr.submit()
|
||||
|
||||
# Createing purchase invoice against Purchase Receipt
|
||||
pi = create_purchase_invoice(pr.name)
|
||||
pi.conversion_rate = 80
|
||||
pi.conversion_rate = 70
|
||||
pi.credit_to = "_Test Payable USD - TCP1"
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
# Get exchnage gain and loss account
|
||||
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
|
||||
|
||||
# fetching the latest GL Entry with exchange gain and loss account account
|
||||
amount = frappe.db.get_value(
|
||||
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
|
||||
self.assertFalse(
|
||||
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
|
||||
)
|
||||
|
||||
discrepancy_caused_by_exchange_rate_diff = abs(
|
||||
pi.items[1].base_net_amount - pr.items[1].base_net_amount
|
||||
)
|
||||
|
||||
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
|
||||
|
||||
def test_purchase_invoice_change_naming_series(self):
|
||||
pi = frappe.copy_doc(test_records[1])
|
||||
pi.insert()
|
||||
@@ -1662,6 +1654,96 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
|
||||
|
||||
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
|
||||
|
||||
class StockAdjustmentInvoice:
|
||||
company = "_Test Company"
|
||||
conversion_rate = 1
|
||||
update_stock = 1
|
||||
is_internal_supplier = 0
|
||||
return_against = None
|
||||
project = None
|
||||
|
||||
def __init__(self, is_return, defaults):
|
||||
self.is_return = is_return
|
||||
self.defaults = defaults
|
||||
|
||||
def get(self, fieldname):
|
||||
return None
|
||||
|
||||
def get_company_default(self, fieldname, ignore_validation=False):
|
||||
return self.defaults.get(fieldname)
|
||||
|
||||
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
|
||||
return frappe._dict(args)
|
||||
|
||||
def make_invoice(is_return, defaults):
|
||||
return StockAdjustmentInvoice(is_return, defaults)
|
||||
|
||||
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
|
||||
return frappe._dict(
|
||||
{
|
||||
"name": "row-1",
|
||||
"warehouse": "Stores - _TC",
|
||||
"valuation_rate": 10,
|
||||
"qty": 10,
|
||||
"conversion_factor": 1,
|
||||
"base_net_amount": 100,
|
||||
"item_tax_amount": 0,
|
||||
"landed_cost_voucher_amount": 0,
|
||||
"sales_incoming_rate": 0,
|
||||
"is_fixed_asset": is_fixed_asset,
|
||||
"expense_account": expense_account,
|
||||
"cost_center": "Main - _TC",
|
||||
"project": None,
|
||||
"precision": lambda fieldname: 2,
|
||||
}
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"default_expense_account": None,
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
|
||||
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
|
||||
}
|
||||
test_cases = (
|
||||
(
|
||||
"company default expense",
|
||||
0,
|
||||
make_item(),
|
||||
{**defaults, "default_expense_account": "Default Expense - _TC"},
|
||||
"Default Expense - _TC",
|
||||
),
|
||||
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
|
||||
(
|
||||
"asset rbnb",
|
||||
0,
|
||||
make_item(is_fixed_asset=1),
|
||||
defaults,
|
||||
"Asset Received But Not Billed - _TC",
|
||||
),
|
||||
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
|
||||
(
|
||||
"return without item expense",
|
||||
1,
|
||||
make_item(expense_account=None),
|
||||
defaults,
|
||||
"Stock Received But Not Billed - _TC",
|
||||
),
|
||||
)
|
||||
|
||||
for label, is_return, item, company_defaults, expected_account in test_cases:
|
||||
with self.subTest(label=label):
|
||||
invoice = make_invoice(is_return, company_defaults)
|
||||
gl_entries = []
|
||||
PurchaseInvoice.make_stock_adjustment_entry(
|
||||
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
|
||||
)
|
||||
|
||||
self.assertEqual(gl_entries[0].account, expected_account)
|
||||
self.assertEqual(gl_entries[0].debit, 10)
|
||||
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
|
||||
|
||||
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
|
||||
def test_purchase_invoice_advance_taxes(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
@@ -2609,6 +2691,39 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
self.assertEqual(row.serial_no, "\n".join(serial_nos[:2]))
|
||||
self.assertEqual(row.rejected_serial_no, serial_nos[2])
|
||||
|
||||
def test_purchase_invoice_return_against_closed_purchase_order(self):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
po = create_purchase_order(qty=2, rate=100)
|
||||
|
||||
invoices = []
|
||||
for _ in range(2):
|
||||
pi = make_pi_from_po(po.name)
|
||||
pi.items[0].qty = 1
|
||||
pi.submit()
|
||||
invoices.append(pi)
|
||||
|
||||
make_return_doc("Purchase Invoice", invoices[0].name).submit()
|
||||
|
||||
po.reload()
|
||||
po.update_status("Closed")
|
||||
|
||||
# a debit note against a closed Purchase Order should still go through,
|
||||
# the same way a Sales Invoice return does against a closed Sales Order
|
||||
debit_note = make_return_doc("Purchase Invoice", invoices[1].name)
|
||||
debit_note.submit()
|
||||
|
||||
self.assertEqual(debit_note.docstatus, 1)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed")
|
||||
|
||||
# cancelling the debit note runs the same check on the closed order
|
||||
debit_note.reload()
|
||||
debit_note.cancel()
|
||||
|
||||
# a regular invoice against the closed order must still be blocked
|
||||
blocked_pi = make_pi_from_po(po.name)
|
||||
self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pi.save)
|
||||
|
||||
def test_make_pr_and_pi_from_po(self):
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset_category
|
||||
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
"project",
|
||||
"section_break_9",
|
||||
"account_currency",
|
||||
"net_amount",
|
||||
"tax_amount",
|
||||
"tax_amount_after_discount_amount",
|
||||
"total",
|
||||
"column_break_14",
|
||||
"base_net_amount",
|
||||
"base_tax_amount",
|
||||
"base_total",
|
||||
"base_tax_amount_after_discount_amount",
|
||||
@@ -213,11 +215,11 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -241,20 +243,38 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Tax Withholding Account",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Basis for tax calculation",
|
||||
"fieldname": "net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Basis for tax calculation",
|
||||
"fieldname": "base_net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount (Company Currency)",
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-15 13:14:48.936047",
|
||||
"modified": "2026-05-01 00:38:29.543523",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Taxes and Charges",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
|
||||
account_currency: DF.Link | None
|
||||
account_head: DF.Link
|
||||
add_deduct_tax: DF.Literal["Add", "Deduct"]
|
||||
base_net_amount: DF.Currency
|
||||
base_tax_amount: DF.Currency
|
||||
base_tax_amount_after_discount_amount: DF.Currency
|
||||
base_total: DF.Currency
|
||||
@@ -35,9 +36,11 @@ class PurchaseTaxesandCharges(Document):
|
||||
included_in_print_rate: DF.Check
|
||||
is_tax_withholding_account: DF.Check
|
||||
item_wise_tax_detail: DF.Code | None
|
||||
net_amount: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
project: DF.Link | None
|
||||
rate: DF.Float
|
||||
row_id: DF.Data | None
|
||||
tax_amount: DF.Currency
|
||||
|
||||
@@ -25,9 +25,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.party import (
|
||||
CROSS_PARTY_FIELD_NO_MAP,
|
||||
_get_party_details,
|
||||
get_due_date,
|
||||
get_party_account,
|
||||
get_party_details,
|
||||
)
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
@@ -2266,9 +2266,9 @@ def make_delivery_note(source_name, target_doc=None):
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1
|
||||
and not doc.dn_detail
|
||||
and doc.qty - doc.delivered_qty > 0,
|
||||
"condition": lambda doc: (
|
||||
doc.delivered_by_supplier != 1 and not doc.dn_detail and doc.qty - doc.delivered_qty > 0
|
||||
),
|
||||
},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
"Sales Team": {
|
||||
@@ -2737,7 +2737,7 @@ def update_taxes(
|
||||
master_doctype=None,
|
||||
):
|
||||
# Update Party Details
|
||||
party_details = get_party_details(
|
||||
party_details = _get_party_details(
|
||||
party=party,
|
||||
party_type=party_type,
|
||||
company=company,
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
"rate",
|
||||
"section_break_9",
|
||||
"account_currency",
|
||||
"net_amount",
|
||||
"tax_amount",
|
||||
"total",
|
||||
"tax_amount_after_discount_amount",
|
||||
"column_break_13",
|
||||
"base_net_amount",
|
||||
"base_tax_amount",
|
||||
"base_total",
|
||||
"base_tax_amount_after_discount_amount",
|
||||
@@ -190,11 +192,11 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -220,19 +222,36 @@
|
||||
"label": "Account Currency",
|
||||
"options": "Currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Basis for tax calculation",
|
||||
"fieldname": "net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Basis for tax calculation",
|
||||
"fieldname": "base_net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount (Company Currency)",
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-14 10:08:17.776528",
|
||||
"modified": "2026-05-01 00:37:57.880071",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Taxes and Charges",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class SalesTaxesandCharges(Document):
|
||||
|
||||
account_currency: DF.Link | None
|
||||
account_head: DF.Link
|
||||
base_net_amount: DF.Currency
|
||||
base_tax_amount: DF.Currency
|
||||
base_tax_amount_after_discount_amount: DF.Currency
|
||||
base_total: DF.Currency
|
||||
@@ -33,9 +34,11 @@ class SalesTaxesandCharges(Document):
|
||||
included_in_paid_amount: DF.Check
|
||||
included_in_print_rate: DF.Check
|
||||
item_wise_tax_detail: DF.Code | None
|
||||
net_amount: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
project: DF.Link | None
|
||||
rate: DF.Float
|
||||
row_id: DF.Data | None
|
||||
tax_amount: DF.Currency
|
||||
|
||||
@@ -7,10 +7,9 @@ def get_data():
|
||||
"non_standard_fieldnames": {
|
||||
"Tax Rule": "sales_tax_template",
|
||||
"Subscription": "sales_tax_template",
|
||||
"Restaurant": "default_tax_template",
|
||||
},
|
||||
"transactions": [
|
||||
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
|
||||
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ def get_party_details(
|
||||
price_list=None,
|
||||
currency=None,
|
||||
doctype=None,
|
||||
ignore_permissions=False,
|
||||
fetch_payment_terms_template=True,
|
||||
party_address=None,
|
||||
company_address=None,
|
||||
@@ -93,8 +92,6 @@ def get_party_details(
|
||||
):
|
||||
if not party:
|
||||
return frappe._dict()
|
||||
if not frappe.db.exists(party_type, party):
|
||||
frappe.throw(_("{0}: {1} does not exists").format(party_type, party))
|
||||
return _get_party_details(
|
||||
party,
|
||||
account,
|
||||
@@ -105,7 +102,7 @@ def get_party_details(
|
||||
price_list,
|
||||
currency,
|
||||
doctype,
|
||||
ignore_permissions,
|
||||
False,
|
||||
fetch_payment_terms_template,
|
||||
party_address,
|
||||
company_address,
|
||||
|
||||
@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
options: get_party_type_options(),
|
||||
on_change: function () {
|
||||
frappe.query_report.set_filter_value("party", "");
|
||||
frappe.query_report.toggle_filter_display(
|
||||
"supplier_group",
|
||||
frappe.query_report.get_filter_value("party_type") !== "Supplier"
|
||||
);
|
||||
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
|
||||
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
|
||||
if (supplier_group_filter) {
|
||||
supplier_group_filter.df.hidden = !is_supplier;
|
||||
}
|
||||
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
|
||||
if (!is_supplier) {
|
||||
frappe.query_report.set_filter_value("supplier_group", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -93,5 +93,27 @@ frappe.query_reports["Customer Ledger Summary"] = {
|
||||
fieldtype: "Data",
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "cost_center",
|
||||
label: __("Cost Center"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Cost Center",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Cost Center", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -174,7 +174,7 @@ frappe.query_reports["General Ledger"] = {
|
||||
fieldname: "include_dimensions",
|
||||
label: __("Consider Accounting Dimensions"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
default: frappe.boot.sysdefaults.disable_include_dimensions ? 0 : 1,
|
||||
},
|
||||
{
|
||||
fieldname: "disable_opening_balance_calculation",
|
||||
|
||||
@@ -74,5 +74,27 @@ frappe.query_reports["Supplier Ledger Summary"] = {
|
||||
fieldtype: "Data",
|
||||
hidden: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "cost_center",
|
||||
label: __("Cost Center"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Cost Center",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Cost Center", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "MultiSelectList",
|
||||
options: "Project",
|
||||
get_data: function (txt) {
|
||||
return frappe.db.get_link_options("Project", txt, {
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.party import get_party_shipping_address
|
||||
from erpnext.accounts.utils import (
|
||||
get_currency_precision,
|
||||
get_future_stock_vouchers,
|
||||
get_voucherwise_gl_entries,
|
||||
get_zero_cutoff,
|
||||
@@ -164,6 +165,21 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertEqual(get_zero_cutoff("EUR"), 0.005)
|
||||
self.assertEqual(get_zero_cutoff("BHD"), 0.0005)
|
||||
|
||||
def test_get_currency_precision_respects_zero_and_fallback(self):
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
number_format = frappe.db.get_default("number_format")
|
||||
|
||||
try:
|
||||
frappe.db.set_default("number_format", "#,###.##")
|
||||
frappe.db.set_default("currency_precision", "0")
|
||||
self.assertEqual(get_currency_precision(), 0)
|
||||
|
||||
frappe.db.set_default("currency_precision", "")
|
||||
self.assertEqual(get_currency_precision(), 2)
|
||||
finally:
|
||||
frappe.db.set_default("currency_precision", currency_precision or "")
|
||||
frappe.db.set_default("number_format", number_format or "#,###.##")
|
||||
|
||||
|
||||
ADDRESS_RECORDS = [
|
||||
{
|
||||
|
||||
@@ -1131,12 +1131,12 @@ def fix_total_debit_credit():
|
||||
|
||||
|
||||
def get_currency_precision():
|
||||
precision = cint(frappe.db.get_default("currency_precision"))
|
||||
if not precision:
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
precision = get_number_format_info(number_format)[2]
|
||||
currency_precision = frappe.db.get_default("currency_precision")
|
||||
if currency_precision not in (None, ""):
|
||||
return cint(currency_precision)
|
||||
|
||||
return precision
|
||||
number_format = frappe.db.get_default("number_format") or "#,###.##"
|
||||
return get_number_format_info(number_format)[2]
|
||||
|
||||
|
||||
def get_fraction_units(currency: str) -> int:
|
||||
|
||||
@@ -81,7 +81,7 @@ def post_depreciation_entries(date=None):
|
||||
)
|
||||
|
||||
try:
|
||||
make_depreciation_entry(
|
||||
_make_depreciation_entry(
|
||||
asset_depr_schedule_name,
|
||||
date,
|
||||
sch_start_idx,
|
||||
@@ -139,7 +139,7 @@ def get_depreciable_asset_depr_schedules_data(date):
|
||||
def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None):
|
||||
for row in asset_doc.get("finance_books"):
|
||||
asset_depr_schedule_name = get_asset_depr_schedule_name(asset_doc.name, "Active", row.finance_book)
|
||||
make_depreciation_entry(asset_depr_schedule_name, date)
|
||||
_make_depreciation_entry(asset_depr_schedule_name, date)
|
||||
|
||||
|
||||
def get_acc_frozen_upto():
|
||||
@@ -193,6 +193,30 @@ def make_depreciation_entry(
|
||||
credit_and_debit_accounts=None,
|
||||
depreciation_cost_center_and_depreciation_series=None,
|
||||
accounting_dimensions=None,
|
||||
):
|
||||
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
|
||||
frappe.has_permission("Asset Depreciation Schedule", "write", asset_depr_schedule_doc, throw=True)
|
||||
frappe.has_permission("Asset", "write", asset_depr_schedule_doc.asset, throw=True)
|
||||
|
||||
return _make_depreciation_entry(
|
||||
asset_depr_schedule_name,
|
||||
date,
|
||||
sch_start_idx,
|
||||
sch_end_idx,
|
||||
credit_and_debit_accounts,
|
||||
depreciation_cost_center_and_depreciation_series,
|
||||
accounting_dimensions,
|
||||
)
|
||||
|
||||
|
||||
def _make_depreciation_entry(
|
||||
asset_depr_schedule_name,
|
||||
date=None,
|
||||
sch_start_idx=None,
|
||||
sch_end_idx=None,
|
||||
credit_and_debit_accounts=None,
|
||||
depreciation_cost_center_and_depreciation_series=None,
|
||||
accounting_dimensions=None,
|
||||
):
|
||||
frappe.has_permission("Journal Entry", throw=True)
|
||||
|
||||
@@ -395,6 +419,7 @@ def get_comma_separated_links(names, doctype):
|
||||
|
||||
@frappe.whitelist()
|
||||
def scrap_asset(asset_name, scrap_date=None):
|
||||
frappe.has_permission("Asset", "write", asset_name, throw=True)
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
if asset.docstatus != 1:
|
||||
@@ -472,6 +497,7 @@ def validate_scrap_date(scrap_date, today_date, purchase_date, calculate_depreci
|
||||
|
||||
@frappe.whitelist()
|
||||
def restore_asset(asset_name):
|
||||
frappe.has_permission("Asset", "write", asset_name, throw=True)
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
reverse_depreciation_entry_made_after_disposal(asset, asset.disposal_date)
|
||||
|
||||
@@ -166,6 +166,8 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
for d in self.asset_items:
|
||||
args = self.as_dict()
|
||||
args.update(d.as_dict())
|
||||
@@ -187,6 +189,30 @@ class AssetCapitalization(StockController):
|
||||
if d.meta.has_field(k) and (not d.get(k) or k in force_fields):
|
||||
d.set(k, v)
|
||||
|
||||
def split_valuation_rate_for_grouped_stock_items(self):
|
||||
groups = {}
|
||||
for d in self.stock_items:
|
||||
if d.item_code and d.warehouse and not (d.serial_no or d.batch_no or d.serial_and_batch_bundle):
|
||||
groups.setdefault((d.item_code, d.warehouse), []).append(d)
|
||||
|
||||
for rows in groups.values():
|
||||
if len(rows) < 2:
|
||||
continue
|
||||
|
||||
cumulative_qty = 0.0
|
||||
prev_cumulative_value = 0.0
|
||||
for d in rows:
|
||||
cumulative_qty += flt(d.stock_qty)
|
||||
args = self.get_args_for_incoming_rate(d)
|
||||
args["qty"] = -1 * cumulative_qty
|
||||
cumulative_rate = flt(get_incoming_rate(args, raise_error_if_no_rate=False))
|
||||
cumulative_value = cumulative_rate * cumulative_qty
|
||||
|
||||
row_value = cumulative_value - prev_cumulative_value
|
||||
d.valuation_rate = flt(row_value / d.stock_qty) if flt(d.stock_qty) else 0.0
|
||||
d.amount = flt(flt(d.stock_qty) * d.valuation_rate, d.precision("amount"))
|
||||
prev_cumulative_value = cumulative_value
|
||||
|
||||
def validate_target_item(self):
|
||||
target_item = frappe.get_cached_doc("Item", self.target_item_code)
|
||||
|
||||
@@ -338,6 +364,8 @@ class AssetCapitalization(StockController):
|
||||
warehouse_details = get_warehouse_details(args)
|
||||
d.update(warehouse_details)
|
||||
|
||||
self.split_valuation_rate_for_grouped_stock_items()
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_asset_values(self):
|
||||
for d in self.get("asset_items"):
|
||||
|
||||
@@ -10,12 +10,14 @@ from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import (
|
||||
create_asset,
|
||||
create_asset_data,
|
||||
create_fixed_asset_item,
|
||||
set_depreciation_settings_in_company,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_asset_depr_schedule_doc,
|
||||
)
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
@@ -340,6 +342,33 @@ class TestAssetCapitalization(unittest.TestCase):
|
||||
self.assertFalse(get_actual_gle_dict(asset_capitalization.name))
|
||||
self.assertFalse(get_actual_sle_dict(asset_capitalization.name))
|
||||
|
||||
def test_grouped_stock_item_rows_split_fifo_rate(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
company = "_Test Company"
|
||||
warehouse = create_warehouse("_Test Warehouse for Grouped FIFO Rows", company=company)
|
||||
item = create_item(
|
||||
"_Test Grouped FIFO Rows Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=1
|
||||
)
|
||||
target_item = create_fixed_asset_item("_Test Grouped FIFO Rows Target Item")
|
||||
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=100, company=company, warehouse=warehouse)
|
||||
make_purchase_receipt(item_code=item.item_code, qty=1, rate=200, company=company, warehouse=warehouse)
|
||||
|
||||
asset_capitalization = frappe.new_doc("Asset Capitalization")
|
||||
asset_capitalization.company = company
|
||||
asset_capitalization.target_item_code = target_item.name
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.append(
|
||||
"stock_items", {"item_code": item.item_code, "warehouse": warehouse, "stock_qty": 1}
|
||||
)
|
||||
asset_capitalization.insert()
|
||||
|
||||
rates = [d.valuation_rate for d in asset_capitalization.stock_items]
|
||||
self.assertEqual(rates, [100, 200])
|
||||
|
||||
|
||||
def create_asset_capitalization_data():
|
||||
create_item("Capitalization Target Stock Item", is_stock_item=1, is_fixed_asset=0, is_purchase_item=0)
|
||||
|
||||
@@ -9,13 +9,18 @@ from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.query_builder import Order
|
||||
from frappe.utils import get_url
|
||||
from frappe.utils.print_format import download_pdf
|
||||
from frappe.utils.user import get_user_fullname
|
||||
|
||||
from erpnext.accounts.party import get_party_account_currency, get_party_details
|
||||
from erpnext.accounts.party import (
|
||||
_get_party_details,
|
||||
get_party_account_currency,
|
||||
validate_party_frozen_disabled,
|
||||
)
|
||||
from erpnext.buying.utils import validate_for_items
|
||||
from erpnext.controllers.buying_controller import BuyingController
|
||||
from erpnext.stock.doctype.material_request.material_request import set_missing_values
|
||||
@@ -123,6 +128,8 @@ class RequestforQuotation(BuyingController):
|
||||
|
||||
def validate_supplier_list(self):
|
||||
for d in self.suppliers:
|
||||
validate_party_frozen_disabled("Supplier", d.supplier)
|
||||
|
||||
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
|
||||
if prevent_rfqs:
|
||||
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")
|
||||
@@ -443,7 +450,7 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
|
||||
def postprocess(source, target_doc):
|
||||
if for_supplier:
|
||||
target_doc.supplier = for_supplier
|
||||
args = get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
|
||||
args = _get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
|
||||
target_doc.currency = args.currency or get_party_account_currency(
|
||||
"Supplier", for_supplier, source.company
|
||||
)
|
||||
@@ -479,36 +486,73 @@ def make_supplier_quotation_from_rfq(source_name, target_doc=None, for_supplier=
|
||||
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc):
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
supplier = doc.get("supplier")
|
||||
|
||||
if frappe.session.user not in frappe.get_all(
|
||||
"Portal User", {"parent": doc.get("supplier")}, pluck="user"
|
||||
):
|
||||
if frappe.session.user not in frappe.get_all("Portal User", {"parent": supplier}, pluck="user"):
|
||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||
|
||||
try:
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": doc.get("supplier"),
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_value("Buying Settings", None, "buying_price_list"),
|
||||
}
|
||||
validate_existing_supplier_quotation(supplier, doc.get("items"))
|
||||
|
||||
sq_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Supplier Quotation",
|
||||
"supplier": supplier,
|
||||
"terms": doc.get("terms"),
|
||||
"company": doc.get("company"),
|
||||
"currency": doc.get("currency")
|
||||
or get_party_account_currency("Supplier", supplier, doc.get("company")),
|
||||
"buying_price_list": doc.get("buying_price_list")
|
||||
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
|
||||
}
|
||||
)
|
||||
add_items(sq_doc, supplier, doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
|
||||
|
||||
def validate_existing_supplier_quotation(supplier, items):
|
||||
request_for_quotations = {item.get("parent") for item in items if item.get("parent")}
|
||||
if not request_for_quotations:
|
||||
return
|
||||
|
||||
rfq = frappe.qb.DocType("Request for Quotation")
|
||||
(
|
||||
frappe.qb.from_(rfq)
|
||||
.select(rfq.name)
|
||||
.where(rfq.name.isin(request_for_quotations))
|
||||
.orderby(rfq.name)
|
||||
.for_update()
|
||||
).run()
|
||||
|
||||
sq = frappe.qb.DocType("Supplier Quotation")
|
||||
sqi = frappe.qb.DocType("Supplier Quotation Item")
|
||||
existing_quotation = (
|
||||
frappe.qb.from_(sq)
|
||||
.inner_join(sqi)
|
||||
.on(sq.name == sqi.parent)
|
||||
.select(sq.name, sqi.request_for_quotation)
|
||||
.where(
|
||||
(sq.docstatus < 2)
|
||||
& (sq.supplier == supplier)
|
||||
& (sqi.request_for_quotation.isin(request_for_quotations))
|
||||
)
|
||||
.limit(1)
|
||||
).run(as_dict=True)
|
||||
|
||||
if existing_quotation:
|
||||
existing_quotation = existing_quotation[0]
|
||||
frappe.throw(
|
||||
_("Supplier Quotation {0} already exists against Request for Quotation {1}").format(
|
||||
frappe.bold(existing_quotation.name),
|
||||
frappe.bold(existing_quotation.request_for_quotation),
|
||||
)
|
||||
)
|
||||
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
|
||||
sq_doc.flags.ignore_permissions = True
|
||||
sq_doc.run_method("set_missing_values")
|
||||
sq_doc.save()
|
||||
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
|
||||
return sq_doc.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def add_items(sq_doc, supplier, items):
|
||||
|
||||
@@ -17,6 +17,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
|
||||
|
||||
@@ -57,6 +58,17 @@ class TestRequestforQuotation(FrappeTestCase):
|
||||
self.assertEqual(rfq.get("suppliers")[0].quote_status, "Received")
|
||||
self.assertEqual(rfq.get("suppliers")[1].quote_status, "Pending")
|
||||
|
||||
def test_rfq_blocked_for_disabled_supplier(self):
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
|
||||
rfq = make_request_for_quotation(
|
||||
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
|
||||
do_not_save=True,
|
||||
)
|
||||
self.assertRaises(PartyDisabled, rfq.save)
|
||||
|
||||
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
|
||||
rfq.save()
|
||||
|
||||
def test_make_supplier_quotation(self):
|
||||
rfq = make_request_for_quotation()
|
||||
|
||||
@@ -149,6 +161,18 @@ class TestRequestforQuotation(FrappeTestCase):
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].qty, 5)
|
||||
self.assertEqual(supplier_quotation_doc.get("items")[0].amount, 500)
|
||||
|
||||
def test_make_duplicate_supplier_quotation_from_portal(self):
|
||||
rfq = make_request_for_quotation()
|
||||
rfq.supplier = rfq.suppliers[0].supplier
|
||||
supplier_quotation = frappe.get_doc("Supplier Quotation", create_supplier_quotation(rfq))
|
||||
supplier_quotation.submit()
|
||||
|
||||
with self.assertRaisesRegex(frappe.ValidationError, "already exists"):
|
||||
create_supplier_quotation(rfq)
|
||||
|
||||
supplier_quotation.cancel()
|
||||
self.assertTrue(create_supplier_quotation(rfq))
|
||||
|
||||
def test_make_multi_uom_supplier_quotation(self):
|
||||
item_code = "_Test Multi UOM RFQ Item"
|
||||
if not frappe.db.exists("Item", item_code):
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Supplier",
|
||||
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
|
||||
"options": "Supplier",
|
||||
"reqd": 1
|
||||
},
|
||||
|
||||
@@ -125,12 +125,12 @@ class TestSupplier(FrappeTestCase):
|
||||
self.assertEqual(supplier.country, "Greece")
|
||||
|
||||
def test_party_details_tax_category(self):
|
||||
from erpnext.accounts.party import get_party_details
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
|
||||
frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
|
||||
|
||||
# Tax Category without Address
|
||||
details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
|
||||
details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
|
||||
self.assertEqual(details.tax_category, "_Test Tax Category 1")
|
||||
|
||||
address = frappe.get_doc(
|
||||
@@ -147,7 +147,7 @@ class TestSupplier(FrappeTestCase):
|
||||
).insert()
|
||||
|
||||
# Tax Category with Address
|
||||
details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
|
||||
details = _get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
|
||||
self.assertEqual(details.tax_category, "_Test Tax Category 2")
|
||||
|
||||
# Rollback
|
||||
|
||||
@@ -85,6 +85,17 @@ frappe.query_reports["Supplier Quotation Comparison"] = {
|
||||
],
|
||||
default: __("Categorize by Supplier"),
|
||||
},
|
||||
{
|
||||
fieldname: "status",
|
||||
label: __("Status"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ label: "", value: "" },
|
||||
{ label: __("Draft"), value: "Draft" },
|
||||
{ label: __("Submitted"), value: "Submitted" },
|
||||
],
|
||||
default: "Submitted",
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: __("Include Expired"),
|
||||
|
||||
@@ -58,13 +58,20 @@ def get_data(filters):
|
||||
)
|
||||
.where(
|
||||
(sq_item.parent == sq.name)
|
||||
& (sq_item.docstatus < 2)
|
||||
& (sq.company == filters.get("company"))
|
||||
& (sq.transaction_date.between(filters.get("from_date"), filters.get("to_date")))
|
||||
)
|
||||
.orderby(sq.transaction_date, sq_item.item_code)
|
||||
)
|
||||
|
||||
# blank -> Draft + Submitted, else filter to the chosen docstatus
|
||||
if filters.get("status") == "Draft":
|
||||
query = query.where(sq_item.docstatus == 0)
|
||||
elif filters.get("status") == "Submitted":
|
||||
query = query.where(sq_item.docstatus == 1)
|
||||
else:
|
||||
query = query.where(sq_item.docstatus < 2)
|
||||
|
||||
if filters.get("item_code"):
|
||||
query = query.where(sq_item.item_code == filters.get("item_code"))
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ from erpnext.setup.utils import get_exchange_rate
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
_get_item_tax_template,
|
||||
_get_item_tax_template_from_item_group,
|
||||
get_bin_details,
|
||||
@@ -719,6 +720,8 @@ class AccountsController(TransactionBase):
|
||||
self.validate_non_invoice_documents_schedule()
|
||||
|
||||
def before_print(self, settings=None):
|
||||
self.set_missing_terms()
|
||||
|
||||
if self.doctype in [
|
||||
"Purchase Order",
|
||||
"Sales Order",
|
||||
@@ -742,6 +745,16 @@ class AccountsController(TransactionBase):
|
||||
set_print_templates_for_item_table(self, settings)
|
||||
set_print_templates_for_taxes(self, settings)
|
||||
|
||||
def set_missing_terms(self):
|
||||
if not self.get("tc_name") or self.get("terms"):
|
||||
return
|
||||
|
||||
from erpnext.setup.doctype.terms_and_conditions.terms_and_conditions import (
|
||||
get_terms_and_conditions,
|
||||
)
|
||||
|
||||
self.terms = get_terms_and_conditions(self.tc_name, self.as_dict())
|
||||
|
||||
def calculate_paid_amount(self):
|
||||
if hasattr(self, "is_pos") or hasattr(self, "is_paid"):
|
||||
is_paid = self.get("is_pos") or self.get("is_paid")
|
||||
@@ -1282,7 +1295,10 @@ class AccountsController(TransactionBase):
|
||||
if isinstance(item_tax_rate, str):
|
||||
item_tax_rate = parse_json(item_tax_rate)
|
||||
|
||||
for account_head, _rate in item_tax_rate.items():
|
||||
for account_head, rate in item_tax_rate.items():
|
||||
if rate == NOT_APPLICABLE_TAX:
|
||||
continue
|
||||
|
||||
row = self.get_tax_row(account_head)
|
||||
|
||||
if not row:
|
||||
@@ -3709,8 +3725,11 @@ def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True):
|
||||
|
||||
if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template:
|
||||
tax_map = json.loads(child_item.get("item_tax_rate"))
|
||||
for tax_type in tax_map:
|
||||
tax_rate = flt(tax_map[tax_type])
|
||||
for tax_type, tax_rate in tax_map.items():
|
||||
if tax_rate == NOT_APPLICABLE_TAX:
|
||||
continue
|
||||
|
||||
tax_rate = flt(tax_rate)
|
||||
taxes = parent_doc.get("taxes") or []
|
||||
# add new row for tax head only if missing
|
||||
found = any(tax.account_head == tax_type for tax in taxes)
|
||||
|
||||
@@ -11,7 +11,7 @@ from frappe.utils.data import nowtime
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
|
||||
from erpnext.accounts.party import get_party_details
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
|
||||
@@ -165,7 +165,7 @@ class BuyingController(SubcontractingController):
|
||||
# set contact and address details for supplier, if they are not mentioned
|
||||
if getattr(self, "supplier", None):
|
||||
self.update_if_missing(
|
||||
get_party_details(
|
||||
_get_party_details(
|
||||
self.supplier,
|
||||
party_type="Supplier",
|
||||
doctype=self.doctype,
|
||||
@@ -755,7 +755,7 @@ class BuyingController(SubcontractingController):
|
||||
if po and po_item_rows:
|
||||
po_obj = frappe.get_doc("Purchase Order", po)
|
||||
|
||||
if po_obj.status in ["Closed", "Cancelled"]:
|
||||
if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")):
|
||||
frappe.throw(
|
||||
_("{0} {1} is cancelled or closed").format(_("Purchase Order"), po),
|
||||
frappe.InvalidStatusError,
|
||||
|
||||
@@ -213,7 +213,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
else 0
|
||||
)
|
||||
|
||||
if column == "stock_qty" and not args.get("return_qty_from_rejected_warehouse"):
|
||||
if column in ("stock_qty", "qty") and not args.get("return_qty_from_rejected_warehouse"):
|
||||
reference_qty = ref.get(column)
|
||||
current_stock_qty = args.get(column)
|
||||
elif args.get("return_qty_from_rejected_warehouse"):
|
||||
|
||||
@@ -560,7 +560,8 @@ class SellingController(StockController):
|
||||
reset_incoming_rate()
|
||||
|
||||
if (
|
||||
not d.incoming_rate
|
||||
(not d.incoming_rate or self.is_new())
|
||||
and not is_standalone
|
||||
or self.is_internal_transfer()
|
||||
or (get_valuation_method(d.item_code) == "Moving Average" and self.get("is_return"))
|
||||
):
|
||||
|
||||
@@ -19,7 +19,11 @@ from erpnext.controllers.accounts_controller import (
|
||||
validate_inclusive_tax,
|
||||
validate_taxes_and_charges,
|
||||
)
|
||||
from erpnext.stock.get_item_details import _get_item_tax_template, get_item_tax_map
|
||||
from erpnext.stock.get_item_details import (
|
||||
NOT_APPLICABLE_TAX,
|
||||
_get_item_tax_template,
|
||||
get_item_tax_map,
|
||||
)
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
|
||||
|
||||
@@ -275,6 +279,7 @@ class calculate_taxes_and_totals:
|
||||
tax.item_wise_tax_detail = {}
|
||||
|
||||
tax_fields = [
|
||||
"net_amount",
|
||||
"total",
|
||||
"tax_amount_after_discount_amount",
|
||||
"tax_amount_for_current_item",
|
||||
@@ -298,33 +303,32 @@ class calculate_taxes_and_totals:
|
||||
|
||||
for item in self.doc.items:
|
||||
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
|
||||
cumulated_tax_fraction = 0
|
||||
total_inclusive_tax_amount_per_qty = 0
|
||||
total_tax_slope = 0
|
||||
total_tax_intercept = 0
|
||||
for i, tax in enumerate(self.doc.get("taxes")):
|
||||
(
|
||||
tax.tax_fraction_for_current_item,
|
||||
inclusive_tax_amount_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map)
|
||||
tax_intercept_per_qty,
|
||||
) = self.get_current_tax_fraction(tax, item_tax_map, item)
|
||||
tax.inclusive_amount_per_qty = tax_intercept_per_qty
|
||||
|
||||
if i == 0:
|
||||
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
|
||||
tax.grand_total_amount_per_qty = tax_intercept_per_qty
|
||||
else:
|
||||
prev = self.doc.get("taxes")[i - 1]
|
||||
tax.grand_total_fraction_for_current_item = (
|
||||
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
|
||||
+ tax.tax_fraction_for_current_item
|
||||
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
|
||||
)
|
||||
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
|
||||
|
||||
cumulated_tax_fraction += tax.tax_fraction_for_current_item
|
||||
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
|
||||
total_tax_slope += tax.tax_fraction_for_current_item
|
||||
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
|
||||
|
||||
if (
|
||||
not self.discount_amount_applied
|
||||
and item.qty
|
||||
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
|
||||
):
|
||||
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
|
||||
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
|
||||
amount = flt(item.amount) - total_tax_intercept
|
||||
|
||||
item.net_amount = flt(amount / (1 + cumulated_tax_fraction), item.precision("net_amount"))
|
||||
item.net_amount = flt(amount / (1 + total_tax_slope), item.precision("net_amount"))
|
||||
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
|
||||
item.discount_percentage = flt(
|
||||
item.discount_percentage, item.precision("discount_percentage")
|
||||
@@ -335,44 +339,57 @@ class calculate_taxes_and_totals:
|
||||
def _load_item_tax_rate(self, item_tax_rate):
|
||||
return json.loads(item_tax_rate) if item_tax_rate else {}
|
||||
|
||||
def get_current_tax_fraction(self, tax, item_tax_map):
|
||||
def get_current_tax_fraction(self, tax, item_tax_map, item):
|
||||
"""
|
||||
Get tax fraction for calculating tax exclusive amount
|
||||
from tax inclusive amount
|
||||
tax = slope * net + intercept.
|
||||
Returns (slope, intercept_per_qty)
|
||||
"""
|
||||
current_tax_fraction = 0
|
||||
inclusive_tax_amount_per_qty = 0
|
||||
tax_slope = 0
|
||||
tax_intercept = 0
|
||||
|
||||
if cint(tax.included_in_print_rate):
|
||||
tax_rate = self._get_tax_rate(tax, item_tax_map)
|
||||
|
||||
if tax_rate == NOT_APPLICABLE_TAX:
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
if tax.charge_type == "On Net Total":
|
||||
current_tax_fraction = tax_rate / 100.0
|
||||
tax_slope = tax_rate / 100.0
|
||||
|
||||
elif tax.charge_type == "On Previous Row Amount":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].tax_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Previous Row Total":
|
||||
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].grand_total_fraction_for_current_item
|
||||
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
|
||||
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
|
||||
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
|
||||
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
inclusive_tax_amount_per_qty = flt(tax_rate)
|
||||
tax_intercept = flt(tax_rate)
|
||||
|
||||
else:
|
||||
# Custom charge_type: the rate applies to a resolved (fixed) base,
|
||||
# e.g. a tax on MRP included in the printed price.
|
||||
qty = flt(item.qty) or 1
|
||||
base = self.get_item_taxable_base(item, tax)
|
||||
tax_intercept = (tax_rate / 100.0) * base / qty
|
||||
|
||||
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
|
||||
current_tax_fraction *= -1.0
|
||||
inclusive_tax_amount_per_qty *= -1.0
|
||||
tax_slope *= -1.0
|
||||
tax_intercept *= -1.0
|
||||
|
||||
return current_tax_fraction, inclusive_tax_amount_per_qty
|
||||
return tax_slope, tax_intercept
|
||||
|
||||
def _get_tax_rate(self, tax, item_tax_map):
|
||||
if tax.account_head in item_tax_map:
|
||||
return flt(item_tax_map.get(tax.account_head), self.doc.precision("rate", tax))
|
||||
else:
|
||||
return tax.rate
|
||||
rate = item_tax_map[tax.account_head]
|
||||
if rate == NOT_APPLICABLE_TAX:
|
||||
return NOT_APPLICABLE_TAX
|
||||
return flt(rate, self.doc.precision("rate", tax))
|
||||
|
||||
return tax.rate
|
||||
|
||||
def calculate_net_total(self):
|
||||
self.doc.total_qty = (
|
||||
@@ -420,9 +437,12 @@ class calculate_taxes_and_totals:
|
||||
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
|
||||
for i, tax in enumerate(doc.taxes):
|
||||
# tax_amount represents the amount of tax for the current step
|
||||
current_tax_amount = self.get_current_tax_amount(item, tax, item_tax_map)
|
||||
current_net_amount, current_tax_amount = self.get_current_tax_and_net_amount(
|
||||
item, tax, item_tax_map
|
||||
)
|
||||
if frappe.flags.round_row_wise_tax:
|
||||
current_tax_amount = flt(current_tax_amount, tax.precision("tax_amount"))
|
||||
current_net_amount = flt(current_net_amount, tax.precision("net_amount"))
|
||||
|
||||
# Adjust divisional loss to the last item
|
||||
if tax.charge_type == "Actual":
|
||||
@@ -430,6 +450,10 @@ class calculate_taxes_and_totals:
|
||||
if n == len(self._items) - 1:
|
||||
current_tax_amount += actual_tax_dict[tax.idx]
|
||||
|
||||
# net_amount is the taxable basis, it feeds no total and is always
|
||||
# accumulated, unlike tax_amount which is kept from the first pass
|
||||
tax.net_amount += current_net_amount
|
||||
|
||||
# accumulate tax amount into tax.tax_amount
|
||||
if tax.charge_type != "Actual" and not (
|
||||
self.discount_amount_applied and self.doc.apply_discount_on == "Grand Total"
|
||||
@@ -480,7 +504,9 @@ class calculate_taxes_and_totals:
|
||||
|
||||
for i, tax in enumerate(doc.taxes):
|
||||
self.round_off_totals(tax)
|
||||
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
|
||||
self._set_in_company_currency(
|
||||
tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]
|
||||
)
|
||||
|
||||
self.round_off_base_values(tax)
|
||||
self.set_cumulative_total(i, tax)
|
||||
@@ -511,8 +537,17 @@ class calculate_taxes_and_totals:
|
||||
tax.total = flt(self.doc.get("taxes")[row_idx - 1].total + tax_amount, tax.precision("total"))
|
||||
|
||||
def get_current_tax_amount(self, item, tax, item_tax_map):
|
||||
# kept for backwards compatibility with callers outside this module
|
||||
_, current_tax_amount = self.get_current_tax_and_net_amount(item, tax, item_tax_map)
|
||||
return current_tax_amount
|
||||
|
||||
def get_current_tax_and_net_amount(self, item, tax, item_tax_map):
|
||||
tax_rate = self._get_tax_rate(tax, item_tax_map)
|
||||
current_tax_amount = 0.0
|
||||
current_net_amount = 0.0
|
||||
|
||||
if tax_rate == NOT_APPLICABLE_TAX:
|
||||
return current_net_amount, current_tax_amount
|
||||
|
||||
if tax.charge_type == "Actual":
|
||||
# distribute the tax amount proportionally to each item row
|
||||
@@ -522,29 +557,63 @@ class calculate_taxes_and_totals:
|
||||
if not item.get("apply_tds") or not self.doc.tax_withholding_net_total:
|
||||
current_tax_amount = 0.0
|
||||
else:
|
||||
current_tax_amount = item.net_amount * actual / self.doc.tax_withholding_net_total
|
||||
current_net_amount = item.net_amount
|
||||
current_tax_amount = current_net_amount * actual / self.doc.tax_withholding_net_total
|
||||
else:
|
||||
current_net_amount = item.net_amount
|
||||
current_tax_amount = (
|
||||
item.net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
|
||||
current_net_amount * actual / self.doc.net_total if self.doc.net_total else 0.0
|
||||
)
|
||||
|
||||
elif tax.charge_type == "On Net Total":
|
||||
current_net_amount = item.net_amount
|
||||
current_tax_amount = (tax_rate / 100.0) * item.net_amount
|
||||
elif tax.charge_type == "On Previous Row Amount":
|
||||
current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].tax_amount_for_current_item
|
||||
current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].tax_amount_for_current_item
|
||||
current_tax_amount = (tax_rate / 100.0) * current_net_amount
|
||||
elif tax.charge_type == "On Previous Row Total":
|
||||
current_tax_amount = (tax_rate / 100.0) * self.doc.get("taxes")[
|
||||
cint(tax.row_id) - 1
|
||||
].grand_total_for_current_item
|
||||
current_net_amount = self.doc.get("taxes")[cint(tax.row_id) - 1].grand_total_for_current_item
|
||||
current_tax_amount = (tax_rate / 100.0) * current_net_amount
|
||||
elif tax.charge_type == "On Item Quantity":
|
||||
# don't sum current net amount: net_amount field is currency-denominated
|
||||
current_tax_amount = tax_rate * item.qty
|
||||
else:
|
||||
# Custom charge_type: rate applies to the resolver-provided base.
|
||||
current_tax_amount = (tax_rate / 100.0) * self.get_item_taxable_base(item, tax)
|
||||
|
||||
if not (self.doc.get("is_consolidated") or tax.get("dont_recompute_tax")):
|
||||
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount)
|
||||
|
||||
return current_tax_amount
|
||||
return current_net_amount, current_tax_amount
|
||||
|
||||
def get_item_taxable_base(self, item, tax):
|
||||
"""Per-item base a custom charge_type's rate is applied to.
|
||||
|
||||
Override the base (gross, MRP, net of other taxes, …) via the
|
||||
`erpnext_taxable_base_resolvers` hook
|
||||
|
||||
Register a resolver in `hooks.py`, keyed by charge_type:
|
||||
|
||||
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
|
||||
|
||||
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
|
||||
and returns the base (flt-coerced by the caller):
|
||||
|
||||
def gross_base(calc, item, tax):
|
||||
return item.custom_field_mrp * item.qty
|
||||
|
||||
A resolver may stamp transient attributes on `item`; it can be called more than once
|
||||
per item, so such stamping must be idempotent.
|
||||
"""
|
||||
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
|
||||
path = resolvers.get(tax.charge_type)
|
||||
|
||||
if path:
|
||||
method = path[-1] if isinstance(path, list | tuple) else path
|
||||
return flt(frappe.get_attr(method)(self, item, tax))
|
||||
|
||||
# fallback
|
||||
return flt(item.net_amount)
|
||||
|
||||
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount):
|
||||
# store tax breakup for each item
|
||||
@@ -788,8 +857,9 @@ class calculate_taxes_and_totals:
|
||||
item.net_amount = flt(
|
||||
item.net_amount + rounding_difference, item.precision("net_amount")
|
||||
)
|
||||
# net_amount went up by rounding_difference, so its discount share goes down
|
||||
item.distributed_discount_amount = flt(
|
||||
distributed_amount + rounding_difference,
|
||||
distributed_amount - rounding_difference,
|
||||
item.precision("distributed_discount_amount"),
|
||||
)
|
||||
net_total += rounding_difference
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
@@ -60,6 +60,30 @@ class TestTaxesAndTotals(AccountsTestMixin, FrappeTestCase):
|
||||
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
|
||||
self.assertEqual(so.grand_total, 1400)
|
||||
|
||||
@change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_distributed_discount_amount_with_rounding_adjustment(self):
|
||||
so = make_sales_order(do_not_save=1)
|
||||
so.apply_discount_on = "Net Total"
|
||||
so.discount_amount = 10
|
||||
so.items[0].qty = 1
|
||||
so.items[0].rate = 100
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.append("items", so.items[0].as_dict())
|
||||
so.save()
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
# the rounding adjustment lands on the second line
|
||||
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
|
||||
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
|
||||
|
||||
for item in so.items:
|
||||
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
|
||||
self.assertAlmostEqual(
|
||||
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
|
||||
)
|
||||
self.assertEqual(so.net_total, 290)
|
||||
|
||||
def test_100_percent_discount_with_inclusive_tax(self):
|
||||
"""Test that 100% discount with inclusive taxes results in zero net_total"""
|
||||
so = make_sales_order(do_not_save=1)
|
||||
|
||||
@@ -87,3 +87,35 @@ class TestSalesAndPurchaseReturn(FrappeTestCase):
|
||||
return_si.items[0].qty = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, return_si.save)
|
||||
|
||||
def test_sales_invoice_partial_return_with_different_stock_uom(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_properties = {"is_stock_item": 1, "stock_uom": "Kg"}
|
||||
if frappe.get_meta("Item").has_field("gst_hsn_code") and frappe.db.exists("GST HSN Code", "010121"):
|
||||
item_properties["gst_hsn_code"] = "010121"
|
||||
|
||||
item = make_item(
|
||||
"_Test SI Return Different Stock UOM",
|
||||
item_properties,
|
||||
uoms=[{"uom": "Nos", "conversion_factor": 0.013888889}],
|
||||
)
|
||||
|
||||
si = create_sales_invoice(item_code=item.name, qty=48, do_not_save=True)
|
||||
si.items[0].uom = "Nos"
|
||||
si.items[0].stock_uom = "Kg"
|
||||
si.items[0].conversion_factor = 0.013888889
|
||||
si.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
|
||||
|
||||
first_return = make_return_doc(si.doctype, si.name)
|
||||
first_return.items[0].qty = -24
|
||||
first_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", first_return.name)
|
||||
|
||||
second_return = make_return_doc(si.doctype, si.name)
|
||||
self.assertEqual(second_return.items[0].qty, -24)
|
||||
second_return.save().submit()
|
||||
self.addCleanup(self._cancel_and_delete, "Sales Invoice", second_return.name)
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
|
||||
def resolve_on_gross(calc, item, tax):
|
||||
# base = gross printed line amount
|
||||
return flt(item.amount)
|
||||
|
||||
|
||||
def resolve_on_mrp(calc, item, tax):
|
||||
# base = MRP, not net
|
||||
return flt(item.price_list_rate) * flt(item.qty)
|
||||
|
||||
|
||||
class TestTaxesAndTotals(FrappeTestCase):
|
||||
def test_regional_round_off_accounts(self):
|
||||
"""
|
||||
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(FrappeTestCase):
|
||||
|
||||
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
|
||||
|
||||
def test_exclusive_custom_charge_on_resolved_base(self):
|
||||
"""Added (exclusive) custom charge_type whose base is resolved by the
|
||||
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
|
||||
-> tax 100, net 1000, grand 1100."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1000,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Gross Value",
|
||||
"account_head": "_Test Account Excise Duty - _TC",
|
||||
"description": "IPI 10% on gross product value",
|
||||
"rate": 10,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {
|
||||
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
|
||||
}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.net_total, 1000.0)
|
||||
self.assertEqual(so.taxes[0].tax_amount, 100.0)
|
||||
self.assertEqual(so.grand_total, 1100.0)
|
||||
|
||||
def test_inclusive_custom_charge_on_resolved_base(self):
|
||||
"""Inclusive custom charge on a resolved base backs out non-compounding
|
||||
(tax = rate x resolved base) — a resolved base is fixed, so it never
|
||||
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 1000,
|
||||
"price_list_rate": 1200,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On MRP",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": "Tax 10% on MRP, inclusive",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
real_get_hooks = frappe.get_hooks
|
||||
|
||||
def fake_get_hooks(hook=None, *args, **kwargs):
|
||||
if hook == "erpnext_taxable_base_resolvers":
|
||||
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
|
||||
return real_get_hooks(hook, *args, **kwargs)
|
||||
|
||||
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.taxes[0].tax_amount, 120.0)
|
||||
self.assertEqual(so.net_total, 880.0)
|
||||
self.assertEqual(so.grand_total, 1000.0)
|
||||
|
||||
def test_disabling_rounded_total_resets_base_fields(self):
|
||||
"""Disabling rounded total should also clear base rounded values."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
@@ -59,3 +158,141 @@ class TestTaxesAndTotals(FrappeTestCase):
|
||||
self.assertEqual(so.rounding_adjustment, 0)
|
||||
self.assertEqual(so.base_rounded_total, 0)
|
||||
self.assertEqual(so.base_rounding_adjustment, 0)
|
||||
|
||||
def test_tax_net_amount_with_not_applicable_item_tax(self):
|
||||
"""Each tax row records only the net of the items it actually applies to.
|
||||
|
||||
Two items of 100 each, one per template. Template A applies VAT 7 and
|
||||
marks VAT 19 not applicable, template B does the reverse. Both tax rows
|
||||
must report a net_amount of 100, not the full net total of 200.
|
||||
"""
|
||||
vat_7 = "_Test Account VAT - _TC"
|
||||
vat_19 = "_Test Account Service Tax - _TC"
|
||||
|
||||
templates = {}
|
||||
for title, rows in {
|
||||
"_Test NA Template A": [(vat_7, 7, 0), (vat_19, 0, 1)],
|
||||
"_Test NA Template B": [(vat_7, 0, 1), (vat_19, 19, 0)],
|
||||
}.items():
|
||||
doc = frappe.new_doc("Item Tax Template")
|
||||
doc.title = title
|
||||
doc.company = "_Test Company"
|
||||
for tax_type, tax_rate, not_applicable in rows:
|
||||
doc.append(
|
||||
"taxes",
|
||||
{"tax_type": tax_type, "tax_rate": tax_rate, "not_applicable": not_applicable},
|
||||
)
|
||||
templates[title] = doc.insert().name
|
||||
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
for title in templates:
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_tax_template": templates[title],
|
||||
},
|
||||
)
|
||||
|
||||
so.set("taxes", [])
|
||||
for account_head in (vat_7, vat_19):
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": account_head,
|
||||
"description": account_head,
|
||||
"rate": 0,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
so.save()
|
||||
|
||||
self.assertEqual(so.net_total, 200.0)
|
||||
self.assertEqual(so.taxes[0].net_amount, 100.0)
|
||||
self.assertEqual(so.taxes[0].tax_amount, 7.0)
|
||||
self.assertEqual(so.taxes[1].net_amount, 100.0)
|
||||
self.assertEqual(so.taxes[1].tax_amount, 19.0)
|
||||
|
||||
def test_inclusive_tax_with_not_applicable_item_tax(self):
|
||||
"""An inclusive tax row meeting an item that marks it not applicable must
|
||||
contribute no fraction, instead of raising in get_current_tax_fraction."""
|
||||
vat_19 = "_Test Account Service Tax - _TC"
|
||||
|
||||
template = frappe.new_doc("Item Tax Template")
|
||||
template.title = "_Test NA Template Inclusive"
|
||||
template.company = "_Test Company"
|
||||
template.append("taxes", {"tax_type": vat_19, "tax_rate": 0, "not_applicable": 1})
|
||||
template.insert()
|
||||
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 119,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_tax_template": template.name,
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": vat_19,
|
||||
"description": vat_19,
|
||||
"rate": 19,
|
||||
"included_in_print_rate": 1,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
so.save()
|
||||
|
||||
# the tax does not apply, so nothing is backed out of the printed rate
|
||||
self.assertEqual(so.net_total, 119.0)
|
||||
self.assertEqual(so.taxes[0].tax_amount, 0.0)
|
||||
self.assertEqual(so.taxes[0].net_amount, 0.0)
|
||||
self.assertEqual(so.grand_total, 119.0)
|
||||
|
||||
def test_tax_net_amount_survives_grand_total_discount(self):
|
||||
"""A discount on Grand Total re-runs the calculation with
|
||||
discount_amount_applied set. net_amount is reset on that second pass, so
|
||||
it has to be accumulated there too instead of being left at zero."""
|
||||
so = make_sales_order(do_not_save=True)
|
||||
so.items = []
|
||||
so.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
so.set("taxes", [])
|
||||
so.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": "VAT",
|
||||
"rate": 19,
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
so.apply_discount_on = "Grand Total"
|
||||
so.discount_amount = 100
|
||||
|
||||
calculate_taxes_and_totals(so)
|
||||
|
||||
self.assertEqual(so.taxes[0].net_amount, so.net_total)
|
||||
self.assertEqual(so.grand_total, 1090.0)
|
||||
|
||||
@@ -69,6 +69,13 @@ class CRMSettings(Document):
|
||||
self.allowed_users = []
|
||||
|
||||
def custom_fields_for_frappe_crm_data_sync(self):
|
||||
custom_fields = self.get_frappe_crm_custom_fields()
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization:
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
|
||||
@staticmethod
|
||||
def get_frappe_crm_custom_fields():
|
||||
custom_fields = {
|
||||
"Quotation": [
|
||||
{
|
||||
@@ -88,4 +95,4 @@ class CRMSettings(Document):
|
||||
],
|
||||
}
|
||||
|
||||
create_custom_fields(custom_fields, ignore_validate=True)
|
||||
return custom_fields
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.query_builder import DocType, Interval
|
||||
from frappe.query_builder.functions import Now
|
||||
from frappe.utils import flt, get_fullname
|
||||
|
||||
from erpnext.accounts.party import validate_party_frozen_disabled
|
||||
from erpnext.crm.utils import (
|
||||
CRMNote,
|
||||
copy_comments,
|
||||
@@ -131,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.validate_item_details()
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.validate_cust_name()
|
||||
self.validate_party()
|
||||
self.map_fields()
|
||||
self.validate_qty()
|
||||
self.set_exchange_rate()
|
||||
@@ -346,6 +348,10 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate_party(self) -> None:
|
||||
if self.opportunity_from == "Customer":
|
||||
validate_party_frozen_disabled("Customer", self.party_name)
|
||||
|
||||
def validate_cust_name(self):
|
||||
if self.party_name:
|
||||
if self.opportunity_from == "Customer":
|
||||
|
||||
@@ -10,6 +10,7 @@ from erpnext.crm.doctype.lead.lead import make_customer
|
||||
from erpnext.crm.doctype.lead.test_lead import make_lead
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
|
||||
from erpnext.crm.utils import get_linked_communication_list
|
||||
from erpnext.exceptions import PartyDisabled
|
||||
|
||||
test_records = frappe.get_test_records("Opportunity")
|
||||
|
||||
@@ -52,6 +53,23 @@ class TestOpportunity(unittest.TestCase):
|
||||
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
|
||||
self.assertEqual(opportunity_doc.total, 2200)
|
||||
|
||||
def test_disabled_customer_not_allowed(self):
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
|
||||
|
||||
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
|
||||
|
||||
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
|
||||
make_opportunity(with_items=0)
|
||||
|
||||
def test_disabled_lead_not_blocked(self):
|
||||
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
|
||||
# excludes Converted leads), so it shouldn't block Opportunity creation either.
|
||||
lead_doc = make_lead()
|
||||
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
|
||||
|
||||
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
|
||||
self.assertEqual(opp_doc.party_name, lead_doc.name)
|
||||
|
||||
def test_carry_forward_of_email_and_comments(self):
|
||||
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
|
||||
lead_doc = make_lead()
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_last_interaction(contact=None, lead=None):
|
||||
if not contact and not lead:
|
||||
return
|
||||
|
||||
last_communication = None
|
||||
last_issue = None
|
||||
if contact:
|
||||
query_condition = ""
|
||||
values = []
|
||||
contact = frappe.get_doc("Contact", contact)
|
||||
for link in contact.links:
|
||||
if link.link_doctype == "Customer":
|
||||
last_issue = get_last_issue_from_customer(link.link_name)
|
||||
query_condition += "(`reference_doctype`=%s AND `reference_name`=%s) OR"
|
||||
values += [link.link_doctype, link.link_name]
|
||||
|
||||
if query_condition:
|
||||
# remove extra appended 'OR'
|
||||
query_condition = query_condition[:-2]
|
||||
last_communication = frappe.db.sql(
|
||||
f"""
|
||||
SELECT `name`, `content`
|
||||
FROM `tabCommunication`
|
||||
WHERE `sent_or_received`='Received'
|
||||
AND ({query_condition})
|
||||
ORDER BY `modified`
|
||||
LIMIT 1
|
||||
""",
|
||||
values,
|
||||
as_dict=1,
|
||||
) # nosec
|
||||
|
||||
if lead:
|
||||
last_communication = frappe.get_all(
|
||||
"Communication",
|
||||
filters={"reference_doctype": "Lead", "reference_name": lead, "sent_or_received": "Received"},
|
||||
fields=["name", "content"],
|
||||
order_by="`creation` DESC",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
last_communication = last_communication[0] if last_communication else None
|
||||
|
||||
return {"last_communication": last_communication, "last_issue": last_issue}
|
||||
|
||||
|
||||
def get_last_issue_from_customer(customer_name):
|
||||
issues = frappe.get_all(
|
||||
"Issue",
|
||||
|
||||
@@ -597,16 +597,16 @@ regional_overrides = {
|
||||
"erpnext.controllers.accounts_controller.validate_regional": "erpnext.regional.italy.utils.sales_invoice_validate",
|
||||
},
|
||||
}
|
||||
user_privacy_documents = [
|
||||
user_data_fields = [
|
||||
{
|
||||
"doctype": "Lead",
|
||||
"match_field": "email_id",
|
||||
"personal_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
|
||||
"filter_by": "email_id",
|
||||
"redact_fields": ["phone", "mobile_no", "fax", "website", "lead_name"],
|
||||
},
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"match_field": "contact_email",
|
||||
"personal_fields": ["contact_mobile", "contact_display", "customer_name"],
|
||||
"filter_by": "contact_email",
|
||||
"redact_fields": ["contact_mobile", "contact_display", "customer_name"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -173,7 +173,9 @@ frappe.ui.form.on("BOM", {
|
||||
frm.set_intro(
|
||||
__("This is a Template BOM and will be used to make the work order for {0} of the item {1}", [
|
||||
`<a class="variants-intro">variants</a>`,
|
||||
`<a href="/app/item/${frm.doc.item}">${frm.doc.item}</a>`,
|
||||
`<a href="${frappe.utils.get_form_link("Item", frm.doc.item)}">${frappe.utils.escape_html(
|
||||
frm.doc.item
|
||||
)}</a>`,
|
||||
]),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<hr style="margin: 15px -15px;">
|
||||
<p>
|
||||
{% if data.value && data.value != "BOM" %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ data.value }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/bom/{{ frappe.utils.escape_html(data.value) }}">
|
||||
{{ __("Open BOM {0}", [data.value.bold()]) }}</a>
|
||||
{% endif %}
|
||||
{% if data.item_code %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ data.item_code }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="/app/item/{{ frappe.utils.escape_html(data.item_code) }}">
|
||||
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def get_minimum_material_coverage_fraction(
|
||||
required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int
|
||||
) -> float:
|
||||
"""Return the least-covered component ratio at the configured quantity precision."""
|
||||
coverage = []
|
||||
for item_code, required in required_qty.items():
|
||||
transferred = flt(transferred_qty.get(item_code))
|
||||
# Stored values can differ after the digits that the user can enter or see.
|
||||
if flt(transferred, precision) == flt(required, precision):
|
||||
coverage.append(1.0)
|
||||
else:
|
||||
coverage.append(transferred / required)
|
||||
|
||||
return min(coverage, default=0.0)
|
||||
@@ -1461,9 +1461,11 @@ class TestWorkOrder(FrappeTestCase):
|
||||
del transfer_entry.get("items")[0] # transfer only one RM
|
||||
transfer_entry.submit()
|
||||
|
||||
# WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
|
||||
# One required item is still missing, so no finished-good quantity is covered yet.
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 0)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
@@ -1483,6 +1485,47 @@ class TestWorkOrder(FrappeTestCase):
|
||||
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
|
||||
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
|
||||
|
||||
def test_material_transfer_claim_follows_actual_coverage(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=20,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
|
||||
)
|
||||
for row in transfer_entry.items:
|
||||
if row.item_code == "_Test Item":
|
||||
row.qty = 1
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(transfer_entry.fg_completed_qty, 1)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
|
||||
|
||||
remainder_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
|
||||
)
|
||||
remainder_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(remainder_entry.fg_completed_qty, 3)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 4)
|
||||
|
||||
def test_material_coverage_cap_skips_manufacture_entry(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
|
||||
manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
|
||||
manufacture_entry.pro_doc = work_order
|
||||
manufacture_entry._action = "submit"
|
||||
|
||||
self.assertFalse(manufacture_entry._should_cap_completed_qty())
|
||||
|
||||
def test_material_transferred_min_fraction_on_partial_pick_list(self):
|
||||
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
|
||||
must reflect the least-transferred required item (the bottleneck), instead of being
|
||||
@@ -1545,6 +1588,97 @@ class TestWorkOrder(FrappeTestCase):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_material_transferred_ignores_hidden_precision_difference(self):
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100",
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=10,
|
||||
basic_rate=1000.0,
|
||||
)
|
||||
|
||||
precision = work_order.precision("required_qty", "required_items")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
row = work_order.required_items[0]
|
||||
row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
|
||||
work_order.reload()
|
||||
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
|
||||
|
||||
transfer_entry = frappe.get_doc(
|
||||
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
|
||||
)
|
||||
for item in transfer_entry.items:
|
||||
item.qty = flt(required_qty[item.item_code], precision)
|
||||
item.transfer_qty = item.qty
|
||||
transfer_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(
|
||||
flt(work_order.required_items[0].required_qty, precision),
|
||||
flt(work_order.required_items[0].transferred_qty, precision),
|
||||
)
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
|
||||
def test_repair_material_transfer_precision_patch(self):
|
||||
from erpnext.patches.v16_0.repair_work_order_material_transfer import (
|
||||
execute,
|
||||
get_precision_affected_work_orders,
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
hidden_difference = 4 / (10 ** (precision + 1))
|
||||
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for index, row in enumerate(work_order.required_items):
|
||||
required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
|
||||
row.db_set(
|
||||
{
|
||||
"required_qty": required_qty,
|
||||
"transferred_qty": flt(required_qty, precision),
|
||||
},
|
||||
update_modified=False,
|
||||
)
|
||||
work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in partial_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
partial_row = partial_work_order.required_items[0]
|
||||
partial_row.db_set(
|
||||
"transferred_qty",
|
||||
flt(partial_row.required_qty, precision) - (1 / (10**precision)),
|
||||
update_modified=False,
|
||||
)
|
||||
partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
|
||||
|
||||
terminal_work_orders = []
|
||||
for status in ("Stopped", "Closed", "Completed"):
|
||||
terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
|
||||
for row in terminal_work_order.required_items:
|
||||
row.db_set("transferred_qty", row.required_qty, update_modified=False)
|
||||
terminal_work_order.db_set(
|
||||
{"material_transferred_for_manufacturing": 1.99, "status": status},
|
||||
update_modified=False,
|
||||
)
|
||||
terminal_work_orders.append(terminal_work_order)
|
||||
|
||||
updates = get_precision_affected_work_orders()
|
||||
self.assertIn(work_order.name, updates)
|
||||
self.assertNotIn(partial_work_order.name, updates)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
self.assertNotIn(terminal_work_order.name, updates)
|
||||
|
||||
execute()
|
||||
work_order.reload()
|
||||
partial_work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
|
||||
self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
for terminal_work_order in terminal_work_orders:
|
||||
terminal_work_order.reload()
|
||||
self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred(self):
|
||||
"""Stock Entry created from a Pick List that picked only one of the required items:
|
||||
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
|
||||
|
||||
@@ -31,6 +31,10 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
|
||||
get_mins_between_operations,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import make_batch
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
|
||||
@@ -312,7 +316,18 @@ class WorkOrder(Document):
|
||||
if not self.wip_warehouse and not self.skip_transfer:
|
||||
self.wip_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_wip_warehouse")
|
||||
if not self.fg_warehouse:
|
||||
self.fg_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse")
|
||||
self.fg_warehouse = (
|
||||
frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse")
|
||||
or self.get_production_item_warehouse()
|
||||
)
|
||||
|
||||
def get_production_item_warehouse(self):
|
||||
if not self.production_item:
|
||||
return None
|
||||
|
||||
return get_item_defaults(self.production_item, self.company).get(
|
||||
"default_warehouse"
|
||||
) or get_item_group_defaults(self.production_item, self.company).get("default_warehouse")
|
||||
|
||||
def check_wip_warehouse_skip(self):
|
||||
if self.skip_transfer and not self.from_wip_warehouse:
|
||||
@@ -412,11 +427,7 @@ class WorkOrder(Document):
|
||||
elif self.docstatus == 1:
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if (
|
||||
flt(self.material_transferred_for_manufacturing) > 0
|
||||
or self.skip_transfer
|
||||
or self.has_transferred_material()
|
||||
):
|
||||
if flt(self.material_transferred_for_manufacturing) > 0 or self.has_transferred_material():
|
||||
status = "In Process"
|
||||
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
@@ -436,8 +447,7 @@ class WorkOrder(Document):
|
||||
return status
|
||||
|
||||
def has_transferred_material(self):
|
||||
"""True if any raw material was transferred against this work order via a pick list
|
||||
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
|
||||
"""True if any raw material was transferred against this work order."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
qty = (
|
||||
@@ -450,7 +460,6 @@ class WorkOrder(Document):
|
||||
& (ste.docstatus == 1)
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull())
|
||||
)
|
||||
).run()[0][0]
|
||||
return flt(qty) > 0
|
||||
@@ -1231,7 +1240,10 @@ class WorkOrder(Document):
|
||||
"description": item.description,
|
||||
"allow_alternative_item": item.allow_alternative_item,
|
||||
"required_qty": item.qty,
|
||||
"source_warehouse": item.source_warehouse or item.default_warehouse,
|
||||
"source_warehouse": item.source_warehouse
|
||||
or item.default_warehouse
|
||||
or self.source_warehouse
|
||||
or get_item_group_defaults(item.item_code, self.company).get("default_warehouse"),
|
||||
"include_item_in_manufacturing": item.include_item_in_manufacturing,
|
||||
},
|
||||
)
|
||||
@@ -1274,20 +1286,13 @@ class WorkOrder(Document):
|
||||
self.recompute_material_transferred_for_manufacturing(transferred_items)
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
"""Set transferred quantity from the raw materials that have actually moved."""
|
||||
# Job Card transfers use the minimum completed quantity across operations.
|
||||
if self.operations and self.transfer_material_against == "Job Card":
|
||||
return
|
||||
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
|
||||
if sum_fg_completed_qty:
|
||||
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
|
||||
return
|
||||
claimed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
|
||||
|
||||
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
|
||||
# so partial availability does not prematurely mark the work order as fully transferred.
|
||||
required_by_item = {}
|
||||
for row in self.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
@@ -1297,12 +1302,13 @@ class WorkOrder(Document):
|
||||
if not required_by_item:
|
||||
return
|
||||
|
||||
min_fraction = min(
|
||||
flt(transferred_items.get(item_code) or 0) / required_qty
|
||||
for item_code, required_qty in required_by_item.items()
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_by_item,
|
||||
transferred_items,
|
||||
self.precision("required_qty", "required_items"),
|
||||
)
|
||||
min_fraction = min(min_fraction, 1.0)
|
||||
material_transferred = min_fraction * flt(self.qty)
|
||||
covered_qty = min_fraction * flt(self.qty)
|
||||
material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty))
|
||||
self.db_set("material_transferred_for_manufacturing", material_transferred)
|
||||
|
||||
def update_returned_qty(self):
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
<hr style="margin: 15px -15px;">
|
||||
<p>
|
||||
{% if data.value %}
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ data.value }}">
|
||||
<a style="margin-right: 7px; margin-bottom: 7px" class="btn btn-default btn-xs" href="#Form/Work Order/{{ frappe.utils.escape_html(data.value) }}">
|
||||
{{ __("Open Work Order {0}", [data.value.bold()]) }}</a>
|
||||
{% endif %}
|
||||
{% if data.item_code %}
|
||||
<a class="btn btn-default btn-xs" href="#Form/Item/{{ data.item_code }}">
|
||||
<a class="btn btn-default btn-xs" href="#Form/Item/{{ frappe.utils.escape_html(data.item_code) }}">
|
||||
{{ __("Open Item {0}", [data.item_code.bold()]) }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -413,7 +413,7 @@ def get_workstations(**kwargs):
|
||||
|
||||
for d in data:
|
||||
d.workstation_name = get_link_to_form("Workstation", d.name)
|
||||
d.status_image = d.on_status_image
|
||||
d.status_image = frappe.utils.escape_html(d.on_status_image)
|
||||
d.background_color = color_map.get(d.status, "var(--red-600)")
|
||||
d.workstation_link = get_url_to_form("Workstation", d.name)
|
||||
if d.status != "Production":
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div style = "max-height: 400px; overflow-y: auto;">
|
||||
{% $.each(data, (idx, d) => { %}
|
||||
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{d.name}}">
|
||||
<div class="row form-dashboard-section job-card-link form-links border-gray-200" data-name="{{ frappe.utils.escape_html(d.name) }}">
|
||||
<div class="section-head section-head-job-card">
|
||||
{{ d.operation }} - {{ d.production_item }}
|
||||
<span class="ml-2 collapse-indicator-job mb-1" style="">
|
||||
@@ -64,8 +64,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-column col-sm-2 text-center">
|
||||
<button style="width: 85px;" class="btn btn-default btn-start {% if(d.status !== "Open") { %} hide {% } %}" job-card="{{d.name}}"> {{__("Start")}} </button>
|
||||
<button style="width: 85px;" class="btn btn-default btn-complete {% if(d.status === "Open") { %} hide {% } %}" job-card="{{d.name}}" pending-qty="{{d.for_quantity - d.transferred_qty}}"> {{__("Complete")}} </button>
|
||||
<button style="width: 85px;" class="btn btn-default btn-start {% if(d.status !== "Open") { %} hide {% } %}" job-card="{{ frappe.utils.escape_html(d.name) }}"> {{__("Start")}} </button>
|
||||
<button style="width: 85px;" class="btn btn-default btn-complete {% if(d.status === "Open") { %} hide {% } %}" job-card="{{ frappe.utils.escape_html(d.name) }}" pending-qty="{{d.for_quantity - d.transferred_qty}}"> {{__("Complete")}} </button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
{% if(d.make_material_request) { %}
|
||||
<div class="form-column col-sm-10 text-right">
|
||||
<button class="btn btn-default btn-xs make-material-request" job-card="{{d.name}}">{{ __("Material Request") }}</button>
|
||||
<button class="btn btn-default btn-xs make-material-request" job-card="{{ frappe.utils.escape_html(d.name) }}">{{ __("Material Request") }}</button>
|
||||
</div>
|
||||
{% } %}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,10 @@ frappe.query_reports["Production Plan Summary"] = {
|
||||
|
||||
if (column.fieldname == "item_code") {
|
||||
var color = data.pending_qty > 0 ? "red" : "green";
|
||||
value = `<a style='color:${color}' href="/app/item/${data["item_code"]}" data-doctype="Item">${data["item_code"]}</a>`;
|
||||
value = `<a style='color:${color}' href="${frappe.utils.get_form_link(
|
||||
"Item",
|
||||
data["item_code"]
|
||||
)}" data-doctype="Item">${frappe.utils.escape_html(data["item_code"])}</a>`;
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -447,3 +447,5 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v15_0.fix_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
erpnext.patches.v16_0.repair_work_order_material_transfer
|
||||
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
|
||||
|
||||
27
erpnext/patches/v16_0/remove_frappe_crm_custom_fields.py
Normal file
27
erpnext/patches/v16_0/remove_frappe_crm_custom_fields.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import delete_custom_fields
|
||||
|
||||
from erpnext.crm.doctype.crm_settings.crm_settings import CRMSettings
|
||||
|
||||
|
||||
def execute():
|
||||
"""Delete the `crm_deal` fields on Quotation and Customer if Frappe CRM Data Synchronization is disabled and there's no data on those fields."""
|
||||
|
||||
crm_deal_exists_in_quotation = frappe.db.has_column("Quotation", "crm_deal") and frappe.get_all(
|
||||
"Quotation", filters={"crm_deal": ["is", "set"]}, limit=1
|
||||
)
|
||||
|
||||
crm_deal_exists_in_customer = frappe.db.has_column("Customer", "crm_deal") and frappe.get_all(
|
||||
"Customer", filters={"crm_deal": ["is", "set"]}, limit=1
|
||||
)
|
||||
|
||||
enable_frappe_crm_data_sync = frappe.get_single_value(
|
||||
"CRM Settings", "enable_frappe_crm_data_synchronization"
|
||||
)
|
||||
|
||||
if enable_frappe_crm_data_sync or crm_deal_exists_in_quotation or crm_deal_exists_in_customer:
|
||||
return
|
||||
|
||||
custom_fields = CRMSettings.get_frappe_crm_custom_fields()
|
||||
|
||||
delete_custom_fields(custom_fields)
|
||||
65
erpnext/patches/v16_0/repair_work_order_material_transfer.py
Normal file
65
erpnext/patches/v16_0/repair_work_order_material_transfer.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
from pypika import functions as fn
|
||||
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
updates = get_precision_affected_work_orders()
|
||||
frappe.db.bulk_update("Work Order", updates, update_modified=False)
|
||||
|
||||
|
||||
def get_precision_affected_work_orders():
|
||||
"""Return Work Orders whose components cover the plan at quantity precision."""
|
||||
work_orders = {}
|
||||
for row in _get_candidate_rows():
|
||||
work_order = work_orders.setdefault(
|
||||
row.work_order,
|
||||
{"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}},
|
||||
)
|
||||
item_code = row.item_code
|
||||
work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt(
|
||||
row.required_qty
|
||||
)
|
||||
work_order["transferred_qty"][item_code] = max(
|
||||
work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
|
||||
precision = frappe.get_precision("Work Order Item", "required_qty")
|
||||
return {
|
||||
name: {"material_transferred_for_manufacturing": values["qty"]}
|
||||
for name, values in work_orders.items()
|
||||
if get_minimum_material_coverage_fraction(
|
||||
values["required_qty"], values["transferred_qty"], precision
|
||||
)
|
||||
>= 1.0
|
||||
}
|
||||
|
||||
|
||||
def _get_candidate_rows():
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
required_item = frappe.qb.DocType("Work Order Item")
|
||||
return (
|
||||
frappe.qb.from_(work_order)
|
||||
.inner_join(required_item)
|
||||
.on(required_item.parent == work_order.name)
|
||||
.select(
|
||||
work_order.name.as_("work_order"),
|
||||
work_order.qty,
|
||||
required_item.item_code,
|
||||
required_item.required_qty,
|
||||
required_item.transferred_qty,
|
||||
)
|
||||
.where(
|
||||
(work_order.docstatus == 1)
|
||||
& (work_order.status.notin(["Stopped", "Closed", "Completed"]))
|
||||
& (fn.Coalesce(work_order.skip_transfer, 0) == 0)
|
||||
& (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty)
|
||||
& (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card")
|
||||
& (required_item.include_item_in_manufacturing == 1)
|
||||
& (required_item.required_qty > 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
@@ -3,7 +3,7 @@
|
||||
{% for d in data %}
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<a class="small time-sheet-link" data-activity_type="{{ d.activity_type || "" }}">
|
||||
<a class="small time-sheet-link" data-activity_type="{{ frappe.utils.escape_html(d.activity_type || "") }}">
|
||||
{{ d.activity_type || __("Unknown") }}</a>
|
||||
</div>
|
||||
<div class="col-xs-8">
|
||||
|
||||
@@ -116,19 +116,35 @@ class Task(NestedSet):
|
||||
if not self.project or frappe.flags.in_test:
|
||||
return
|
||||
|
||||
if project_end_date := frappe.db.get_value("Project", self.project, "expected_end_date"):
|
||||
project_end_date = getdate(project_end_date)
|
||||
for fieldname in ("exp_start_date", "exp_end_date", "act_start_date", "act_end_date"):
|
||||
task_date = self.get(fieldname)
|
||||
if task_date and date_diff(project_end_date, getdate(task_date)) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be after {2}'s Expected End Date.").format(
|
||||
frappe.bold(frappe.get_desk_link("Task", self.name)),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
frappe.bold(frappe.get_desk_link("Project", self.project)),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
project_start_date, project_end_date = frappe.db.get_value(
|
||||
"Project", self.project, ["expected_start_date", "expected_end_date"]
|
||||
)
|
||||
|
||||
for fieldname in ("exp_start_date", "exp_end_date", "act_start_date", "act_end_date"):
|
||||
task_date = self.get(fieldname)
|
||||
if not task_date:
|
||||
continue
|
||||
task_date = getdate(task_date)
|
||||
|
||||
if project_end_date and date_diff(getdate(project_end_date), task_date) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be after {2}'s Expected End Date.").format(
|
||||
get_link_to_form("Task", self.name),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
get_link_to_form("Project", self.project),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
|
||||
if project_start_date and date_diff(task_date, getdate(project_start_date)) < 0:
|
||||
frappe.throw(
|
||||
_("{0}'s {1} cannot be before {2}'s Expected Start Date.").format(
|
||||
get_link_to_form("Task", self.name),
|
||||
_(self.meta.get_label(fieldname)),
|
||||
get_link_to_form("Project", self.project),
|
||||
),
|
||||
frappe.exceptions.InvalidDates,
|
||||
)
|
||||
|
||||
def validate_status(self):
|
||||
if self.is_template and self.status != "Template":
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.query_report import get_filtered_data
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
VALUE_FIELDNAMES = ("hours", "billing_hours", "billing_amount")
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
group_fieldname = filters.pop("group_by", None)
|
||||
|
||||
filters = frappe._dict(filters or {})
|
||||
columns = get_columns(filters, group_fieldname)
|
||||
|
||||
data = get_data(filters)
|
||||
data = get_filtered_data("Timesheet", columns, data, frappe.session.user)
|
||||
report_summary = get_report_summary(data)
|
||||
|
||||
if group_fieldname:
|
||||
data = group_by(data, group_fieldname)
|
||||
|
||||
return columns, data, None, None, report_summary, 1
|
||||
|
||||
|
||||
def get_columns(filters, group_fieldname=None):
|
||||
group_columns = {
|
||||
"date": {
|
||||
"label": _("Date"),
|
||||
"fieldtype": "Date",
|
||||
"fieldname": "date",
|
||||
"width": 150,
|
||||
},
|
||||
"project": {
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"fieldname": "project",
|
||||
"options": "Project",
|
||||
"width": 200,
|
||||
"hidden": int(bool(filters.get("project"))),
|
||||
},
|
||||
"employee": {
|
||||
"label": _("Employee ID"),
|
||||
"fieldtype": "Link",
|
||||
"fieldname": "employee",
|
||||
"options": "Employee",
|
||||
"width": 200,
|
||||
"hidden": int(bool(filters.get("employee"))),
|
||||
},
|
||||
}
|
||||
columns = []
|
||||
if group_fieldname in group_columns:
|
||||
# the grouped column labels the group rows: keep it visible even when it is filtered too
|
||||
group_columns[group_fieldname]["hidden"] = 0
|
||||
columns.append(group_columns.pop(group_fieldname))
|
||||
|
||||
columns.extend(group_columns.values())
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Employee Name"),
|
||||
"fieldtype": "data",
|
||||
"fieldname": "employee_name",
|
||||
"hidden": 1,
|
||||
},
|
||||
{
|
||||
"label": _("Timesheet"),
|
||||
"fieldtype": "Link",
|
||||
"fieldname": "timesheet",
|
||||
"options": "Timesheet",
|
||||
"width": 150,
|
||||
},
|
||||
{"label": _("Working Hours"), "fieldtype": "Float", "fieldname": "hours", "width": 150},
|
||||
{
|
||||
"label": _("Billing Hours"),
|
||||
"fieldtype": "Float",
|
||||
"fieldname": "billing_hours",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Billing Amount"),
|
||||
"fieldtype": "Currency",
|
||||
"fieldname": "billing_amount",
|
||||
"width": 150,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def get_data(filters):
|
||||
_filters = []
|
||||
if filters.get("employee"):
|
||||
_filters.append(("employee", "=", filters.get("employee")))
|
||||
if filters.get("project"):
|
||||
_filters.append(("Timesheet Detail", "project", "=", filters.get("project")))
|
||||
if filters.get("from_date"):
|
||||
_filters.append(("Timesheet Detail", "from_time", ">=", filters.get("from_date")))
|
||||
if filters.get("to_date"):
|
||||
_filters.append(("Timesheet Detail", "from_time", "<", add_days(getdate(filters.get("to_date")), 1)))
|
||||
if not filters.get("include_draft_timesheets"):
|
||||
_filters.append(("docstatus", "=", DocStatus.submitted()))
|
||||
else:
|
||||
_filters.append(("docstatus", "in", (DocStatus.submitted(), DocStatus.draft())))
|
||||
|
||||
data = frappe.get_list(
|
||||
"Timesheet",
|
||||
fields=[
|
||||
"name as timesheet",
|
||||
"`tabTimesheet`.employee",
|
||||
"`tabTimesheet`.employee_name",
|
||||
"`tabTimesheet Detail`.from_time as date",
|
||||
"`tabTimesheet Detail`.project",
|
||||
"`tabTimesheet Detail`.hours",
|
||||
"`tabTimesheet Detail`.billing_hours",
|
||||
"`tabTimesheet Detail`.billing_amount",
|
||||
],
|
||||
filters=_filters,
|
||||
order_by="`tabTimesheet Detail`.from_time",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def group_by(data, fieldname):
|
||||
groups = {}
|
||||
for row in data:
|
||||
groups.setdefault(get_group_value(row, fieldname), []).append(row)
|
||||
|
||||
grouped_data = []
|
||||
for group in sorted(groups, key=lambda g: (g is None, g)):
|
||||
hours = billing_hours = billing_amount = 0
|
||||
child_rows = []
|
||||
for row in groups[group]:
|
||||
hours += row.get("hours") or 0
|
||||
billing_hours += row.get("billing_hours") or 0
|
||||
billing_amount += row.get("billing_amount") or 0
|
||||
|
||||
_row = row.copy()
|
||||
_row[fieldname] = None
|
||||
_row["indent"] = 1
|
||||
_row["is_group"] = 0
|
||||
child_rows.append(_row)
|
||||
|
||||
group_row = {
|
||||
fieldname: group,
|
||||
"hours": hours,
|
||||
"billing_hours": billing_hours,
|
||||
"billing_amount": billing_amount,
|
||||
"indent": 0,
|
||||
"is_group": 1,
|
||||
}
|
||||
if fieldname == "employee":
|
||||
group_row["employee_name"] = groups[group][0].get("employee_name")
|
||||
|
||||
grouped_data.append(group_row)
|
||||
grouped_data.extend(child_rows)
|
||||
|
||||
return grouped_data
|
||||
|
||||
|
||||
def get_group_value(row, fieldname):
|
||||
value = row.get(fieldname)
|
||||
# `date` is `Timesheet Detail.from_time`, a datetime: everything logged on a day is one group
|
||||
return getdate(value) if fieldname == "date" and value else value
|
||||
|
||||
|
||||
def get_report_summary(data):
|
||||
if not data:
|
||||
return None
|
||||
|
||||
totals = dict.fromkeys(VALUE_FIELDNAMES, 0.0)
|
||||
for row in data:
|
||||
for value_fieldname in VALUE_FIELDNAMES:
|
||||
totals[value_fieldname] += row.get(value_fieldname) or 0
|
||||
|
||||
return [
|
||||
{
|
||||
"value": totals["hours"],
|
||||
"indicator": "Blue",
|
||||
"label": _("Total Working Hours"),
|
||||
"datatype": "Float",
|
||||
},
|
||||
{
|
||||
"value": totals["billing_hours"],
|
||||
"indicator": "Blue",
|
||||
"label": _("Total Billing Hours"),
|
||||
"datatype": "Float",
|
||||
},
|
||||
{
|
||||
"value": totals["billing_amount"],
|
||||
"indicator": "Green",
|
||||
"label": _("Total Billing Amount"),
|
||||
"datatype": "Currency",
|
||||
},
|
||||
]
|
||||
@@ -1,6 +1,14 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
// mirror of erpnext.stock.get_item_details.NOT_APPLICABLE_TAX
|
||||
erpnext.NOT_APPLICABLE_TAX = "N/A";
|
||||
|
||||
// Per-charge_type base resolvers, mirror of the `erpnext_taxable_base_resolvers`
|
||||
// server hook. A localization registers `fn(calc, item, tax)` returning the per-item
|
||||
// base, so the client preview matches the server for custom charge types.
|
||||
erpnext.taxable_base_resolvers = erpnext.taxable_base_resolvers || {};
|
||||
|
||||
erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
setup() {
|
||||
this.fetch_round_off_accounts();
|
||||
@@ -252,28 +260,33 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
$.each(this.frm.doc.items || [], function(n, item) {
|
||||
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
|
||||
var cumulated_tax_fraction = 0.0;
|
||||
var total_inclusive_tax_amount_per_qty = 0;
|
||||
var total_tax_slope = 0.0;
|
||||
var total_tax_intercept = 0;
|
||||
$.each(me.frm.doc["taxes"] || [], function(i, tax) {
|
||||
var current_tax_fraction = me.get_current_tax_fraction(tax, item_tax_map);
|
||||
tax.tax_fraction_for_current_item = current_tax_fraction[0];
|
||||
var inclusive_tax_amount_per_qty = current_tax_fraction[1];
|
||||
var tax_contribution = me.get_current_tax_fraction(tax, item_tax_map, item);
|
||||
tax.tax_fraction_for_current_item = tax_contribution[0];
|
||||
var tax_intercept_per_qty = tax_contribution[1];
|
||||
tax.inclusive_amount_per_qty = tax_intercept_per_qty;
|
||||
|
||||
if(i==0) {
|
||||
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item;
|
||||
tax.grand_total_amount_per_qty = tax_intercept_per_qty;
|
||||
} else {
|
||||
var prev = me.frm.doc["taxes"][i - 1];
|
||||
tax.grand_total_fraction_for_current_item =
|
||||
me.frm.doc["taxes"][i-1].grand_total_fraction_for_current_item +
|
||||
prev.grand_total_fraction_for_current_item +
|
||||
tax.tax_fraction_for_current_item;
|
||||
tax.grand_total_amount_per_qty =
|
||||
flt(prev.grand_total_amount_per_qty) + tax_intercept_per_qty;
|
||||
}
|
||||
|
||||
cumulated_tax_fraction += tax.tax_fraction_for_current_item;
|
||||
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty);
|
||||
total_tax_slope += tax.tax_fraction_for_current_item;
|
||||
total_tax_intercept += tax_intercept_per_qty * flt(item.qty);
|
||||
});
|
||||
|
||||
if(!me.discount_amount_applied && item.qty && (total_inclusive_tax_amount_per_qty || cumulated_tax_fraction)) {
|
||||
var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty;
|
||||
item.net_amount = flt(amount / (1 + cumulated_tax_fraction), precision("net_amount", item));
|
||||
if(!me.discount_amount_applied && item.qty && (total_tax_intercept || total_tax_slope)) {
|
||||
var amount = flt(item.amount) - total_tax_intercept;
|
||||
item.net_amount = flt(amount / (1 + total_tax_slope), precision("net_amount", item));
|
||||
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
|
||||
|
||||
me.set_in_company_currency(item, ["net_rate", "net_amount"]);
|
||||
@@ -281,40 +294,66 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
});
|
||||
}
|
||||
|
||||
get_current_tax_fraction(tax, item_tax_map) {
|
||||
// Get tax fraction for calculating tax exclusive amount
|
||||
// from tax inclusive amount
|
||||
var current_tax_fraction = 0.0;
|
||||
var inclusive_tax_amount_per_qty = 0;
|
||||
get_current_tax_fraction(tax, item_tax_map, item) {
|
||||
// tax = slope * net + intercept.
|
||||
// Returns [slope, intercept_per_qty]
|
||||
var tax_slope = 0.0;
|
||||
var tax_intercept = 0;
|
||||
|
||||
if(cint(tax.included_in_print_rate)) {
|
||||
var tax_rate = this._get_tax_rate(tax, item_tax_map);
|
||||
|
||||
if (tax_rate === erpnext.NOT_APPLICABLE_TAX) {
|
||||
return [tax_slope, tax_intercept];
|
||||
}
|
||||
|
||||
if(tax.charge_type == "On Net Total") {
|
||||
current_tax_fraction = (tax_rate / 100.0);
|
||||
tax_slope = (tax_rate / 100.0);
|
||||
|
||||
} else if(tax.charge_type == "On Previous Row Amount") {
|
||||
current_tax_fraction = (tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_fraction_for_current_item;
|
||||
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
|
||||
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item;
|
||||
tax_intercept = (tax_rate / 100.0) * flt(row.inclusive_amount_per_qty);
|
||||
|
||||
} else if(tax.charge_type == "On Previous Row Total") {
|
||||
current_tax_fraction = (tax_rate / 100.0) *
|
||||
this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_fraction_for_current_item;
|
||||
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
|
||||
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item;
|
||||
tax_intercept = (tax_rate / 100.0) * flt(row.grand_total_amount_per_qty);
|
||||
} else if (tax.charge_type == "On Item Quantity") {
|
||||
inclusive_tax_amount_per_qty = flt(tax_rate);
|
||||
tax_intercept = flt(tax_rate);
|
||||
} else {
|
||||
// Custom charge_type: the rate applies to a resolved (fixed) base,
|
||||
// e.g. a tax on MRP included in the printed price.
|
||||
const qty = flt(item.qty) || 1;
|
||||
const base = this.get_item_taxable_base(item, tax);
|
||||
tax_intercept = ((tax_rate / 100.0) * base) / qty;
|
||||
}
|
||||
}
|
||||
|
||||
if(tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") {
|
||||
current_tax_fraction *= -1;
|
||||
inclusive_tax_amount_per_qty *= -1;
|
||||
tax_slope *= -1;
|
||||
tax_intercept *= -1;
|
||||
}
|
||||
return [current_tax_fraction, inclusive_tax_amount_per_qty];
|
||||
return [tax_slope, tax_intercept];
|
||||
}
|
||||
|
||||
get_item_taxable_base(item, tax) {
|
||||
// Mirror of the server get_item_taxable_base: a custom charge_type's resolver
|
||||
// overrides the base value; otherwise the net amount.
|
||||
const resolver = erpnext.taxable_base_resolvers[tax.charge_type];
|
||||
if (resolver) return flt(resolver(this, item, tax));
|
||||
return flt(item.net_amount);
|
||||
}
|
||||
|
||||
_get_tax_rate(tax, item_tax_map) {
|
||||
return (Object.keys(item_tax_map).indexOf(tax.account_head) != -1) ?
|
||||
flt(item_tax_map[tax.account_head], precision("rate", tax)) : tax.rate;
|
||||
if (tax.account_head in item_tax_map) {
|
||||
let rate = item_tax_map[tax.account_head];
|
||||
if (rate === erpnext.NOT_APPLICABLE_TAX) {
|
||||
return erpnext.NOT_APPLICABLE_TAX;
|
||||
}
|
||||
return flt(rate, precision("rate", tax));
|
||||
}
|
||||
return tax.rate;
|
||||
}
|
||||
|
||||
calculate_net_total() {
|
||||
@@ -353,6 +392,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
|
||||
$.each(item_tax_map, function(tax, rate) {
|
||||
if (rate === erpnext.NOT_APPLICABLE_TAX) {
|
||||
return;
|
||||
}
|
||||
let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax);
|
||||
if (!found) {
|
||||
let child = frappe.model.add_child(me.frm.doc, "taxes");
|
||||
@@ -403,11 +445,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
}
|
||||
}
|
||||
|
||||
// net_amount is the taxable basis, it feeds no total and is always
|
||||
// accumulated, unlike tax_amount which is kept from the first pass
|
||||
tax.net_amount += current_net_amount;
|
||||
|
||||
// accumulate tax amount into tax.tax_amount
|
||||
if (tax.charge_type != "Actual" &&
|
||||
!(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) {
|
||||
tax.tax_amount += current_tax_amount;
|
||||
tax.net_amount += current_net_amount;
|
||||
}
|
||||
|
||||
// store tax_amount for current item as it will be used for
|
||||
@@ -493,6 +538,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
var current_tax_amount = 0.0;
|
||||
var current_net_amount = 0.0;
|
||||
|
||||
if (tax_rate === erpnext.NOT_APPLICABLE_TAX) {
|
||||
return [current_net_amount, current_tax_amount];
|
||||
}
|
||||
|
||||
// To set row_id by default as previous row.
|
||||
if(["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) {
|
||||
if (tax.idx === 1) {
|
||||
@@ -526,6 +575,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
} else if (tax.charge_type == "On Item Quantity") {
|
||||
// don't sum current net amount due to the field being a currency field
|
||||
current_tax_amount = tax_rate * item.qty;
|
||||
} else {
|
||||
// Custom charge_type: rate applies to the resolver-provided base.
|
||||
var resolved_base = this.get_item_taxable_base(item, tax);
|
||||
current_net_amount = resolved_base;
|
||||
current_tax_amount = (tax_rate / 100.0) * resolved_base;
|
||||
}
|
||||
|
||||
if (!tax.dont_recompute_tax) {
|
||||
|
||||
@@ -818,6 +818,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
$.each(item_tax_map, function(tax, rate) {
|
||||
if (rate === erpnext.NOT_APPLICABLE_TAX) {
|
||||
return;
|
||||
}
|
||||
let found = (me.frm.doc.taxes || []).find(d => d.account_head === tax);
|
||||
if(!found) {
|
||||
let child = frappe.model.add_child(me.frm.doc, "taxes");
|
||||
@@ -1611,9 +1614,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.doc.taxes && this.frm.doc.taxes.length > 0) {
|
||||
this.frm.set_currency_labels(["tax_amount", "total", "tax_amount_after_discount"], this.frm.doc.currency, "taxes");
|
||||
this.frm.set_currency_labels(["net_amount", "tax_amount", "total", "tax_amount_after_discount"], this.frm.doc.currency, "taxes");
|
||||
|
||||
this.frm.set_currency_labels(["base_tax_amount", "base_total", "base_tax_amount_after_discount"], company_currency, "taxes");
|
||||
this.frm.set_currency_labels(["base_net_amount", "base_tax_amount", "base_total", "base_tax_amount_after_discount"], company_currency, "taxes");
|
||||
}
|
||||
|
||||
if (this.frm.doc.advances && this.frm.doc.advances.length > 0) {
|
||||
|
||||
@@ -12,6 +12,10 @@ $.extend(erpnext.queries, {
|
||||
return { query: "erpnext.controllers.queries.lead_query" };
|
||||
},
|
||||
|
||||
customer: function () {
|
||||
return { filters: { disabled: 0 } };
|
||||
},
|
||||
|
||||
item: function (filters) {
|
||||
var args = { query: "erpnext.controllers.queries.item_query" };
|
||||
if (filters) args["filters"] = filters;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="text-muted"> • {{ comment_when(creation) }}</span>
|
||||
</div>
|
||||
<span>
|
||||
<a class="action-btn" href="/app/call-log/{{ name }}" title="{{ __("Open Call Log") }}">
|
||||
<a class="action-btn" href="/app/call-log/{{ frappe.utils.escape_html(name) }}" title="{{ __("Open Call Log") }}">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-link-url" class="like-icon"></use>
|
||||
</svg>
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="margin-top">
|
||||
<audio
|
||||
controls
|
||||
src="{{ recording_url }}">
|
||||
src="{{ frappe.utils.escape_html(recording_url) }}">
|
||||
</audio>
|
||||
</div>
|
||||
{% } %}
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
<use href="#icon-small-message"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/todo/{{ tasks[i].name }}" title="{{ __('Open Task') }}">
|
||||
<a href="/app/todo/{{ frappe.utils.escape_html(tasks[i].name) }}" title="{{ __('Open Task') }}">
|
||||
{%= tasks[i].description %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" class="completion-checkbox"
|
||||
name="{{tasks[i].name}}" title="{{ __('Mark As Closed') }}">
|
||||
name="{{ frappe.utils.escape_html(tasks[i].name) }}" title="{{ __('Mark As Closed') }}">
|
||||
</div>
|
||||
</div>
|
||||
{% if(tasks[i].date) { %}
|
||||
@@ -73,13 +73,13 @@
|
||||
<use href="#icon-{{ icon_set[events[i].event_category] || 'calendar' }}"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/event/{{ events[i].name }}" title="{{ __('Open Event') }}">
|
||||
{%= events[i].subject %}
|
||||
<a href="/app/event/{{ frappe.utils.escape_html(events[i].name) }}" title="{{ __('Open Event') }}">
|
||||
{%= frappe.utils.escape_html(events[i].subject) %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<input type="checkbox" class="completion-checkbox"
|
||||
name="{{ events[i].name }}" title="{{ __('Mark As Closed') }}">
|
||||
name="{{ frappe.utils.escape_html(events[i].name) }}" title="{{ __('Mark As Closed') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted ml-1">
|
||||
|
||||
@@ -8,6 +8,16 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
? this.item.rejected_serial_and_batch_bundle
|
||||
: this.item.serial_and_batch_bundle;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.based_on = await erpnext.stock.get_pick_serial_batch_based_on();
|
||||
} catch (e) {
|
||||
this.based_on = "FIFO";
|
||||
}
|
||||
|
||||
this.make();
|
||||
this.render_data();
|
||||
}
|
||||
@@ -379,7 +389,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
{
|
||||
fieldtype: "Select",
|
||||
options: ["FIFO", "LIFO", "Expiry"],
|
||||
default: "FIFO",
|
||||
default: this.based_on,
|
||||
fieldname: "based_on",
|
||||
label: __("Fetch Based On"),
|
||||
onchange: () => this.get_auto_data(),
|
||||
@@ -525,7 +535,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
}
|
||||
|
||||
if (!based_on) {
|
||||
based_on = "FIFO";
|
||||
based_on = this.based_on;
|
||||
}
|
||||
|
||||
let warehouse = this.item.warehouse || this.item.s_warehouse;
|
||||
@@ -658,6 +668,27 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
frappe.throw(__("Rejected Warehouse and Accepted Warehouse cannot be same."));
|
||||
}
|
||||
|
||||
let qty_to_fetch = flt(this.dialog.get_value("qty"));
|
||||
let total_qty = entries.reduce((total, row) => total + (flt(row.qty) || 1.0), 0);
|
||||
|
||||
if (flt(total_qty, 6) !== flt(qty_to_fetch, 6)) {
|
||||
const confirm_dialog = frappe.confirm(
|
||||
__(
|
||||
"<strong>Total qty</strong> of the rows (<strong>{0}</strong>) does not match the <strong>Qty to Fetch</strong> (<strong>{1}</strong>). Qty of the item will be changed to <strong>{0}</strong>. Are you sure want to proceed?",
|
||||
[format_number(total_qty), format_number(qty_to_fetch)]
|
||||
),
|
||||
() => this.create_bundle_entries(entries, warehouse)
|
||||
);
|
||||
confirm_dialog.indicator = "blue";
|
||||
confirm_dialog.set_indicator();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.create_bundle_entries(entries, warehouse);
|
||||
}
|
||||
|
||||
create_bundle_entries(entries, warehouse) {
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers",
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
<Descrizione>{{ html2text(item.description or '') or item.item_name }}</Descrizione>
|
||||
<Quantita>{{ format_float(item.qty) }}</Quantita>
|
||||
<UnitaMisura>{{ item.stock_uom }}</UnitaMisura>
|
||||
{%- set item_unit_net_price = (item.price_list_rate / tax_divisor) or (item.net_rate) or (item.rate / tax_divisor) %}
|
||||
{%- set item_unit_net_price = ((item.price_list_rate or 0) / tax_divisor) or (item.net_rate) or (item.rate / tax_divisor) %}
|
||||
<PrezzoUnitario>{{ format_float(item_unit_net_price, item_meta.get_field("rate").precision) }}</PrezzoUnitario>
|
||||
{{ render_discount_or_margin(item, tax_divisor) }}
|
||||
<PrezzoTotale>{{ format_float(item.net_amount, item_meta.get_field("amount").precision) }}</PrezzoTotale>
|
||||
|
||||
80
erpnext/regional/italy/test_italy.py
Normal file
80
erpnext/regional/italy/test_italy.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.regional.italy.utils import get_invoice_summary
|
||||
|
||||
VAT_7 = "_Test Italy VAT 7 - _TC"
|
||||
VAT_19 = "_Test Italy VAT 19 - _TC"
|
||||
|
||||
|
||||
def make_item(item_code, net_amount, tax_amount, item_tax_rate):
|
||||
return frappe._dict(
|
||||
item_code=item_code,
|
||||
net_amount=net_amount,
|
||||
tax_amount=tax_amount,
|
||||
item_tax_rate=item_tax_rate,
|
||||
)
|
||||
|
||||
|
||||
def make_tax(account_head, total, charge_type="On Net Total", **kwargs):
|
||||
return frappe._dict(
|
||||
charge_type=charge_type,
|
||||
account_head=account_head,
|
||||
rate=0,
|
||||
total=total,
|
||||
tax_exemption_reason="N4-esenti",
|
||||
tax_exemption_law="Art.10",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TestItalyInvoiceSummary(FrappeTestCase):
|
||||
def test_not_applicable_tax_excluded_from_summary(self):
|
||||
"""An item that marks a tax not applicable belongs to another summary
|
||||
block. Counting it here inflates DatiRiepilogo and emits a block with
|
||||
AliquotaIVA 0.00 and no Natura, which SDI rejects."""
|
||||
items = [
|
||||
make_item("A", 100.0, 7.0, {VAT_7: 7.0, VAT_19: "N/A"}),
|
||||
make_item("B", 100.0, 19.0, {VAT_7: "N/A", VAT_19: 19.0}),
|
||||
]
|
||||
taxes = [make_tax(VAT_7, 107.0), make_tax(VAT_19, 126.0)]
|
||||
|
||||
summary = get_invoice_summary(items, taxes)
|
||||
|
||||
self.assertEqual(sorted(summary.keys()), ["19.0", "7.0"])
|
||||
self.assertEqual(summary["7.0"]["taxable_amount"], 100.0)
|
||||
self.assertEqual(summary["19.0"]["taxable_amount"], 100.0)
|
||||
|
||||
def test_zero_rated_tax_keeps_exemption_reason(self):
|
||||
"""A genuine 0% rate is still exempt and must carry its Natura."""
|
||||
items = [make_item("C", 100.0, 0.0, {VAT_7: 0.0})]
|
||||
|
||||
summary = get_invoice_summary(items, [make_tax(VAT_7, 100.0)])
|
||||
|
||||
self.assertEqual(list(summary.keys()), ["0.0"])
|
||||
self.assertEqual(summary["0.0"]["taxable_amount"], 100.0)
|
||||
self.assertEqual(summary["0.0"]["tax_exemption_reason"], "N4-esenti")
|
||||
|
||||
def test_all_items_not_applicable_falls_back_to_zero_vat(self):
|
||||
"""With every item excluded the summary would be empty, so the existing
|
||||
zero VAT fallback has to supply the block and its Natura."""
|
||||
items = [make_item("D", 100.0, 0.0, {VAT_7: "N/A"})]
|
||||
|
||||
summary = get_invoice_summary(items, [make_tax(VAT_7, 100.0)])
|
||||
|
||||
self.assertEqual(list(summary.keys()), ["0.0"])
|
||||
self.assertEqual(summary["0.0"]["tax_exemption_reason"], "N4-esenti")
|
||||
|
||||
def test_previous_row_tax_with_only_not_applicable_items(self):
|
||||
"""The summary key leaks out of the item loop and is read again for
|
||||
previous-row charges. Every item being excluded leaves it unset."""
|
||||
items = [make_item("A", 100.0, 0.0, {VAT_7: 0.0, VAT_19: "N/A"})]
|
||||
taxes = [
|
||||
make_tax(VAT_7, 100.0, idx=1),
|
||||
make_tax(VAT_19, 100.0, charge_type="On Previous Row Total", idx=2, row_id=None),
|
||||
]
|
||||
|
||||
summary = get_invoice_summary(items, taxes)
|
||||
|
||||
self.assertEqual(list(summary.keys()), ["0.0"])
|
||||
self.assertEqual(summary["0.0"]["taxable_amount"], 100.0)
|
||||
@@ -8,6 +8,7 @@ from frappe.utils.file_manager import remove_file
|
||||
|
||||
from erpnext.controllers.taxes_and_totals import get_itemised_tax
|
||||
from erpnext.regional.italy import state_codes
|
||||
from erpnext.stock.get_item_details import NOT_APPLICABLE_TAX
|
||||
|
||||
|
||||
def update_itemised_tax_data(doc):
|
||||
@@ -171,13 +172,20 @@ def get_invoice_summary(items, taxes):
|
||||
|
||||
# Check item tax rates if tax rate is zero.
|
||||
if tax.rate == 0:
|
||||
key = None
|
||||
for item in items:
|
||||
item_tax_rate = item.item_tax_rate
|
||||
if isinstance(item.item_tax_rate, str):
|
||||
item_tax_rate = json.loads(item.item_tax_rate)
|
||||
|
||||
if item_tax_rate and tax.account_head in item_tax_rate:
|
||||
key = cstr(item_tax_rate[tax.account_head])
|
||||
rate = item_tax_rate[tax.account_head]
|
||||
if rate == NOT_APPLICABLE_TAX:
|
||||
# the tax does not apply to this item, so the item belongs
|
||||
# to another summary block and must not be counted here
|
||||
continue
|
||||
|
||||
key = cstr(rate)
|
||||
if key not in summary_data:
|
||||
summary_data.setdefault(
|
||||
key,
|
||||
@@ -195,10 +203,15 @@ def get_invoice_summary(items, taxes):
|
||||
summary_data[key]["tax_exemption_reason"] = tax.tax_exemption_reason
|
||||
summary_data[key]["tax_exemption_law"] = tax.tax_exemption_law
|
||||
|
||||
if summary_data.get("0.0") and tax.charge_type in [
|
||||
"On Previous Row Total",
|
||||
"On Previous Row Amount",
|
||||
]:
|
||||
if (
|
||||
key
|
||||
and summary_data.get("0.0")
|
||||
and tax.charge_type
|
||||
in [
|
||||
"On Previous Row Total",
|
||||
"On Previous Row Amount",
|
||||
]
|
||||
):
|
||||
summary_data[key]["taxable_amount"] = tax.total
|
||||
|
||||
if summary_data == {}: # Implies that Zero VAT has not been set on any item.
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestCustomer(FrappeTestCase):
|
||||
doc.delete()
|
||||
|
||||
def test_party_details(self):
|
||||
from erpnext.accounts.party import get_party_details
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
|
||||
to_check = {
|
||||
"selling_price_list": None,
|
||||
@@ -91,7 +91,7 @@ class TestCustomer(FrappeTestCase):
|
||||
"Contact", "_Test Contact for _Test Customer-_Test Customer", "is_primary_contact", 1
|
||||
)
|
||||
|
||||
details = get_party_details("_Test Customer")
|
||||
details = _get_party_details("_Test Customer")
|
||||
|
||||
for key, value in to_check.items():
|
||||
val = details.get(key)
|
||||
@@ -101,13 +101,13 @@ class TestCustomer(FrappeTestCase):
|
||||
self.assertEqual(value, val)
|
||||
|
||||
def test_party_details_tax_category(self):
|
||||
from erpnext.accounts.party import get_party_details
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
|
||||
frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
|
||||
frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Shipping")
|
||||
|
||||
# Tax Category without Address
|
||||
details = get_party_details("_Test Customer With Tax Category")
|
||||
details = _get_party_details("_Test Customer With Tax Category")
|
||||
self.assertEqual(details.tax_category, "_Test Tax Category 1")
|
||||
|
||||
billing_address = frappe.get_doc(
|
||||
@@ -141,13 +141,13 @@ class TestCustomer(FrappeTestCase):
|
||||
# Tax Category from Billing Address
|
||||
settings.determine_address_tax_category_from = "Billing Address"
|
||||
settings.save()
|
||||
details = get_party_details("_Test Customer With Tax Category")
|
||||
details = _get_party_details("_Test Customer With Tax Category")
|
||||
self.assertEqual(details.tax_category, "_Test Tax Category 2")
|
||||
|
||||
# Tax Category from Shipping Address
|
||||
settings.determine_address_tax_category_from = "Shipping Address"
|
||||
settings.save()
|
||||
details = get_party_details("_Test Customer With Tax Category")
|
||||
details = _get_party_details("_Test Customer With Tax Category")
|
||||
self.assertEqual(details.tax_category, "_Test Tax Category 3")
|
||||
|
||||
# Rollback
|
||||
|
||||
@@ -7,7 +7,7 @@ from frappe import _, qb
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
from erpnext import get_default_company
|
||||
from erpnext.accounts.party import get_party_details
|
||||
from erpnext.accounts.party import _get_party_details
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -125,7 +125,7 @@ def get_data(filters=None):
|
||||
|
||||
|
||||
def get_customer_details(filters):
|
||||
customer_details = get_party_details(party=filters.get("customer"), party_type="Customer")
|
||||
customer_details = _get_party_details(party=filters.get("customer"), party_type="Customer")
|
||||
customer_details.update(
|
||||
{"company": get_default_company(), "price_list": customer_details.get("selling_price_list")}
|
||||
)
|
||||
|
||||
@@ -901,7 +901,9 @@ def send():
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_digest_msg(name):
|
||||
return frappe.get_doc("Email Digest", name).get_msg_html()
|
||||
email_digest = frappe.get_doc("Email Digest", name)
|
||||
email_digest.check_permission()
|
||||
return email_digest.get_msg_html()
|
||||
|
||||
|
||||
def get_incomes_expenses_for_period(account, from_date, to_date):
|
||||
|
||||
@@ -25,6 +25,9 @@ def boot_session(bootinfo):
|
||||
bootinfo.sysdefaults.over_billing_allowance = frappe.db.get_single_value(
|
||||
"Accounts Settings", "over_billing_allowance"
|
||||
)
|
||||
bootinfo.sysdefaults.disable_include_dimensions = cint(
|
||||
frappe.get_single_value("Accounts Settings", "disable_include_dimensions")
|
||||
)
|
||||
|
||||
bootinfo.sysdefaults.quotation_valid_till = cint(
|
||||
frappe.db.get_single_value("CRM Settings", "default_valid_till")
|
||||
|
||||
@@ -188,7 +188,10 @@ frappe.ui.form.on("Item", {
|
||||
if (frm.doc.variant_of) {
|
||||
frm.set_intro(
|
||||
__("This Item is a Variant of {0} (Template).", [
|
||||
`<a href="/app/item/${frm.doc.variant_of}" onclick="location.reload()">${frm.doc.variant_of}</a>`,
|
||||
`<a href="${frappe.utils.get_form_link(
|
||||
"Item",
|
||||
frm.doc.variant_of
|
||||
)}" onclick="location.reload()">${frappe.utils.escape_html(frm.doc.variant_of)}</a>`,
|
||||
]),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "item_description",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Item Description",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -224,7 +224,7 @@
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-02 22:18:00.450641",
|
||||
"modified": "2026-08-12 13:14:41.847412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Price",
|
||||
@@ -264,4 +264,4 @@
|
||||
"states": [],
|
||||
"title_field": "item_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class ItemPrice(Document):
|
||||
currency: DF.Link | None
|
||||
customer: DF.Link | None
|
||||
item_code: DF.Link
|
||||
item_description: DF.Text | None
|
||||
item_description: DF.TextEditor | None
|
||||
item_name: DF.Data | None
|
||||
lead_time_days: DF.Int
|
||||
note: DF.Text | None
|
||||
|
||||
@@ -372,6 +372,9 @@ class PurchaseReceipt(BuyingController):
|
||||
|
||||
# Check for Closed status
|
||||
def check_on_hold_or_closed_status(self):
|
||||
if self.get("is_return"):
|
||||
return
|
||||
|
||||
check_list = []
|
||||
for d in self.get("items"):
|
||||
if d.meta.get_field("purchase_order") and d.purchase_order and d.purchase_order not in check_list:
|
||||
|
||||
@@ -708,6 +708,43 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
update_purchase_receipt_status(pr.name, "Closed")
|
||||
self.assertEqual(frappe.db.get_value("Purchase Receipt", pr.name, "status"), "Closed")
|
||||
|
||||
def test_purchase_return_against_closed_purchase_order(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import (
|
||||
make_purchase_receipt as make_pr_from_po,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
po = create_purchase_order(qty=2, rate=100)
|
||||
|
||||
receipts = []
|
||||
for _ in range(2):
|
||||
pr = make_pr_from_po(po.name)
|
||||
pr.items[0].qty = pr.items[0].received_qty = 1
|
||||
pr.submit()
|
||||
receipts.append(pr)
|
||||
|
||||
first_return = make_return_doc("Purchase Receipt", receipts[0].name)
|
||||
first_return.submit()
|
||||
|
||||
po.reload()
|
||||
po.update_status("Closed")
|
||||
|
||||
# a return against a closed Purchase Order should still go through,
|
||||
# the same way a Delivery Note return does against a closed Sales Order
|
||||
second_return = make_return_doc("Purchase Receipt", receipts[1].name)
|
||||
second_return.submit()
|
||||
|
||||
self.assertEqual(second_return.docstatus, 1)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed")
|
||||
|
||||
# cancelling the return runs the same check on the closed order
|
||||
second_return.cancel()
|
||||
|
||||
# a regular receipt against the closed order must still be blocked
|
||||
blocked_pr = make_pr_from_po(po.name)
|
||||
self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pr.save)
|
||||
|
||||
def test_pr_billing_status(self):
|
||||
"""Flow:
|
||||
1. PO -> PR1 -> PI
|
||||
|
||||
@@ -174,7 +174,10 @@ frappe.ui.form.on("Shipment", {
|
||||
__("Email or Phone/Mobile of the Contact are mandatory to continue.") +
|
||||
"</br>" +
|
||||
__("Please set Email/Phone for the contact") +
|
||||
` <a href='/app/contact/${contact_name}'>${contact_name}</a>`
|
||||
` <a href="${frappe.utils.get_form_link(
|
||||
"Contact",
|
||||
contact_name
|
||||
)}">${frappe.utils.escape_html(contact_name)}</a>`
|
||||
);
|
||||
}
|
||||
let contact_display = r.message.contact_display;
|
||||
|
||||
@@ -32,6 +32,9 @@ from erpnext.manufacturing.doctype.bom.bom import (
|
||||
get_scrap_items_from_sub_assemblies,
|
||||
validate_bom_no,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
|
||||
get_minimum_material_coverage_fraction,
|
||||
)
|
||||
from erpnext.setup.doctype.brand.brand import get_brand_defaults
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
@@ -262,6 +265,7 @@ class StockEntry(StockController):
|
||||
self.calculate_rate_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_component_and_quantities()
|
||||
self._cap_completed_qty_to_material_coverage()
|
||||
self.validate_finished_good_serial_batch_for_work_order()
|
||||
|
||||
if not self.get("purpose") == "Manufacture":
|
||||
@@ -1186,6 +1190,67 @@ class StockEntry(StockController):
|
||||
title=_("Missing Item"),
|
||||
)
|
||||
|
||||
def _cap_completed_qty_to_material_coverage(self):
|
||||
if not self._should_cap_completed_qty():
|
||||
return
|
||||
# Keep an excessive claim intact so the Work Order allowance check can reject it.
|
||||
max_qty = flt(self.pro_doc.qty)
|
||||
overproduction_percentage = flt(
|
||||
frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
|
||||
)
|
||||
to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt(
|
||||
self.fg_completed_qty
|
||||
)
|
||||
transfer_limit_qty = max_qty + (max_qty * overproduction_percentage / 100)
|
||||
if transfer_limit_qty < to_transfer_qty:
|
||||
return
|
||||
|
||||
required_qty, transferred_qty = self._get_work_order_material_qty()
|
||||
if not required_qty:
|
||||
return
|
||||
|
||||
covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
for row in self.items:
|
||||
item_code = row.original_item or row.item_code
|
||||
if row.s_warehouse and item_code in required_qty:
|
||||
transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
|
||||
covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
|
||||
self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
|
||||
|
||||
def _should_cap_completed_qty(self):
|
||||
if self.get("_action") != "submit":
|
||||
return False
|
||||
if self.purpose != "Material Transfer for Manufacture":
|
||||
return False
|
||||
if not self.pro_doc or not self.fg_completed_qty:
|
||||
return False
|
||||
if self.is_return or self.get("is_additional_transfer_entry"):
|
||||
return False
|
||||
return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
|
||||
|
||||
def _get_work_order_material_qty(self):
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
for row in self.pro_doc.required_items:
|
||||
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_qty[row.item_code] = required_qty.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
# Duplicate required-item rows each hold the aggregate transferred quantity.
|
||||
transferred_qty[row.item_code] = max(
|
||||
transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
return required_qty, transferred_qty
|
||||
|
||||
def _get_covered_work_order_qty(self, required_qty, transferred_qty):
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
)
|
||||
return min_fraction * flt(self.pro_doc.qty)
|
||||
|
||||
def _validate_no_excess_transfer(self):
|
||||
if self.is_return:
|
||||
return
|
||||
|
||||
@@ -1162,12 +1162,15 @@ def get_item_and_warehouses(item_code, warehouse):
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
|
||||
items = []
|
||||
stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
|
||||
if frappe.get_cached_value("Warehouse", warehouse, "is_group"):
|
||||
childrens = get_descendants_of("Warehouse", warehouse, ignore_permissions=True, order_by="lft")
|
||||
for ch_warehouse in childrens:
|
||||
items.append(frappe._dict({"item_code": item_code, "warehouse": ch_warehouse}))
|
||||
items.append(
|
||||
frappe._dict({"item_code": item_code, "warehouse": ch_warehouse, "stock_uom": stock_uom})
|
||||
)
|
||||
else:
|
||||
items = [frappe._dict({"item_code": item_code, "warehouse": warehouse})]
|
||||
items = [frappe._dict({"item_code": item_code, "warehouse": warehouse, "stock_uom": stock_uom})]
|
||||
|
||||
return items
|
||||
|
||||
@@ -1177,7 +1180,8 @@ def get_items_for_stock_reco(warehouse, company):
|
||||
items = frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no
|
||||
i.name as item_code, i.item_name, bin.warehouse as warehouse, i.has_serial_no, i.has_batch_no,
|
||||
i.stock_uom
|
||||
from
|
||||
`tabBin` bin, `tabItem` i
|
||||
where
|
||||
@@ -1195,7 +1199,8 @@ def get_items_for_stock_reco(warehouse, company):
|
||||
items += frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no
|
||||
i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no,
|
||||
i.has_batch_no, i.stock_uom
|
||||
from
|
||||
`tabItem` i, `tabItem Default` id
|
||||
where
|
||||
@@ -1241,6 +1246,7 @@ def get_item_data(row, qty, valuation_rate, serial_no=None):
|
||||
"current_serial_no": serial_no,
|
||||
"serial_no": serial_no,
|
||||
"batch_no": row.get("batch_no"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1268,6 +1274,7 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
|
||||
"valuation_rate": row[9],
|
||||
"item_name": row[1],
|
||||
"batch_no": row[4],
|
||||
"stock_uom": row[11],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -141,6 +141,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
||||
"_Test Stock Reco Item",
|
||||
is_stock_item=1,
|
||||
valuation_rate=100,
|
||||
stock_uom="_Test UOM",
|
||||
warehouse="_Test Warehouse Ledger 1 - _TC",
|
||||
opening_stock=100,
|
||||
)
|
||||
@@ -148,8 +149,8 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
|
||||
items = get_items("_Test Warehouse Group 1 - _TC", nowdate(), nowtime(), "_Test Company")
|
||||
|
||||
self.assertEqual(
|
||||
["_Test Stock Reco Item", "_Test Warehouse Ledger 1 - _TC", 100],
|
||||
[items[0]["item_code"], items[0]["warehouse"], items[0]["qty"]],
|
||||
["_Test Stock Reco Item", "_Test Warehouse Ledger 1 - _TC", 100, "_Test UOM"],
|
||||
[items[0]["item_code"], items[0]["warehouse"], items[0]["qty"], items[0]["stock_uom"]],
|
||||
)
|
||||
|
||||
def test_stock_reco_for_serialized_item(self):
|
||||
|
||||
@@ -34,6 +34,8 @@ purchase_doctypes = [
|
||||
"Purchase Invoice",
|
||||
]
|
||||
|
||||
NOT_APPLICABLE_TAX = "N/A"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True):
|
||||
@@ -806,7 +808,10 @@ def get_item_tax_map(company, item_tax_template, as_json=True):
|
||||
template = frappe.get_cached_doc("Item Tax Template", item_tax_template)
|
||||
for d in template.taxes:
|
||||
if frappe.get_cached_value("Account", d.tax_type, "company") == company:
|
||||
item_tax_map[d.tax_type] = d.tax_rate
|
||||
if d.get("not_applicable"):
|
||||
item_tax_map[d.tax_type] = NOT_APPLICABLE_TAX
|
||||
else:
|
||||
item_tax_map[d.tax_type] = d.tax_rate
|
||||
|
||||
return json.dumps(item_tax_map) if as_json else item_tax_map
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@ def get_batchwise_data_from_serial_batch_bundle(batchwise_data, filters):
|
||||
|
||||
|
||||
def get_query_based_on_filters(query, batch, table, filters):
|
||||
if filters.company:
|
||||
query = query.where(table.company == filters.company)
|
||||
|
||||
if filters.item_code:
|
||||
query = query.where(table.item_code == filters.item_code)
|
||||
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Stock Qty vs Serial No Count"] = {
|
||||
onload: function (report) {
|
||||
report.page.add_inner_button(__("Sync Serial No Status"), () => {
|
||||
const warehouse = report.get_filter_value("warehouse");
|
||||
if (!warehouse) {
|
||||
frappe.msgprint(__("Please select a warehouse first."));
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This will update the warehouse and status of Serial Nos counted in {0} to match the stock ledger. Continue?",
|
||||
[warehouse.bold()]
|
||||
),
|
||||
() => {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count.sync_serial_no_status",
|
||||
args: { warehouse: warehouse },
|
||||
freeze: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
filters: [
|
||||
{
|
||||
fieldname: "company",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Order
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import cstr, flt
|
||||
from pypika import analytics as an
|
||||
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_no_status
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -38,7 +44,20 @@ def get_columns():
|
||||
return columns
|
||||
|
||||
|
||||
def get_warehouses(warehouse):
|
||||
if frappe.db.get_value("Warehouse", warehouse, "is_group"):
|
||||
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
|
||||
|
||||
return get_child_warehouses(warehouse)
|
||||
|
||||
return [warehouse]
|
||||
|
||||
|
||||
def get_data(warehouse, show_disabled_items):
|
||||
# A group (parent) warehouse holds no stock itself; stock lives in its child
|
||||
# warehouses. Expand it to all its descendants so the report aggregates them.
|
||||
warehouses = get_warehouses(warehouse)
|
||||
|
||||
filters = {"has_serial_no": True}
|
||||
if not show_disabled_items:
|
||||
filters["disabled"] = False
|
||||
@@ -53,16 +72,23 @@ def get_data(warehouse, show_disabled_items):
|
||||
for item in serial_item_list:
|
||||
total_serial_no = frappe.db.count(
|
||||
"Serial No",
|
||||
filters={"item_code": item.item_code, "status": ("in", status_list), "warehouse": warehouse},
|
||||
filters={
|
||||
"item_code": item.item_code,
|
||||
"status": ("in", status_list),
|
||||
"warehouse": ("in", warehouses),
|
||||
},
|
||||
)
|
||||
|
||||
actual_qty = frappe.db.get_value(
|
||||
"Bin", fieldname=["actual_qty"], filters={"warehouse": warehouse, "item_code": item.item_code}
|
||||
)
|
||||
bin_table = frappe.qb.DocType("Bin")
|
||||
bin_qty = (
|
||||
frappe.qb.from_(bin_table)
|
||||
.select(Sum(bin_table.actual_qty))
|
||||
.where(bin_table.item_code == item.item_code)
|
||||
.where(bin_table.warehouse.isin(warehouses))
|
||||
).run()
|
||||
|
||||
# frappe.db.get_value returns null if no record exist.
|
||||
if not actual_qty:
|
||||
actual_qty = 0
|
||||
# Sum is null when no Bin record exists for the item in these warehouses.
|
||||
actual_qty = flt(bin_qty[0][0]) if bin_qty else 0
|
||||
|
||||
difference = total_serial_no - actual_qty
|
||||
|
||||
@@ -77,3 +103,172 @@ def get_data(warehouse, show_disabled_items):
|
||||
data.append(row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
SYNC_CHUNK_SIZE = 1000
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def sync_serial_no_status(warehouse: str, item_code: str | None = None):
|
||||
if not frappe.has_permission("Serial No", "write"):
|
||||
frappe.throw(_("Not permitted to update Serial No"), frappe.PermissionError)
|
||||
|
||||
warehouse = cstr(warehouse)
|
||||
item_code = cstr(item_code) if item_code else None
|
||||
if not frappe.db.exists("Warehouse", warehouse):
|
||||
frappe.throw(_("Warehouse {0} does not exist").format(warehouse))
|
||||
|
||||
if item_code and not frappe.db.exists("Item", item_code):
|
||||
frappe.throw(_("Item {0} does not exist").format(item_code))
|
||||
|
||||
frappe.enqueue(
|
||||
sync_serial_no_status_for_warehouse,
|
||||
queue="long",
|
||||
warehouse=warehouse,
|
||||
item_code=item_code,
|
||||
)
|
||||
frappe.msgprint(
|
||||
_("Serial No status sync has been queued. Reload the report after a few minutes."),
|
||||
alert=True,
|
||||
)
|
||||
|
||||
|
||||
def sync_serial_no_status_for_warehouse(warehouse, item_code=None):
|
||||
filters = {"has_serial_no": 1}
|
||||
if item_code:
|
||||
filters["name"] = item_code
|
||||
|
||||
for item in frappe.get_all("Item", filters=filters, pluck="name"):
|
||||
sync_serial_no_status_for_item(item, warehouse)
|
||||
|
||||
|
||||
def sync_serial_no_status_for_item(item_code, warehouse):
|
||||
"""Correct Serial No records this report counts in the warehouse but whose last
|
||||
stock ledger movement says the stock left it. Reposting rebuilds qty and valuation
|
||||
from the ledger but never rewrites Serial No warehouse/status, so records orphaned
|
||||
by cancelled or amended vouchers keep inflating the serial count."""
|
||||
serial_nos = frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"item_code": item_code, "warehouse": warehouse, "status": ("in", ["Active", "Expired"])},
|
||||
pluck="name",
|
||||
)
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
last_moves = get_last_ledger_moves(item_code, serial_nos)
|
||||
for serial_no in serial_nos:
|
||||
row = last_moves.get(serial_no)
|
||||
if row and flt(row.qty) > 0 and row.warehouse == warehouse:
|
||||
continue
|
||||
|
||||
set_serial_no_state_from_ledger(serial_no, row)
|
||||
|
||||
|
||||
def set_serial_no_state_from_ledger(serial_no, row):
|
||||
if not row:
|
||||
frappe.db.set_value(
|
||||
"Serial No", serial_no, {"warehouse": None, "status": "Inactive"}, update_modified=False
|
||||
)
|
||||
return
|
||||
|
||||
status = get_serial_no_status(
|
||||
frappe._dict(
|
||||
actual_qty=flt(row.qty),
|
||||
warehouse=row.warehouse,
|
||||
voucher_type=row.voucher_type,
|
||||
voucher_no=row.voucher_no,
|
||||
is_cancelled=0,
|
||||
)
|
||||
)
|
||||
warehouse = row.warehouse if status == "Active" else None
|
||||
frappe.db.set_value(
|
||||
"Serial No", serial_no, {"warehouse": warehouse, "status": status}, update_modified=False
|
||||
)
|
||||
|
||||
|
||||
def get_last_ledger_moves(item_code, serial_nos):
|
||||
last_moves = get_last_bundle_moves(item_code, serial_nos)
|
||||
if missing := [serial_no for serial_no in serial_nos if serial_no not in last_moves]:
|
||||
set_legacy_last_moves(item_code, missing, last_moves)
|
||||
|
||||
return last_moves
|
||||
|
||||
|
||||
def get_last_bundle_moves(item_code, serial_nos):
|
||||
last_moves = {}
|
||||
for start in range(0, len(serial_nos), SYNC_CHUNK_SIZE):
|
||||
for row in get_last_bundle_moves_chunk(item_code, serial_nos[start : start + SYNC_CHUNK_SIZE]):
|
||||
last_moves[row.serial_no] = row
|
||||
|
||||
return last_moves
|
||||
|
||||
|
||||
def get_last_bundle_moves_chunk(item_code, serial_nos):
|
||||
"""A bundle can be created much before its Stock Ledger Entry, so same-posting-datetime
|
||||
ties are broken on the creation of the bundle's own SLE. The SLE join also keeps only
|
||||
real stock movements - reservation bundles (Pick List) carry no SLE."""
|
||||
entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
bundle = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
row_number = (
|
||||
an.RowNumber()
|
||||
.over(entry.serial_no)
|
||||
.orderby(bundle.posting_datetime, order=Order.desc)
|
||||
.orderby(sle.creation, order=Order.desc)
|
||||
)
|
||||
|
||||
ranked = (
|
||||
frappe.qb.from_(entry)
|
||||
.inner_join(bundle)
|
||||
.on(entry.parent == bundle.name)
|
||||
.inner_join(sle)
|
||||
.on(sle.serial_and_batch_bundle == bundle.name)
|
||||
.select(
|
||||
entry.serial_no,
|
||||
entry.qty,
|
||||
Coalesce(entry.warehouse, bundle.warehouse).as_("warehouse"),
|
||||
bundle.voucher_type,
|
||||
bundle.voucher_no,
|
||||
row_number.as_("row_no"),
|
||||
)
|
||||
.where(
|
||||
(bundle.docstatus == 1)
|
||||
& (Coalesce(bundle.is_cancelled, 0) == 0)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (bundle.item_code == item_code)
|
||||
& (entry.serial_no.isin(serial_nos))
|
||||
)
|
||||
).as_("ranked")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(ranked)
|
||||
.select(ranked.serial_no, ranked.qty, ranked.warehouse, ranked.voucher_type, ranked.voucher_no)
|
||||
.where(ranked.row_no == 1)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
|
||||
def set_legacy_last_moves(item_code, serial_nos, last_moves):
|
||||
"""Movements posted before Serial and Batch Bundle exist only as newline-separated
|
||||
text on Stock Ledger Entry."""
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
pending = set(serial_nos)
|
||||
rows = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"item_code": item_code, "is_cancelled": 0, "serial_no": ("is", "set")},
|
||||
fields=["serial_no", "actual_qty", "warehouse", "voucher_type", "voucher_no"],
|
||||
order_by="posting_datetime asc, creation asc",
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
qty = 1 if flt(row.actual_qty) > 0 else -1
|
||||
for serial_no in get_serial_nos(row.serial_no):
|
||||
if serial_no in pending:
|
||||
last_moves[serial_no] = frappe._dict(
|
||||
qty=qty,
|
||||
warehouse=row.warehouse,
|
||||
voucher_type=row.voucher_type,
|
||||
voucher_no=row.voucher_no,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
|
||||
class TestStockQtyVsSerialNoCount(FrappeTestCase):
|
||||
def test_sync_serial_no_status(self):
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.report.stock_qty_vs_serial_no_count.stock_qty_vs_serial_no_count import (
|
||||
sync_serial_no_status_for_warehouse,
|
||||
)
|
||||
|
||||
item = "_Test Serialized Item With Series"
|
||||
warehouse = "Stores - _TC"
|
||||
se = make_stock_entry(item_code=item, to_warehouse=warehouse, qty=2, rate=100)
|
||||
serial_no = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
{"parent": se.items[0].serial_and_batch_bundle},
|
||||
pluck="serial_no",
|
||||
)[0]
|
||||
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=1,
|
||||
serial_no=serial_no,
|
||||
use_serial_batch_fields=1,
|
||||
)
|
||||
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Delivered")
|
||||
|
||||
frappe.db.set_value("Serial No", serial_no, {"status": "Active", "warehouse": warehouse})
|
||||
|
||||
sync_serial_no_status_for_warehouse(warehouse, item_code=item)
|
||||
|
||||
details = frappe.db.get_value("Serial No", serial_no, ["status", "warehouse"], as_dict=True)
|
||||
self.assertEqual(details.status, "Delivered")
|
||||
self.assertFalse(details.warehouse)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user