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

chore: release v15
This commit is contained in:
Diptanil Saha
2026-08-25 22:27:36 +05:30
committed by GitHub
119 changed files with 8550 additions and 1973 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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:

View File

@@ -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":

View File

@@ -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);
}
},
});

View File

@@ -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")

View File

@@ -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"]},
],
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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);
},

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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": []
}
}

View File

@@ -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

View File

@@ -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"]},
],
}

View File

@@ -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,

View File

@@ -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", []);
}
},
},
{

View File

@@ -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"),
});
},
},
],
};

View File

@@ -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",

View File

@@ -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"),
});
},
},
],
};

View File

@@ -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 = [
{

View File

@@ -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:

View File

@@ -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)

View File

@@ -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"):

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):

View File

@@ -40,6 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
"options": "Supplier",
"reqd": 1
},

View File

@@ -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

View File

@@ -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"),

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"):

View File

@@ -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"))
):

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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":

View File

@@ -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()

View File

@@ -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",

View File

@@ -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"],
},
]

View File

@@ -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
);

View File

@@ -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>

View File

@@ -0,0 +1 @@

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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>

View File

@@ -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":

View File

@@ -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>

View File

@@ -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;

View File

@@ -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

View 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)

View 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)

View File

@@ -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">

View File

@@ -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":

View File

@@ -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",
},
]

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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>
{% } %}

View File

@@ -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">

View File

@@ -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",

View File

@@ -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>

View 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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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")}
)

View File

@@ -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):

View File

@@ -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")

View File

@@ -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
);

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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],
}
)
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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