mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-17 05:45:11 +00:00
Merge pull request #52730 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:parent.doctype == 'Sales Invoice'",
|
||||
"depends_on": "eval: [\"POS Invoice\", \"Sales Invoice\"].includes(parent.doctype)",
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
@@ -85,7 +85,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-23 16:20:06.436979",
|
||||
"modified": "2026-02-16 20:46:34.592604",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Payment",
|
||||
@@ -95,4 +95,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,12 +804,19 @@ def validate_against_pcv(is_opening, posting_date, company):
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, "max(period_end_date)"
|
||||
)
|
||||
# Local import so you don't have to touch file-level imports
|
||||
from frappe.query_builder.functions import Max
|
||||
|
||||
pcv = frappe.qb.DocType("Period Closing Voucher")
|
||||
|
||||
last_pcv_date = (
|
||||
frappe.qb.from_(pcv)
|
||||
.select(Max(pcv.period_end_date))
|
||||
.where((pcv.docstatus == 1) & (pcv.company == company))
|
||||
).run(pluck=True)[0]
|
||||
|
||||
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
|
||||
message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
|
||||
message = _("Books have been closed till the period ending on {0}.").format(formatdate(last_pcv_date))
|
||||
message += "</br >"
|
||||
message += _("You cannot create/amend any accounting entries till this date.")
|
||||
frappe.throw(message, title=_("Period Closed"))
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.utils import cstr, flt
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
@@ -33,11 +34,19 @@ def execute(filters=None):
|
||||
|
||||
def get_accounts_data(based_on, company):
|
||||
if based_on == "Cost Center":
|
||||
return frappe.db.sql(
|
||||
"""select name, parent_cost_center as parent_account, cost_center_name as account_name, lft, rgt
|
||||
from `tabCost Center` where company=%s order by name""",
|
||||
company,
|
||||
as_dict=True,
|
||||
cc = qb.DocType("Cost Center")
|
||||
return (
|
||||
qb.from_(cc)
|
||||
.select(
|
||||
cc.name,
|
||||
cc.parent_cost_center.as_("parent_account"),
|
||||
cc.cost_center_name.as_("account_name"),
|
||||
cc.lft,
|
||||
cc.rgt,
|
||||
)
|
||||
.where(cc.company.eq(company))
|
||||
.orderby(cc.name)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
elif based_on == "Project":
|
||||
return frappe.get_all("Project", fields=["name"], filters={"company": company}, order_by="name")
|
||||
@@ -206,27 +215,38 @@ def set_gl_entries_by_account(
|
||||
company, from_date, to_date, based_on, gl_entries_by_account, ignore_closing_entries=False
|
||||
):
|
||||
"""Returns a dict like { "account": [gl entries], ... }"""
|
||||
additional_conditions = []
|
||||
gl = qb.DocType("GL Entry")
|
||||
acc = qb.DocType("Account")
|
||||
|
||||
conditions = []
|
||||
conditions.append(gl.company.eq(company))
|
||||
conditions.append(gl[based_on].notnull())
|
||||
conditions.append(gl.is_cancelled.eq(0))
|
||||
|
||||
if from_date and to_date:
|
||||
conditions.append(gl.posting_date.between(from_date, to_date))
|
||||
elif from_date and not to_date:
|
||||
conditions.append(gl.posting_date.gte(from_date))
|
||||
elif not from_date and to_date:
|
||||
conditions.append(gl.posting_date.lte(to_date))
|
||||
|
||||
if ignore_closing_entries:
|
||||
additional_conditions.append("and voucher_type !='Period Closing Voucher'")
|
||||
conditions.append(gl.voucher_type.ne("Period Closing Voucher"))
|
||||
|
||||
if from_date:
|
||||
additional_conditions.append("and posting_date >= %(from_date)s")
|
||||
|
||||
gl_entries = frappe.db.sql(
|
||||
"""select posting_date, {based_on} as based_on, debit, credit,
|
||||
is_opening, (select root_type from `tabAccount` where name = account) as type
|
||||
from `tabGL Entry` where company=%(company)s
|
||||
{additional_conditions}
|
||||
and posting_date <= %(to_date)s
|
||||
and {based_on} is not null
|
||||
and is_cancelled = 0
|
||||
order by {based_on}, posting_date""".format(
|
||||
additional_conditions="\n".join(additional_conditions), based_on=based_on
|
||||
),
|
||||
{"company": company, "from_date": from_date, "to_date": to_date},
|
||||
as_dict=True,
|
||||
root_subquery = qb.from_(acc).select(acc.root_type).where(acc.name.eq(gl.account))
|
||||
gl_entries = (
|
||||
qb.from_(gl)
|
||||
.select(
|
||||
gl.posting_date,
|
||||
gl[based_on].as_("based_on"),
|
||||
gl.debit,
|
||||
gl.credit,
|
||||
gl.is_opening,
|
||||
root_subquery.as_("type"),
|
||||
)
|
||||
.where(Criterion.all(conditions))
|
||||
.orderby(gl[based_on], gl.posting_date)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
for entry in gl_entries:
|
||||
|
||||
@@ -51,7 +51,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
entries = {}
|
||||
for name, details in gle_map.items():
|
||||
for entry in details:
|
||||
tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0
|
||||
tax_amount, total_amount, grand_total, base_total, base_tax_withholding_net_total = 0, 0, 0, 0, 0
|
||||
tax_withholding_category, rate = None, None
|
||||
bill_no, bill_date = "", ""
|
||||
party = entry.party or entry.against
|
||||
@@ -83,6 +83,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
# back calculate total amount from rate and tax_amount
|
||||
base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0])
|
||||
total_amount = grand_total = base_total
|
||||
base_tax_withholding_net_total = total_amount
|
||||
|
||||
else:
|
||||
if tax_amount and rate:
|
||||
@@ -93,12 +94,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
|
||||
grand_total = values[1]
|
||||
base_total = values[2]
|
||||
base_tax_withholding_net_total = total_amount
|
||||
|
||||
if voucher_type == "Purchase Invoice":
|
||||
base_tax_withholding_net_total = values[0]
|
||||
bill_no = values[3]
|
||||
bill_date = values[4]
|
||||
|
||||
else:
|
||||
total_amount += entry.credit
|
||||
base_tax_withholding_net_total = total_amount
|
||||
|
||||
if tax_amount:
|
||||
if party_map.get(party, {}).get("party_type") == "Supplier":
|
||||
@@ -125,6 +130,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_
|
||||
"rate": rate,
|
||||
"total_amount": total_amount,
|
||||
"grand_total": grand_total,
|
||||
"base_tax_withholding_net_total": base_tax_withholding_net_total,
|
||||
"base_total": base_total,
|
||||
"tax_amount": tax_amount,
|
||||
"transaction_date": posting_date,
|
||||
@@ -252,14 +258,14 @@ def get_columns(filters):
|
||||
"width": 60,
|
||||
},
|
||||
{
|
||||
"label": _("Total Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"label": _("Tax Withholding Net Total"),
|
||||
"fieldname": "base_tax_withholding_net_total",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Base Total"),
|
||||
"fieldname": "base_total",
|
||||
"label": _("Taxable Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
},
|
||||
@@ -270,10 +276,16 @@ def get_columns(filters):
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Grand Total"),
|
||||
"label": _("Grand Total (Company Currency)"),
|
||||
"fieldname": "base_total",
|
||||
"fieldtype": "Float",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Grand Total (Transaction Currency)"),
|
||||
"fieldname": "grand_total",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"width": 170,
|
||||
},
|
||||
{"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 130},
|
||||
{
|
||||
|
||||
@@ -35,9 +35,9 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
result = execute(filters)[1]
|
||||
expected_values = [
|
||||
# Check for JV totals using back calculation logic
|
||||
[jv.name, "TCS", 0.075, -10000.0, -7.5, -10000.0],
|
||||
[pe.name, "TCS", 0.075, 2550, 0.53, 2550.53],
|
||||
[si.name, "TCS", 0.075, 1000, 0.52, 1000.52],
|
||||
[jv.name, "TCS", 0.075, -10000.0, -10000.0, -7.5, -10000.0],
|
||||
[pe.name, "TCS", 0.075, 706.67, 2550.0, 0.53, 2550.53],
|
||||
[si.name, "TCS", 0.075, 693.33, 1000.0, 0.52, 1000.52],
|
||||
]
|
||||
self.check_expected_values(result, expected_values)
|
||||
|
||||
@@ -55,8 +55,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
frappe._dict(company="_Test Company", party_type="Supplier", from_date=today(), to_date=today())
|
||||
)[1]
|
||||
expected_values = [
|
||||
[inv_1.name, "TDS - 1", 10, 5000, 500, 5500],
|
||||
[inv_2.name, "TDS - 2", 20, 5000, 1000, 6000],
|
||||
[inv_1.name, "TDS - 1", 10, 5000, 5000, 500, 5500],
|
||||
[inv_2.name, "TDS - 2", 20, 5000, 5000, 1000, 6000],
|
||||
]
|
||||
self.check_expected_values(result, expected_values)
|
||||
|
||||
@@ -107,8 +107,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
)[1]
|
||||
|
||||
expected_values = [
|
||||
[inv_1.name, "TDS - 3", 10.0, 5000, 500, 4500],
|
||||
[inv_2.name, "TDS - 3", 20.0, 5000, 1000, 4000],
|
||||
[inv_1.name, "TDS - 3", 10.0, 5000, 5000, 500, 4500],
|
||||
[inv_2.name, "TDS - 3", 20.0, 5000, 5000, 1000, 4000],
|
||||
]
|
||||
self.check_expected_values(result, expected_values)
|
||||
|
||||
@@ -120,6 +120,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase):
|
||||
voucher.ref_no,
|
||||
voucher.section_code,
|
||||
voucher.rate,
|
||||
voucher.base_tax_withholding_net_total,
|
||||
voucher.base_total,
|
||||
voucher.tax_amount,
|
||||
voucher.grand_total,
|
||||
|
||||
@@ -128,7 +128,7 @@ def get_columns(filters):
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Total Amount"),
|
||||
"label": _("Total Taxable Amount"),
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
|
||||
@@ -2580,12 +2580,12 @@ class AccountsController(TransactionBase):
|
||||
|
||||
def get_order_details(self):
|
||||
if self.doctype == "Sales Invoice":
|
||||
po_or_so = self.get("items")[0].get("sales_order")
|
||||
po_or_so = self.get("items") and self.get("items")[0].get("sales_order")
|
||||
po_or_so_doctype = "Sales Order"
|
||||
po_or_so_doctype_name = "sales_order"
|
||||
|
||||
else:
|
||||
po_or_so = self.get("items")[0].get("purchase_order")
|
||||
po_or_so = self.get("items") and self.get("items")[0].get("purchase_order")
|
||||
po_or_so_doctype = "Purchase Order"
|
||||
po_or_so_doctype_name = "purchase_order"
|
||||
|
||||
@@ -4002,6 +4002,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
flt(d.get("conversion_factor"), conv_fac_precision) or conversion_factor
|
||||
)
|
||||
|
||||
if child_item.get("total_weight") and child_item.get("weight_per_unit"):
|
||||
child_item.total_weight = flt(
|
||||
child_item.weight_per_unit * child_item.qty * child_item.conversion_factor,
|
||||
child_item.precision("total_weight"),
|
||||
)
|
||||
|
||||
if d.get("delivery_date") and parent_doctype == "Sales Order":
|
||||
child_item.delivery_date = d.get("delivery_date")
|
||||
|
||||
@@ -4054,6 +4060,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
|
||||
parent.set_payment_schedule()
|
||||
if parent_doctype == "Purchase Order":
|
||||
parent.set_tax_withholding()
|
||||
parent.validate_minimum_order_qty()
|
||||
parent.validate_budget()
|
||||
if parent.is_against_so():
|
||||
|
||||
@@ -626,7 +626,9 @@ class BuyingController(SubcontractingController):
|
||||
or self.is_return
|
||||
or (self.is_internal_transfer() and self.docstatus == 2)
|
||||
else self.get_package_for_target_warehouse(
|
||||
d, type_of_transaction=type_of_transaction
|
||||
d,
|
||||
type_of_transaction=type_of_transaction,
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
),
|
||||
},
|
||||
@@ -714,7 +716,22 @@ class BuyingController(SubcontractingController):
|
||||
via_landed_cost_voucher=via_landed_cost_voucher,
|
||||
)
|
||||
|
||||
def get_package_for_target_warehouse(self, item, warehouse=None, type_of_transaction=None) -> str:
|
||||
def get_package_for_target_warehouse(
|
||||
self, item, warehouse=None, type_of_transaction=None, via_landed_cost_voucher=None
|
||||
) -> str:
|
||||
if via_landed_cost_voucher and item.get("warehouse"):
|
||||
if sabb := frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
{
|
||||
"voucher_detail_no": item.name,
|
||||
"warehouse": item.get("warehouse"),
|
||||
"docstatus": 1,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
"name",
|
||||
):
|
||||
return sabb
|
||||
|
||||
if not item.serial_and_batch_bundle:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1591,7 +1591,7 @@ def get_gl_entries_for_preview(doctype, docname, fields):
|
||||
|
||||
def get_columns(raw_columns, fields):
|
||||
return [
|
||||
{"name": d.get("label"), "editable": False, "width": 110}
|
||||
{"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")}
|
||||
for d in raw_columns
|
||||
if not d.get("hidden") and d.get("fieldname") in fields
|
||||
]
|
||||
|
||||
@@ -1,96 +1,48 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_events_in_timeline": 0,
|
||||
"allow_guest_to_view": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"autoname": "field:market_segment",
|
||||
"beta": 0,
|
||||
"creation": "2018-10-01 09:59:14.479509",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:market_segment",
|
||||
"creation": "2018-10-01 09:59:14.479509",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"market_segment"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_in_quick_entry": 0,
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "market_segment",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Market Segment",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"translatable": 0,
|
||||
"fieldname": "market_segment",
|
||||
"fieldtype": "Data",
|
||||
"label": "Market Segment",
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"has_web_view": 0,
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 0,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 0,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-10-01 09:59:14.479509",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Market Segment",
|
||||
"name_case": "",
|
||||
"owner": "Administrator",
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-12-17 12:09:34.687368",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Market Segment",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"amend": 0,
|
||||
"cancel": 0,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 0,
|
||||
"import": 0,
|
||||
"permlevel": 0,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"set_user_permissions": 0,
|
||||
"share": 1,
|
||||
"submit": 0,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0,
|
||||
"track_views": 0
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1,
|
||||
"translated_doctype": 1
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"label": "Batch Size"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.parenttype == \"Routing\"",
|
||||
"depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing",
|
||||
"fieldname": "sequence_id",
|
||||
"fieldtype": "Int",
|
||||
"label": "Sequence ID"
|
||||
@@ -196,7 +196,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-31 16:17:47.287117",
|
||||
"modified": "2026-02-17 15:33:28.495850",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM Operation",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Warehouse",
|
||||
"options": "Warehouse"
|
||||
"options": "Warehouse",
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal && doc.warehouse",
|
||||
@@ -66,13 +67,14 @@
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
"options": "Company",
|
||||
"set_only_once": 1
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-19 19:06:36.481625",
|
||||
"modified": "2026-02-17 11:53:17.940039",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Plant Floor",
|
||||
|
||||
@@ -648,8 +648,8 @@ class ProductionPlan(Document):
|
||||
self.status = "Completed"
|
||||
|
||||
if self.status != "Completed":
|
||||
self.update_ordered_status()
|
||||
self.update_requested_status()
|
||||
self.update_ordered_status()
|
||||
|
||||
if close is not None:
|
||||
self.db_set("status", self.status)
|
||||
@@ -658,25 +658,17 @@ class ProductionPlan(Document):
|
||||
self.update_bin_qty()
|
||||
|
||||
def update_ordered_status(self):
|
||||
update_status = False
|
||||
for d in self.po_items:
|
||||
if d.planned_qty == d.ordered_qty:
|
||||
update_status = True
|
||||
|
||||
if update_status and self.status != "Completed":
|
||||
self.status = "In Process"
|
||||
for child_table in ["po_items", "sub_assembly_items"]:
|
||||
for item in self.get(child_table):
|
||||
if item.ordered_qty:
|
||||
self.status = "In Process"
|
||||
return
|
||||
|
||||
def update_requested_status(self):
|
||||
if not self.mr_items:
|
||||
return
|
||||
|
||||
update_status = True
|
||||
for d in self.mr_items:
|
||||
if d.quantity != d.requested_qty:
|
||||
update_status = False
|
||||
|
||||
if update_status:
|
||||
self.status = "Material Requested"
|
||||
if d.requested_qty:
|
||||
self.status = "Material Requested"
|
||||
break
|
||||
|
||||
def get_production_items(self):
|
||||
item_dict = {}
|
||||
@@ -798,6 +790,8 @@ class ProductionPlan(Document):
|
||||
"stock_uom",
|
||||
"bom_level",
|
||||
"schedule_date",
|
||||
"sales_order",
|
||||
"sales_order_item",
|
||||
]:
|
||||
if row.get(field):
|
||||
wo_data[field] = row.get(field)
|
||||
@@ -837,6 +831,8 @@ class ProductionPlan(Document):
|
||||
"qty",
|
||||
"description",
|
||||
"production_plan_item",
|
||||
"sales_order",
|
||||
"sales_order_item",
|
||||
]:
|
||||
po_data[field] = row.get(field)
|
||||
|
||||
@@ -1023,6 +1019,10 @@ class ProductionPlan(Document):
|
||||
if not is_group_warehouse:
|
||||
data.fg_warehouse = self.sub_assembly_warehouse
|
||||
|
||||
if not self.combine_sub_items:
|
||||
data.sales_order = row.sales_order
|
||||
data.sales_order_item = row.sales_order_item
|
||||
|
||||
def set_default_supplier_for_subcontracting_order(self):
|
||||
items = [
|
||||
d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract"
|
||||
|
||||
@@ -565,6 +565,90 @@ class TestProductionPlan(FrappeTestCase):
|
||||
self.assertEqual(po_doc.items[0].fg_item, fg_item)
|
||||
self.assertEqual(po_doc.items[0].item_code, service_item)
|
||||
|
||||
def test_sales_order_references_for_sub_assembly_items(self):
|
||||
"""
|
||||
Test that Sales Order and Sales Order Item references in Work Order and Purchase Order
|
||||
are correctly propagated from the Production Plan.
|
||||
"""
|
||||
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
# Setup Test Items & BOM
|
||||
fg_item = "Test FG Good Item"
|
||||
sub_assembly_item1 = "Test Sub Assembly Item 1"
|
||||
sub_assembly_item2 = "Test Sub Assembly Item 2"
|
||||
|
||||
bom_tree = {
|
||||
fg_item: {
|
||||
sub_assembly_item1: {"Test Raw Material 1": {}},
|
||||
sub_assembly_item2: {"Test Raw Material 2": {}},
|
||||
}
|
||||
}
|
||||
|
||||
create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
# Create Sales Order
|
||||
so = make_sales_order(item_code=fg_item, qty=10)
|
||||
so_item_row = so.items[0].name
|
||||
|
||||
# Create Production Plan from Sales Order
|
||||
production_plan = frappe.new_doc("Production Plan")
|
||||
production_plan.company = so.company
|
||||
production_plan.get_items_from = "Sales Order"
|
||||
production_plan.item_code = fg_item
|
||||
|
||||
production_plan.get_open_sales_orders()
|
||||
self.assertEqual(production_plan.sales_orders[0].sales_order, so.name)
|
||||
|
||||
production_plan.get_so_items()
|
||||
|
||||
production_plan.skip_available_sub_assembly_item = 0
|
||||
production_plan.get_sub_assembly_items()
|
||||
|
||||
self.assertEqual(len(production_plan.sub_assembly_items), 2)
|
||||
|
||||
# Validate Sales Order references in Sub Assembly Items
|
||||
for row in production_plan.sub_assembly_items:
|
||||
if row.production_item == sub_assembly_item1:
|
||||
row.supplier = "_Test Supplier"
|
||||
row.type_of_manufacturing = "Subcontract"
|
||||
|
||||
self.assertEqual(row.sales_order, so.name)
|
||||
self.assertEqual(row.sales_order_item, so_item_row)
|
||||
|
||||
# Submit Production Plan
|
||||
production_plan.save()
|
||||
production_plan.submit()
|
||||
production_plan.make_work_order()
|
||||
|
||||
# Validate Purchase Order (Subcontracted Item)
|
||||
po_items = frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
{
|
||||
"production_plan": production_plan.name,
|
||||
"fg_item": sub_assembly_item1,
|
||||
},
|
||||
["sales_order", "sales_order_item"],
|
||||
)
|
||||
|
||||
self.assertTrue(po_items)
|
||||
self.assertEqual(po_items[0].sales_order, so.name)
|
||||
self.assertEqual(po_items[0].sales_order_item, so_item_row)
|
||||
|
||||
# Validate Work Order (In-house Item)
|
||||
work_orders = frappe.get_all(
|
||||
"Work Order",
|
||||
{
|
||||
"production_plan": production_plan.name,
|
||||
"production_item": sub_assembly_item2,
|
||||
},
|
||||
["sales_order", "sales_order_item"],
|
||||
)
|
||||
|
||||
self.assertTrue(work_orders)
|
||||
self.assertEqual(work_orders[0].sales_order, so.name)
|
||||
self.assertEqual(work_orders[0].sales_order_item, so_item_row)
|
||||
|
||||
def test_production_plan_combine_subassembly(self):
|
||||
"""
|
||||
Test combining Sub assembly items belonging to the same BOM in Prod Plan.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"fg_warehouse",
|
||||
"parent_item_code",
|
||||
"schedule_date",
|
||||
"sales_order",
|
||||
"sales_order_item",
|
||||
"column_break_3",
|
||||
"qty",
|
||||
"bom_no",
|
||||
@@ -212,20 +214,36 @@
|
||||
"label": "Ordered Qty",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sales_order",
|
||||
"fieldtype": "Link",
|
||||
"label": "Sales Order",
|
||||
"options": "Sales Order",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "sales_order_item",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Sales Order Item",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-10 13:36:24.759101",
|
||||
"modified": "2026-02-17 12:06:02.309032",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Sub Assembly Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class ProductionPlanSubAssemblyItem(Document):
|
||||
purchase_order: DF.Link | None
|
||||
qty: DF.Float
|
||||
received_qty: DF.Float
|
||||
sales_order: DF.Link | None
|
||||
sales_order_item: DF.Data | None
|
||||
schedule_date: DF.Datetime | None
|
||||
stock_uom: DF.Link | None
|
||||
supplier: DF.Link | None
|
||||
|
||||
@@ -598,9 +598,20 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff
|
||||
: this.frm.doc.net_total);
|
||||
|
||||
if(["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(this.frm.doc.doctype)) {
|
||||
this.frm.doc.base_grand_total = (this.frm.doc.total_taxes_and_charges) ?
|
||||
flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate) : this.frm.doc.base_net_total;
|
||||
// total taxes and charges is calculated before adjusting base grand total
|
||||
this.frm.doc.total_taxes_and_charges = flt(
|
||||
this.frm.doc.grand_total - this.frm.doc.net_total - grand_total_diff,
|
||||
precision("total_taxes_and_charges")
|
||||
);
|
||||
|
||||
if (
|
||||
["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(
|
||||
this.frm.doc.doctype
|
||||
)
|
||||
) {
|
||||
this.frm.doc.base_grand_total = this.frm.doc.total_taxes_and_charges
|
||||
? flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate)
|
||||
: this.frm.doc.base_net_total;
|
||||
} else {
|
||||
// other charges added/deducted
|
||||
this.frm.doc.taxes_and_charges_added = this.frm.doc.taxes_and_charges_deducted = 0.0;
|
||||
@@ -626,11 +637,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
["taxes_and_charges_added", "taxes_and_charges_deducted"]);
|
||||
}
|
||||
|
||||
this.frm.doc.total_taxes_and_charges = flt(this.frm.doc.grand_total - this.frm.doc.net_total
|
||||
- grand_total_diff, precision("total_taxes_and_charges"));
|
||||
|
||||
this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges"]);
|
||||
|
||||
// Round grand total as per precision
|
||||
frappe.model.round_floats_in(this.frm.doc, ["grand_total", "base_grand_total"]);
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ erpnext.accounts.ledger_preview = {
|
||||
},
|
||||
|
||||
get_datatable(columns, data, wrapper) {
|
||||
columns.forEach((col) => {
|
||||
if (col.fieldtype === "Currency") {
|
||||
col.format = (value) => {
|
||||
return format_currency(value);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const datatable_options = {
|
||||
columns: columns,
|
||||
data: data,
|
||||
|
||||
@@ -707,7 +707,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
|
||||
}
|
||||
|
||||
render_data() {
|
||||
if (this.bundle || this.frm.doc.is_return) {
|
||||
if (this.bundle || (this.frm.doc.is_return && this.frm.doc.return_against)) {
|
||||
frappe
|
||||
.call({
|
||||
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_serial_batch_ledgers",
|
||||
|
||||
@@ -1203,7 +1203,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a
|
||||
"doctype": "Sales Invoice",
|
||||
"field_map": {
|
||||
"party_account_currency": "party_account_currency",
|
||||
"payment_terms_template": "payment_terms_template",
|
||||
},
|
||||
"field_no_map": ["payment_terms_template"],
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
|
||||
@@ -202,7 +202,11 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1},
|
||||
{
|
||||
"add_taxes_from_item_tax_template": 0,
|
||||
"add_taxes_from_taxes_and_charges_template": 1,
|
||||
"automatically_fetch_payment_terms": 1,
|
||||
},
|
||||
)
|
||||
def test_make_sales_invoice_with_terms(self):
|
||||
so = make_sales_order(do_not_submit=True)
|
||||
@@ -232,6 +236,34 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
si1 = make_sales_invoice(so.name)
|
||||
self.assertEqual(len(si1.get("items")), 0)
|
||||
|
||||
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) # Enable auto fetch
|
||||
def test_auto_fetch_terms_enable(self):
|
||||
so = make_sales_order(do_not_submit=True)
|
||||
|
||||
so.payment_terms_template = "_Test Payment Term Template"
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
# Check if payment terms are copied from sales order to sales invoice
|
||||
self.assertTrue(si.payment_terms_template)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 0}) # Disable auto fetch
|
||||
def test_auto_fetch_terms_disable(self):
|
||||
so = make_sales_order(do_not_submit=True)
|
||||
|
||||
so.payment_terms_template = "_Test Payment Term Template"
|
||||
so.save()
|
||||
so.submit()
|
||||
|
||||
si = make_sales_invoice(so.name)
|
||||
# Check if payment terms are not copied from sales order to sales invoice
|
||||
self.assertFalse(si.payment_terms_template)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
def test_update_qty(self):
|
||||
so = make_sales_order()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -97,45 +98,53 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_entries(filters):
|
||||
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
|
||||
dt = qb.DocType(filters["doc_type"])
|
||||
st = qb.DocType("Sales Team")
|
||||
date_field = dt["transaction_date"] if filters["doc_type"] == "Sales Order" else dt["posting_date"]
|
||||
|
||||
conditions, values = get_conditions(filters, date_field)
|
||||
entries = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
dt.name, dt.customer, dt.territory, dt.{} as posting_date,dt.base_net_total as base_net_amount,
|
||||
st.commission_rate,st.sales_person, st.allocated_percentage, st.allocated_amount, st.incentives
|
||||
from
|
||||
`tab{}` dt, `tabSales Team` st
|
||||
where
|
||||
st.parent = dt.name and st.parenttype = {}
|
||||
and dt.docstatus = 1 {} order by dt.name desc,st.sales_person
|
||||
""".format(date_field, filters["doc_type"], "%s", conditions),
|
||||
tuple([filters["doc_type"], *values]),
|
||||
as_dict=1,
|
||||
conditions = get_conditions(dt, st, filters, date_field)
|
||||
entries = (
|
||||
qb.from_(dt)
|
||||
.join(st)
|
||||
.on(st.parent.eq(dt.name) & st.parenttype.eq(filters["doc_type"]))
|
||||
.select(
|
||||
dt.name,
|
||||
dt.customer,
|
||||
dt.territory,
|
||||
date_field.as_("posting_date"),
|
||||
dt.base_net_total.as_("base_net_amount"),
|
||||
st.commission_rate,
|
||||
st.sales_person,
|
||||
st.allocated_percentage,
|
||||
st.allocated_amount,
|
||||
st.incentives,
|
||||
)
|
||||
.where(Criterion.all(conditions))
|
||||
.orderby(dt.name, st.sales_person)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
conditions = [""]
|
||||
values = []
|
||||
def get_conditions(dt, st, filters, date_field):
|
||||
conditions = []
|
||||
|
||||
conditions.append(dt.docstatus.eq(1))
|
||||
from_dt = filters.get("from_date")
|
||||
to_dt = filters.get("to_date")
|
||||
if from_dt and to_dt:
|
||||
conditions.append(date_field.between(from_dt, to_dt))
|
||||
elif from_dt and not to_dt:
|
||||
conditions.append(date_field.gte(from_dt))
|
||||
elif not from_dt and to_dt:
|
||||
conditions.append(date_field.lte(to_dt))
|
||||
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
conditions.append(f"dt.{field}=%s")
|
||||
values.append(filters[field])
|
||||
conditions.append(dt[field].eq(filters.get(field)))
|
||||
|
||||
if filters.get("sales_person"):
|
||||
conditions.append("st.sales_person = '{}'".format(filters.get("sales_person")))
|
||||
conditions.append(st["sales_person"].eq(filters.get("sales_person")))
|
||||
|
||||
if filters.get("from_date"):
|
||||
conditions.append(f"dt.{date_field}>=%s")
|
||||
values.append(filters["from_date"])
|
||||
|
||||
if filters.get("to_date"):
|
||||
conditions.append(f"dt.{date_field}<=%s")
|
||||
values.append(filters["to_date"])
|
||||
|
||||
return " and ".join(conditions), values
|
||||
return conditions
|
||||
|
||||
@@ -1015,6 +1015,102 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
pr.cancel()
|
||||
|
||||
def test_lcv_for_internal_transfer(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
make_landed_cost_voucher,
|
||||
)
|
||||
|
||||
prepare_data_for_internal_transfer()
|
||||
|
||||
customer = "_Test Internal Customer 2"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
item_code = make_item(
|
||||
"Test Item For LCV in Internal Transfer",
|
||||
{"has_batch_no": 1, "create_new_batch": 1, "batch_naming_series": "TEST-SBATCH.###"},
|
||||
).name
|
||||
|
||||
pr1 = make_purchase_receipt(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
rate=100,
|
||||
warehouse="Stores - TCP1",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
dn1 = create_delivery_note(
|
||||
item_code=pr1.items[0].item_code,
|
||||
company=company,
|
||||
customer=customer,
|
||||
cost_center="Main - TCP1",
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
qty=10,
|
||||
rate=500,
|
||||
warehouse="Stores - TCP1",
|
||||
target_warehouse="Work In Progress - TCP1",
|
||||
)
|
||||
|
||||
pr = make_inter_company_purchase_receipt(dn1.name)
|
||||
pr.items[0].from_warehouse = "Work In Progress - TCP1"
|
||||
pr.items[0].warehouse = "Stores - TCP1"
|
||||
pr.submit()
|
||||
|
||||
sle_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name},
|
||||
fields=["serial_and_batch_bundle", "actual_qty"],
|
||||
)
|
||||
self.assertEqual(len(sle_entries), 2)
|
||||
|
||||
inward_sabb = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"total_qty": (">", 0),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(len(inward_sabb), 1)
|
||||
|
||||
original_cost = frappe.db.get_value("Serial and Batch Bundle", inward_sabb[0], "total_amount")
|
||||
|
||||
make_landed_cost_voucher(
|
||||
company=pr.company,
|
||||
receipt_document_type="Purchase Receipt",
|
||||
receipt_document=pr.name,
|
||||
charges=100,
|
||||
distribute_charges_based_on="Qty",
|
||||
expense_account="Expenses Included In Valuation - TCP1",
|
||||
)
|
||||
|
||||
sle_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
|
||||
fields=["serial_and_batch_bundle", "actual_qty"],
|
||||
)
|
||||
self.assertEqual(len(sle_entries), 2)
|
||||
|
||||
new_inward_sabb = frappe.get_all(
|
||||
"Serial and Batch Bundle",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"total_qty": (">", 0),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertEqual(len(new_inward_sabb), 1)
|
||||
|
||||
new_cost = frappe.db.get_value("Serial and Batch Bundle", new_inward_sabb[0], "total_amount")
|
||||
self.assertEqual(new_cost, original_cost + 100)
|
||||
|
||||
self.assertTrue(new_inward_sabb[0] == inward_sabb[0])
|
||||
|
||||
def test_stock_transfer_from_purchase_receipt_with_valuation(self):
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.utils import (
|
||||
cint,
|
||||
cstr,
|
||||
flt,
|
||||
format_datetime,
|
||||
get_datetime,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
@@ -1426,22 +1427,33 @@ class SerialandBatchBundle(Document):
|
||||
if flt(available_qty, precision) < 0:
|
||||
self.throw_negative_batch(d.batch_no, available_qty, precision)
|
||||
|
||||
def throw_negative_batch(self, batch_no, available_qty, precision):
|
||||
def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None):
|
||||
from erpnext.stock.stock_ledger import NegativeStockError
|
||||
|
||||
if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"):
|
||||
return
|
||||
|
||||
date_msg = ""
|
||||
if posting_datetime:
|
||||
date_msg = " " + _("as of {0}").format(format_datetime(posting_datetime))
|
||||
|
||||
msg = _(
|
||||
"""
|
||||
The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}.
|
||||
Please add a stock quantity of {4} to proceed with this entry.
|
||||
If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed.
|
||||
However, enabling this setting may lead to negative stock in the system.
|
||||
So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate."""
|
||||
).format(
|
||||
bold(batch_no),
|
||||
bold(self.item_code),
|
||||
bold(self.warehouse),
|
||||
date_msg,
|
||||
bold(abs(flt(available_qty, precision))),
|
||||
)
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"""
|
||||
The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry."""
|
||||
).format(
|
||||
bold(batch_no),
|
||||
bold(self.item_code),
|
||||
bold(self.warehouse),
|
||||
bold(abs(flt(available_qty, precision))),
|
||||
),
|
||||
msg,
|
||||
title=_("Negative Stock Error"),
|
||||
exc=NegativeStockError,
|
||||
)
|
||||
@@ -1464,7 +1476,9 @@ class SerialandBatchBundle(Document):
|
||||
available_qty[row.batch_no] = flt(row.qty)
|
||||
|
||||
if flt(available_qty[row.batch_no], precision) < 0:
|
||||
self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision)
|
||||
self.throw_negative_batch(
|
||||
row.batch_no, available_qty[row.batch_no], precision, row.posting_datetime
|
||||
)
|
||||
|
||||
return available_qty
|
||||
|
||||
@@ -1498,17 +1512,17 @@ class SerialandBatchBundle(Document):
|
||||
def get_available_qty_from_sabb(self):
|
||||
batches = [d.batch_no for d in self.entries if d.batch_no]
|
||||
|
||||
parent = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
parent = frappe.qb.DocType("Stock Ledger Entry")
|
||||
child = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.on(parent.serial_and_batch_bundle == child.parent)
|
||||
.select(
|
||||
child.batch_no,
|
||||
child.qty,
|
||||
CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"),
|
||||
parent.posting_datetime,
|
||||
parent.creation,
|
||||
)
|
||||
.where(
|
||||
@@ -1517,16 +1531,16 @@ class SerialandBatchBundle(Document):
|
||||
& (child.batch_no.isin(batches))
|
||||
& (parent.docstatus == 1)
|
||||
& (parent.is_cancelled == 0)
|
||||
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
|
||||
)
|
||||
.for_update()
|
||||
)
|
||||
|
||||
query = query.where(parent.voucher_type != "Pick List")
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def validate_voucher_no_docstatus(self):
|
||||
if self.is_cancelled:
|
||||
return
|
||||
|
||||
if self.voucher_type == "POS Invoice":
|
||||
return
|
||||
|
||||
|
||||
@@ -542,7 +542,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.",
|
||||
"description": "If enabled, the system will allow negative stock entries for the batch. But, this may lead to incorrect valuation rates, so it is recommended to avoid using this option. The system will permit negative stock only when it is caused by backdated entries and will validate and block negative stock in all other cases.",
|
||||
"fieldname": "allow_negative_stock_for_batch",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Negative Stock for Batch"
|
||||
@@ -553,7 +553,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-09 15:01:12.466175",
|
||||
"modified": "2026-02-16 10:36:59.921491",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Negative Batch Report"] = {
|
||||
filters: [
|
||||
{
|
||||
fieldname: "company",
|
||||
label: __("Company"),
|
||||
fieldtype: "Link",
|
||||
options: "Company",
|
||||
default: frappe.defaults.get_default("company"),
|
||||
},
|
||||
{
|
||||
fieldname: "item_code",
|
||||
label: __("Item Code"),
|
||||
fieldtype: "Link",
|
||||
options: "Item",
|
||||
get_query: function () {
|
||||
return {
|
||||
filters: {
|
||||
has_batch_no: 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
get_query: function () {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"add_translate_data": 0,
|
||||
"columns": [],
|
||||
"creation": "2026-02-17 11:34:21.549485",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"json": "",
|
||||
"letter_head": null,
|
||||
"modified": "2026-02-17 11:34:59.106045",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Negative Batch Report",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Serial and Batch Bundle",
|
||||
"report_name": "Negative Batch Report",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
},
|
||||
{
|
||||
"role": "Purchase User"
|
||||
},
|
||||
{
|
||||
"role": "Purchase Manager"
|
||||
},
|
||||
{
|
||||
"role": "Stock User"
|
||||
},
|
||||
{
|
||||
"role": "Stock Manager"
|
||||
},
|
||||
{
|
||||
"role": "Delivery User"
|
||||
},
|
||||
{
|
||||
"role": "Delivery Manager"
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing User"
|
||||
},
|
||||
{
|
||||
"role": "Manufacturing Manager"
|
||||
}
|
||||
],
|
||||
"timeout": 0
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_to_date, flt, today
|
||||
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute
|
||||
|
||||
|
||||
def execute(filters: dict | None = None):
|
||||
"""Return columns and data for the report.
|
||||
|
||||
This is the main entry point for the report. It accepts the filters as a
|
||||
dictionary and should return columns and data. It is called by the framework
|
||||
every time the report is refreshed or a filter is updated.
|
||||
"""
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"label": _("Posting Datetime"),
|
||||
"fieldname": "posting_date",
|
||||
"fieldtype": "Datetime",
|
||||
"width": 160,
|
||||
},
|
||||
{
|
||||
"label": _("Batch No"),
|
||||
"fieldname": "batch_no",
|
||||
"fieldtype": "Link",
|
||||
"options": "Batch",
|
||||
"width": 120,
|
||||
},
|
||||
{
|
||||
"label": _("Item Code"),
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Warehouse"),
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"options": "Warehouse",
|
||||
"width": 160,
|
||||
},
|
||||
{
|
||||
"label": _("Previous Qty"),
|
||||
"fieldname": "previous_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"label": _("Transaction Qty"),
|
||||
"fieldname": "actual_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"label": _("Qty After Transaction"),
|
||||
"fieldname": "qty_after_transaction",
|
||||
"fieldtype": "Float",
|
||||
"width": 180,
|
||||
},
|
||||
{
|
||||
"label": _("Document Type"),
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Data",
|
||||
"width": 130,
|
||||
},
|
||||
{
|
||||
"label": _("Document No"),
|
||||
"fieldname": "voucher_no",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"options": "voucher_type",
|
||||
"width": 130,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_data(filters) -> list[dict]:
|
||||
batches = get_batches(filters)
|
||||
companies = get_companies(filters)
|
||||
batch_negative_data = []
|
||||
|
||||
flt_precision = frappe.db.get_default("float_precision") or 2
|
||||
for company in companies:
|
||||
for batch in batches:
|
||||
_c, data = stock_ledger_execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"batch_no": batch,
|
||||
"from_date": add_to_date(today(), years=-12),
|
||||
"to_date": today(),
|
||||
"segregate_serial_batch_bundle": 1,
|
||||
"warehouse": filters.get("warehouse"),
|
||||
"valuation_field_type": "Currency",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
previous_qty = 0
|
||||
for row in data:
|
||||
if flt(row.get("qty_after_transaction"), flt_precision) < 0:
|
||||
batch_negative_data.append(
|
||||
{
|
||||
"posting_date": row.get("date"),
|
||||
"batch_no": row.get("batch_no"),
|
||||
"item_code": row.get("item_code"),
|
||||
"item_name": row.get("item_name"),
|
||||
"warehouse": row.get("warehouse"),
|
||||
"actual_qty": row.get("actual_qty"),
|
||||
"qty_after_transaction": row.get("qty_after_transaction"),
|
||||
"previous_qty": previous_qty,
|
||||
"voucher_type": row.get("voucher_type"),
|
||||
"voucher_no": row.get("voucher_no"),
|
||||
}
|
||||
)
|
||||
|
||||
previous_qty = row.get("qty_after_transaction")
|
||||
|
||||
return batch_negative_data
|
||||
|
||||
|
||||
def get_batches(filters):
|
||||
batch_filters = {}
|
||||
if filters.get("item_code"):
|
||||
batch_filters["item"] = filters["item_code"]
|
||||
|
||||
return frappe.get_all("Batch", pluck="name", filters=batch_filters)
|
||||
|
||||
|
||||
def get_companies(filters):
|
||||
company_filters = {}
|
||||
if filters.get("company"):
|
||||
company_filters["name"] = filters["company"]
|
||||
|
||||
return frappe.get_all("Company", pluck="name", filters=company_filters)
|
||||
Reference in New Issue
Block a user