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

This commit is contained in:
Mihir Kandoi
2025-12-23 22:10:38 +05:30
committed by GitHub
23 changed files with 931 additions and 124 deletions

View File

@@ -1520,18 +1520,14 @@ frappe.ui.form.on("Payment Entry", {
"Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'" "Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"
); );
d.row_id = ""; d.row_id = "";
} else if ( } else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
d.row_id
) {
if (d.idx == 1) { if (d.idx == 1) {
msg = __( msg = __(
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
); );
d.charge_type = ""; d.charge_type = "";
} else if (!d.row_id) { } else if (!d.row_id) {
msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); d.row_id = d.idx - 1;
d.row_id = "";
} else if (d.row_id && d.row_id >= d.idx) { } else if (d.row_id && d.row_id >= d.idx) {
msg = __( msg = __(
"Cannot refer row number greater than or equal to current row number for this Charge type" "Cannot refer row number greater than or equal to current row number for this Charge type"

View File

@@ -362,21 +362,34 @@ class SalesInvoice(SellingController):
validate_docs_for_deferred_accounting([self.name], []) validate_docs_for_deferred_accounting([self.name], [])
def validate_fixed_asset(self): def validate_fixed_asset(self):
for d in self.get("items"): if self.doctype != "Sales Invoice":
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: return
asset = frappe.get_doc("Asset", d.asset)
if self.doctype == "Sales Invoice" and self.docstatus == 1:
if self.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset.status in ("Scrapped", "Cancelled", "Capitalized") or ( for d in self.get("items"):
asset.status == "Sold" and not self.is_return if d.is_fixed_asset:
): if d.asset:
frappe.throw( if not self.is_return:
_("Row #{0}: Asset {1} cannot be submitted, it is already {2}").format( asset_status = frappe.db.get_value("Asset", d.asset, "status")
d.idx, d.asset, asset.status if self.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
d.idx, d.asset, asset_status
)
) )
elif asset_status == "Sold" and not self.is_return:
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
elif not self.return_against:
frappe.throw(
_("Row #{0}: Return Against is required for returning asset").format(d.idx)
) )
else:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
title=_("Missing Asset"),
)
def validate_item_cost_centers(self): def validate_item_cost_centers(self):
for item in self.items: for item in self.items:
@@ -465,6 +478,8 @@ class SalesInvoice(SellingController):
self.update_stock_reservation_entries() self.update_stock_reservation_entries()
self.update_stock_ledger() self.update_stock_ledger()
self.process_asset_depreciation()
# this sequence because outstanding may get -ve # this sequence because outstanding may get -ve
self.make_gl_entries() self.make_gl_entries()
@@ -561,6 +576,8 @@ class SalesInvoice(SellingController):
if self.update_stock == 1: if self.update_stock == 1:
self.update_stock_ledger() self.update_stock_ledger()
self.process_asset_depreciation()
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
if self.update_stock == 1: if self.update_stock == 1:
@@ -1182,6 +1199,91 @@ class SalesInvoice(SellingController):
): ):
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
def process_asset_depreciation(self):
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
self.depreciate_asset_on_sale()
else:
self.restore_asset()
self.update_asset()
def depreciate_asset_on_sale(self):
"""
Depreciate asset on sale or cancellation of return sales invoice
"""
disposal_date = self.get_disposal_date()
for d in self.get("items"):
if d.asset:
asset = frappe.get_doc("Asset", d.asset)
if asset.calculate_depreciation and asset.status != "Fully Depreciated":
depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset))
def get_note_for_asset_sale(self, asset):
return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
get_link_to_form(asset.doctype, asset.name),
_("returned") if self.is_return else _("sold"),
get_link_to_form(self.doctype, self.get("name")),
)
def restore_asset(self):
"""
Restore asset on return or cancellation of original sales invoice
"""
for d in self.get("items"):
if d.asset:
asset = frappe.get_cached_doc("Asset", d.asset)
if asset.calculate_depreciation:
posting_date = self.get_disposal_date()
reverse_depreciation_entry_made_after_disposal(asset, posting_date)
note = self.get_note_for_asset_return(asset)
reset_depreciation_schedule(asset, self.posting_date, note)
def get_note_for_asset_return(self, asset):
asset_link = get_link_to_form(asset.doctype, asset.name)
invoice_link = get_link_to_form(self.doctype, self.get("name"))
if self.is_return:
return _(
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
).format(asset_link, invoice_link)
else:
return _(
"This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
).format(asset_link, invoice_link)
def update_asset(self):
"""
Update asset status, disposal date and asset activity on sale or return sales invoice
"""
def _update_asset(asset, disposal_date, note, asset_status=None):
frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
add_asset_activity(asset.name, note)
asset.set_status(asset_status)
disposal_date = self.get_disposal_date()
for d in self.get("items"):
if d.asset:
asset = frappe.get_cached_doc("Asset", d.asset)
if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2):
note = _("Asset returned") if self.is_return else _("Asset sold")
asset_status, disposal_date = None, None
else:
note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled")
asset_status = "Sold"
_update_asset(asset, disposal_date, note, asset_status)
def get_disposal_date(self):
if self.is_return:
disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
else:
disposal_date = self.posting_date
return disposal_date
def make_gl_entries(self, gl_entries=None, from_repost=False): def make_gl_entries(self, gl_entries=None, from_repost=False):
from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
@@ -1358,68 +1460,8 @@ class SalesInvoice(SellingController):
if self.is_internal_transfer(): if self.is_internal_transfer():
continue continue
if item.is_fixed_asset: if item.is_fixed_asset and item.asset:
asset = self.get_asset(item) self.get_gl_entries_for_fixed_asset(item, gl_entries)
if (self.docstatus == 2 and not self.is_return) or (
self.docstatus == 1 and self.is_return
):
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
asset,
item.base_net_amount,
item.finance_book,
self.get("doctype"),
self.get("name"),
self.get("posting_date"),
)
asset.db_set("disposal_date", None)
add_asset_activity(asset.name, _("Asset returned"))
asset_status = asset.get_status()
if asset.calculate_depreciation and not asset_status == "Fully Depreciated":
posting_date = (
frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
if self.is_return
else self.posting_date
)
reverse_depreciation_entry_made_after_disposal(asset, posting_date)
notes = _(
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
).format(
get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")),
)
reset_depreciation_schedule(asset, self.posting_date, notes)
asset.reload()
else:
if asset.calculate_depreciation:
if not asset.status == "Fully Depreciated":
notes = _(
"This schedule was created when Asset {0} was sold through Sales Invoice {1}."
).format(
get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")),
)
depreciate_asset(asset, self.posting_date, notes)
asset.reload()
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
asset,
item.base_net_amount,
item.finance_book,
self.get("doctype"),
self.get("name"),
self.get("posting_date"),
)
asset.db_set("disposal_date", self.posting_date)
add_asset_activity(asset.name, _("Asset sold"))
for gle in fixed_asset_gl_entries:
gle["against"] = self.customer
gl_entries.append(self.get_gl_dict(gle, item=item))
self.set_asset_status(asset)
else: else:
income_account = ( income_account = (
@@ -1455,17 +1497,31 @@ class SalesInvoice(SellingController):
if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company): if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company):
gl_entries += super().get_gl_entries() gl_entries += super().get_gl_entries()
def get_asset(self, item): def get_gl_entries_for_fixed_asset(self, item, gl_entries):
if item.get("asset"): asset = frappe.get_cached_doc("Asset", item.asset)
asset = frappe.get_doc("Asset", item.asset)
if self.is_return:
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
asset,
item.base_net_amount,
item.finance_book,
self.get("doctype"),
self.get("name"),
self.get("posting_date"),
)
else: else:
frappe.throw( fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
_("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_name), asset,
title=_("Missing Asset"), item.base_net_amount,
item.finance_book,
self.get("doctype"),
self.get("name"),
self.get("posting_date"),
) )
self.check_finance_books(item, asset) for gle in fixed_asset_gl_entries:
return asset gle["against"] = self.customer
gl_entries.append(self.get_gl_dict(gle, item=item))
@property @property
def enable_discount_accounting(self): def enable_discount_accounting(self):

View File

@@ -80,6 +80,12 @@ frappe.ui.form.on("Asset", {
} }
}, },
before_submit: function (frm) {
if (frm.doc.is_composite_asset && !frm.has_active_capitalization) {
frappe.throw(__("Please capitalize this asset before submitting."));
}
},
refresh: function (frm) { refresh: function (frm) {
frappe.ui.form.trigger("Asset", "is_existing_asset"); frappe.ui.form.trigger("Asset", "is_existing_asset");
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1); frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
@@ -200,9 +206,10 @@ frappe.ui.form.on("Asset", {
asset: frm.doc.name, asset: frm.doc.name,
}, },
callback: function (r) { callback: function (r) {
frm.has_active_capitalization = r.message;
if (!r.message) { if (!r.message) {
$(".primary-action").prop("hidden", true); $(".form-message").text(__("Capitalize this asset before submitting."));
$(".form-message").text(__("Capitalize this asset to confirm"));
frm.add_custom_button(__("Capitalize Asset"), function () { frm.add_custom_button(__("Capitalize Asset"), function () {
frm.trigger("create_asset_capitalization"); frm.trigger("create_asset_capitalization");
@@ -274,8 +281,14 @@ frappe.ui.form.on("Asset", {
const row = [ const row = [
sch["idx"], sch["idx"],
frappe.format(sch["schedule_date"], { fieldtype: "Date" }), frappe.format(sch["schedule_date"], { fieldtype: "Date" }),
frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }), frappe.format(sch["depreciation_amount"], {
frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }), fieldtype: "Currency",
options: "Company:company:default_currency",
}),
frappe.format(sch["accumulated_depreciation_amount"], {
fieldtype: "Currency",
options: "Company:company:default_currency",
}),
sch["journal_entry"] || "", sch["journal_entry"] || "",
]; ];
@@ -468,7 +481,6 @@ frappe.ui.form.on("Asset", {
is_composite_asset: function (frm) { is_composite_asset: function (frm) {
if (frm.doc.is_composite_asset) { if (frm.doc.is_composite_asset) {
frm.set_value("gross_purchase_amount", 0); frm.set_value("gross_purchase_amount", 0);
frm.set_df_property("gross_purchase_amount", "read_only", 1);
} else { } else {
frm.set_df_property("gross_purchase_amount", "read_only", 0); frm.set_df_property("gross_purchase_amount", "read_only", 0);
} }
@@ -536,7 +548,6 @@ frappe.ui.form.on("Asset", {
callback: function (r) { callback: function (r) {
var doclist = frappe.model.sync(r.message); var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name); frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
$(".primary-action").prop("hidden", false);
}, },
}); });
}, },

View File

@@ -229,7 +229,8 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Net Purchase Amount", "label": "Net Purchase Amount",
"mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)", "mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)",
"options": "Company:company:default_currency" "options": "Company:company:default_currency",
"read_only_depends_on": "eval: doc.is_composite_asset"
}, },
{ {
"fieldname": "available_for_use_date", "fieldname": "available_for_use_date",
@@ -596,7 +597,7 @@
"link_fieldname": "target_asset" "link_fieldname": "target_asset"
} }
], ],
"modified": "2025-11-17 18:01:51.417942", "modified": "2025-12-23 16:01:10.195932",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",

View File

@@ -69,7 +69,6 @@ class Asset(AccountsController):
default_finance_book: DF.Link | None default_finance_book: DF.Link | None
department: DF.Link | None department: DF.Link | None
depr_entry_posting_status: DF.Literal["", "Successful", "Failed"] depr_entry_posting_status: DF.Literal["", "Successful", "Failed"]
depreciation_completed: DF.Check
depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"] depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"]
disposal_date: DF.Date | None disposal_date: DF.Date | None
finance_books: DF.Table[AssetFinanceBook] finance_books: DF.Table[AssetFinanceBook]
@@ -159,6 +158,10 @@ class Asset(AccountsController):
self.total_asset_cost = self.gross_purchase_amount + self.additional_asset_cost self.total_asset_cost = self.gross_purchase_amount + self.additional_asset_cost
self.status = self.get_status() self.status = self.get_status()
def before_submit(self):
if self.is_composite_asset and not has_active_capitalization(self.name):
frappe.throw(_("Please capitalize this asset before submitting."))
def on_submit(self): def on_submit(self):
self.validate_in_use_date() self.validate_in_use_date()
self.make_asset_movement() self.make_asset_movement()

View File

@@ -197,6 +197,13 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
} }
} }
serial_and_batch_bundle(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
if (cdt === "Asset Capitalization Stock Item") {
this.get_warehouse_details(row);
}
}
asset(doc, cdt, cdn) { asset(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn); var row = frappe.get_doc(cdt, cdn);
if (cdt === "Asset Capitalization Asset Item") { if (cdt === "Asset Capitalization Asset Item") {
@@ -410,6 +417,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
voucher_type: me.frm.doc.doctype, voucher_type: me.frm.doc.doctype,
voucher_no: me.frm.doc.name, voucher_no: me.frm.doc.name,
allow_zero_valuation: 1, allow_zero_valuation: 1,
serial_and_batch_bundle: item.serial_and_batch_bundle,
}, },
}, },
callback: function (r) { callback: function (r) {

View File

@@ -363,6 +363,7 @@ class AssetCapitalization(StockController):
"voucher_no": self.name, "voucher_no": self.name,
"company": self.company, "company": self.company,
"allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")), "allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")),
"serial_and_batch_bundle": item.serial_and_batch_bundle,
} }
) )
@@ -763,6 +764,7 @@ def get_consumed_stock_item_details(args):
"company": args.company, "company": args.company,
"serial_no": args.serial_no, "serial_no": args.serial_no,
"batch_no": args.batch_no, "batch_no": args.batch_no,
"serial_and_batch_bundle": args.serial_and_batch_bundle,
} }
) )
out.update(get_warehouse_details(incoming_rate_args)) out.update(get_warehouse_details(incoming_rate_args))

View File

@@ -555,7 +555,10 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
doctype: "Supplier", doctype: "Supplier",
order_by: "name", order_by: "name",
fields: ["name"], fields: ["name"],
filters: [["Supplier", "supplier_group", "=", args.supplier_group]], filters: [
["Supplier", "supplier_group", "=", args.supplier_group],
["disabled", "=", 0],
],
}, },
callback: load_suppliers, callback: load_suppliers,
}); });

View File

@@ -369,6 +369,15 @@ class ProductionPlan(Document):
pi = frappe.qb.DocType("Packed Item") pi = frappe.qb.DocType("Packed Item")
pending_qty = (
frappe.qb.terms.Case()
.when(
(so_item.work_order_qty > so_item.delivered_qty),
(((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty),
)
.else_(((so_item.qty - so_item.delivered_qty) * pi.qty) / so_item.qty)
)
packed_items_query = ( packed_items_query = (
frappe.qb.from_(so_item) frappe.qb.from_(so_item)
.from_(pi) .from_(pi)
@@ -376,7 +385,7 @@ class ProductionPlan(Document):
pi.parent, pi.parent,
pi.item_code, pi.item_code,
pi.warehouse.as_("warehouse"), pi.warehouse.as_("warehouse"),
(((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty).as_("pending_qty"), pending_qty.as_("pending_qty"),
pi.parent_item, pi.parent_item,
pi.description, pi.description,
so_item.name, so_item.name,
@@ -387,7 +396,16 @@ class ProductionPlan(Document):
& (so_item.docstatus == 1) & (so_item.docstatus == 1)
& (pi.parent_item == so_item.item_code) & (pi.parent_item == so_item.item_code)
& (so_item.parent.isin(so_list)) & (so_item.parent.isin(so_list))
& (so_item.qty > so_item.work_order_qty) & (
(
(so_item.work_order_qty > so_item.delivered_qty)
& (so_item.qty > so_item.work_order_qty)
)
| (
(so_item.work_order_qty <= so_item.delivered_qty)
& (so_item.qty > so_item.delivered_qty)
)
)
& ( & (
ExistsCriterion( ExistsCriterion(
frappe.qb.from_(bom) frappe.qb.from_(bom)
@@ -1303,14 +1321,21 @@ def get_material_request_items(
include_safety_stock, include_safety_stock,
warehouse, warehouse,
bin_dict, bin_dict,
consumed_qty,
): ):
total_qty = row["qty"]
required_qty = 0 required_qty = 0
item_code = row.get("item_code")
if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = total_qty required_qty = flt(row.get("qty"))
elif total_qty > bin_dict.get("projected_qty", 0): else:
required_qty = total_qty - bin_dict.get("projected_qty", 0) key = (item_code, warehouse)
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
if available_qty > 0:
required_qty = max(0, flt(row.get("qty")) - available_qty)
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
else:
required_qty = flt(row.get("qty"))
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
required_qty = row["min_order_qty"] required_qty = row["min_order_qty"]
@@ -1354,7 +1379,7 @@ def get_material_request_items(
"item_name": row.item_name, "item_name": row.item_name,
"quantity": required_qty / conversion_factor, "quantity": required_qty / conversion_factor,
"conversion_factor": conversion_factor, "conversion_factor": conversion_factor,
"required_bom_qty": total_qty, "required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"), "stock_uom": row.get("stock_uom"),
"warehouse": warehouse "warehouse": warehouse
or row.get("source_warehouse") or row.get("source_warehouse")
@@ -1648,9 +1673,12 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
so_item_details[sales_order][item_code] = details so_item_details[sales_order][item_code] = details
mr_items = [] mr_items = []
consumed_qty = defaultdict(float)
for sales_order in so_item_details: for sales_order in so_item_details:
item_dict = so_item_details[sales_order] item_dict = so_item_details[sales_order]
for details in item_dict.values(): for details in item_dict.values():
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = get_bin_details(details, doc.company, warehouse)
bin_dict = bin_dict[0] if bin_dict else {} bin_dict = bin_dict[0] if bin_dict else {}
@@ -1664,6 +1692,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
include_safety_stock, include_safety_stock,
warehouse, warehouse,
bin_dict, bin_dict,
consumed_qty,
) )
if items: if items:
mr_items.append(items) mr_items.append(items)

View File

@@ -145,6 +145,84 @@ class TestProductionPlan(FrappeTestCase):
sr2.cancel() sr2.cancel()
pln.cancel() pln.cancel()
def test_projected_qty_cascading_across_multiple_sales_orders(self):
rm_item = make_item(
"_Test RM For Cascading",
{"is_stock_item": 1, "valuation_rate": 100},
).name
fg_item_a = make_item(
"_Test FG A For Cascading",
{"is_stock_item": 1, "valuation_rate": 200},
).name
if not frappe.db.exists("BOM", {"item": fg_item_a, "docstatus": 1}):
make_bom(item=fg_item_a, raw_materials=[rm_item], rm_qty=1)
# Stock for RM
sr = create_stock_reconciliation(item_code=rm_item, target="_Test Warehouse - _TC", qty=1, rate=100)
# Sales orders
so1 = make_sales_order(item_code=fg_item_a, qty=1)
so2 = make_sales_order(item_code=fg_item_a, qty=1)
so3 = make_sales_order(item_code=fg_item_a, qty=1)
# Production plan
pln = frappe.get_doc(
{
"doctype": "Production Plan",
"company": "_Test Company",
"posting_date": nowdate(),
"get_items_from": "Sales Order",
"ignore_existing_ordered_qty": 0,
}
)
pln.append(
"sales_orders",
{
"sales_order": so1.name,
"sales_order_date": so1.transaction_date,
"customer": so1.customer,
"grand_total": so1.grand_total,
},
)
pln.append(
"sales_orders",
{
"sales_order": so2.name,
"sales_order_date": so2.transaction_date,
"customer": so2.customer,
"grand_total": so2.grand_total,
},
)
pln.append(
"sales_orders",
{
"sales_order": so3.name,
"sales_order_date": so3.transaction_date,
"customer": so3.customer,
"grand_total": so3.grand_total,
},
)
pln.get_items()
pln.insert()
mr_items = get_items_for_material_requests(pln.as_dict())
quantities = [d["quantity"] for d in mr_items]
rm_qty = sum(quantities)
# Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
self.assertEqual(len(mr_items), 2)
self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
self.assertEqual(
quantities,
[1, 1],
"Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
)
sr.cancel()
def test_production_plan_with_non_stock_item(self): def test_production_plan_with_non_stock_item(self):
"Test if MR Planning table includes Non Stock RM." "Test if MR Planning table includes Non Stock RM."
pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1) pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1)
@@ -678,6 +756,109 @@ class TestProductionPlan(FrappeTestCase):
frappe.db.rollback() frappe.db.rollback()
def test_get_sales_order_items_for_product_bundle(self):
"""Testing the Planned Qty for Product Bundle Item"""
from erpnext.manufacturing.doctype.work_order.test_work_order import (
make_stock_entry as create_stock_entry,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import (
make_wo_order_test_record,
)
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# 1. Create required items
bundle_item = create_item(item_code="Bundle Item", is_stock_item=0)
bom_item = create_item(item_code="BOM Item")
rm_item = create_item(item_code="RM Item")
fg_warehouse = "_Test FG Warehouse - _TC"
# Create warehouse if it doesn't exist
if not frappe.db.exists("Warehouse", fg_warehouse):
create_warehouse(warehouse_name="_Test FG Warehouse")
# 2. Create initial stock for components
make_stock_entry(item_code=bom_item.name, target="_Test FG Warehouse - _TC", qty=15)
make_stock_entry(item_code=rm_item.name, target="Stores - _TC", qty=25)
# 3. Create BOM for manufactured item
bom = make_bom(
item=bom_item.name,
raw_materials=[rm_item.name],
set_as_default_bom=1,
)
# 4. Create Product Bundle (Bundle Item → contains BOM Item)
make_product_bundle(parent=bundle_item.name, items=[bom_item.name])
# 5. Create Sales Order for 50 units of Bundle Item
sales_order = make_sales_order(item_code=bundle_item.name, qty=50, warehouse=fg_warehouse)
# 6. Create Work Order for partial quantity (25 out of 50)
work_order_qty = 25
work_order = make_wo_order_test_record(
production_item=bom_item.name,
bom_no=bom.name,
qty=work_order_qty,
sales_order=sales_order.name,
source_warehouse="Stores - _TC",
fg_warehouse=fg_warehouse,
do_not_save=1,
)
# Link Work Order to correct Sales Order Item row
work_order.sales_order_item = sales_order.items[0].name
work_order.save()
work_order.submit()
# 7. Material transfer from Stores → WIP
transfer_entry = frappe.get_doc(
create_stock_entry(work_order.name, "Material Transfer for Manufacture")
)
for d in transfer_entry.get("items"):
d.s_warehouse = "Stores - _TC"
transfer_entry.insert()
transfer_entry.submit()
# 8. Complete manufacturing (WIP → Finished Goods)
manufacture_entry = frappe.get_doc(create_stock_entry(work_order.name, "Manufacture"))
manufacture_entry.insert()
manufacture_entry.submit()
# 9. Verify work order qty is correctly updated in Sales Order
sales_order.reload()
self.assertEqual(sales_order.items[0].work_order_qty, work_order_qty)
# 10. Create partial Delivery Note (40 out of 50)
dn = make_delivery_note(sales_order.name)
dn.items[0].qty = 40
dn.save()
dn.submit()
# 11. Check delivered quantity updated correctly
sales_order.reload()
self.assertEqual(sales_order.items[0].delivered_qty, 40)
# 12. Create Production Plan from remaining open Sales Order quantity
pln = frappe.new_doc("Production Plan")
pln.company = sales_order.company
pln.get_items_from = "Sales Order"
pln.item_code = bundle_item.name
# Fetch open sales orders
pln.get_open_sales_orders()
self.assertEqual(pln.sales_orders[0].sales_order, sales_order.name)
# Pull items → should plan remaining 10 qty
pln.get_so_items()
"""
Test Case: Production Plan should plan remaining 10 units
(50 ordered - 25 manufactured - 40 delivered = 10 pending)
"""
self.assertEqual(pln.po_items[0].planned_qty, 10)
def test_multiple_work_order_for_production_plan_item(self): def test_multiple_work_order_for_production_plan_item(self):
"Test producing Prod Plan (making WO) in parts." "Test producing Prod Plan (making WO) in parts."

View File

@@ -2467,6 +2467,259 @@ class TestWorkOrder(FrappeTestCase):
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
) )
def test_disassembly_with_multiple_manufacture_entries(self):
"""
Test that disassembly does not create duplicate items when manufacturing
is done in multiple batches (multiple manufacture stock entries).
Scenario:
1. Create Work Order for 10 units
2. Transfer raw materials
3. Manufacture in 2 parts (3 units, then 7 units) - creates 2 stock entries
4. Create Disassembly for 4 units
5. Verify no duplicate items in the disassembly stock entry
"""
# Create RM and FG item
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
# Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
# Ensure enough stock
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
make_stock_entry_test_record(
item_code=raw_item1,
purpose="Material Receipt",
target=wo.wip_warehouse,
qty=50,
basic_rate=100,
)
make_stock_entry_test_record(
item_code=raw_item2,
purpose="Material Receipt",
target=wo.wip_warehouse,
qty=50,
basic_rate=100,
)
# Transfer for manufacture
se_for_material_transfer = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
)
for item in se_for_material_transfer.items:
item.s_warehouse = wo.wip_warehouse
se_for_material_transfer.save()
se_for_material_transfer.submit()
# First Manufacture Entry - 3 units
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
se_manufacture1.submit()
# Second Manufacture Entry - 7 units
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
se_manufacture2.submit()
wo.reload()
self.assertEqual(wo.produced_qty, 10)
# Count manufacture entries
manufacture_entries = frappe.get_all(
"Stock Entry",
filters={
"work_order": wo.name,
"purpose": "Manufacture",
"docstatus": 1,
},
)
self.assertEqual(len(manufacture_entries), 2, "Expected 2 manufacture entries")
# Disassembly for 4 units
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.save()
stock_entry.submit()
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
item_counts[item_code] = item_counts.get(item_code, 0) + 1
# No duplicates
duplicates = {k: v for k, v in item_counts.items() if v > 1}
self.assertEqual(
len(duplicates),
0,
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
expected_items = 3 # FG item + 2 raw materials
self.assertEqual(
len(stock_entry.items),
expected_items,
f"Expected {expected_items} items, found {len(stock_entry.items)}",
)
# FG item qty
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
# RM quantities
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
self.assertAlmostEqual(
rm_row.qty,
expected_qty,
places=3,
msg=f"Raw material {bom_item.item_code} qty mismatch",
)
def test_disassembly_with_additional_rm_not_in_bom(self):
"""
Test that disassembly correctly handles additional raw materials that were
manually added during manufacturing (not part of the BOM).
Scenario:
1. Create Work Order for 10 units with 2 raw materials in BOM
2. Transfer raw materials for manufacture
3. Manufacture in 2 parts (3 units, then 7 units)
4. In each manufacture entry, manually add an extra consumable item
(not in BOM) in proportion to the manufactured qty
5. Create Disassembly for 4 units
6. Verify that the additional RM is included in disassembly with proportional qty
"""
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
# Create RM and FG item
raw_item1 = make_item("Test BOM Raw 1 for Additional RM Disassembly", {"is_stock_item": 1}).name
raw_item2 = make_item("Test BOM Raw 2 for Additional RM Disassembly", {"is_stock_item": 1}).name
additional_rm = make_item("Test Additional RM for Disassembly", {"is_stock_item": 1}).name
fg_item = make_item("Test FG for Additional RM Disassembly", {"is_stock_item": 1}).name
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
# Create WO
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
# Ensure enough stock
for item in [raw_item1, raw_item2, additional_rm]:
make_stock_entry_test_record(
item_code=item,
purpose="Material Receipt",
target=wo.wip_warehouse,
qty=100,
basic_rate=100,
)
# Transfer for manufacture
se_for_material_transfer = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
)
for item in se_for_material_transfer.items:
item.s_warehouse = wo.wip_warehouse
se_for_material_transfer.save()
se_for_material_transfer.submit()
# First Manufacture Entry - 3 units
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
# Additional RM
se_manufacture1.append(
"items",
{
"item_code": additional_rm,
"qty": 3, # 1 per unit
"s_warehouse": wo.wip_warehouse,
"is_finished_item": 0,
},
)
se_manufacture1.save()
se_manufacture1.submit()
# Second Manufacture Entry - 7 units
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
# AAdditional RM
se_manufacture2.append(
"items",
{
"item_code": additional_rm,
"qty": 7, # 1 per unit
"s_warehouse": wo.wip_warehouse,
"is_finished_item": 0,
},
)
se_manufacture2.save()
se_manufacture2.submit()
wo.reload()
self.assertEqual(wo.produced_qty, 10)
# Disassembly for 4 units
disassemble_qty = 4
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
stock_entry.save()
stock_entry.submit()
# No duplicate
item_counts = {}
for item in stock_entry.items:
item_code = item.item_code
item_counts[item_code] = item_counts.get(item_code, 0) + 1
duplicates = {k: v for k, v in item_counts.items() if v > 1}
self.assertEqual(
len(duplicates),
0,
f"Found duplicate items in disassembly stock entry: {duplicates}",
)
# Additional RM qty
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
self.assertIsNotNone(
additional_rm_row,
f"Additional raw material {additional_rm} not found in disassembly",
)
# intentional full reversal as not part of BOM
# eg: dies or consumables used during manufacturing
expected_additional_rm_qty = 3 + 7
self.assertAlmostEqual(
additional_rm_row.qty,
expected_additional_rm_qty,
places=3,
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
)
# RM qty
for bom_item in bom.items:
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
self.assertIsNotNone(rm_row, f"BOM raw material {bom_item.item_code} not found")
self.assertAlmostEqual(
rm_row.qty,
expected_qty,
places=3,
msg=f"BOM raw material {bom_item.item_code} qty mismatch",
)
# FG qty
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
self.assertEqual(fg_item_row.qty, disassemble_qty)
expected_items = 4
self.assertEqual(
len(stock_entry.items),
expected_items,
f"Expected {expected_items} items, found {len(stock_entry.items)}",
)
def test_components_alternate_item_for_bom_based_manufacture_entry(self): def test_components_alternate_item_for_bom_based_manufacture_entry(self):
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)

View File

@@ -2,6 +2,15 @@ import frappe
def execute(): def execute():
try:
from erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter import execute
execute()
except ImportError:
update_frankfurter_app_parameter_and_result()
def update_frankfurter_app_parameter_and_result():
settings = frappe.get_doc("Currency Exchange Settings") settings = frappe.get_doc("Currency Exchange Settings")
if settings.service_provider != "frankfurter.app": if settings.service_provider != "frankfurter.app":
return return

View File

@@ -11,7 +11,7 @@ from frappe.cache_manager import clear_defaults_cache
from frappe.contacts.address_and_contact import load_address_and_contact from frappe.contacts.address_and_contact import load_address_and_contact
from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.page.setup_wizard.setup_wizard import make_records from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import cint, formatdate, get_link_to_form, get_timestamp, today from frappe.utils import add_months, cint, formatdate, get_first_day, get_link_to_form, get_timestamp, today
from frappe.utils.nestedset import NestedSet, rebuild_tree from frappe.utils.nestedset import NestedSet, rebuild_tree
from erpnext.accounts.doctype.account.account import get_account_currency from erpnext.accounts.doctype.account.account import get_account_currency
@@ -762,27 +762,29 @@ def install_country_fixtures(company, country):
def update_company_current_month_sales(company): def update_company_current_month_sales(company):
current_month_year = formatdate(today(), "MM-yyyy") from_date = get_first_day(today())
to_date = get_first_day(add_months(from_date, 1))
results = frappe.db.sql( results = frappe.db.sql(
f""" """
SELECT SELECT
SUM(base_grand_total) AS total, SUM(base_grand_total) AS total,
DATE_FORMAT(`posting_date`, '%m-%Y') AS month_year DATE_FORMAT(posting_date, '%%m-%%Y') AS month_year
FROM FROM
`tabSales Invoice` `tabSales Invoice`
WHERE WHERE
DATE_FORMAT(`posting_date`, '%m-%Y') = '{current_month_year}' posting_date >= %s
AND posting_date < %s
AND docstatus = 1 AND docstatus = 1
AND company = {frappe.db.escape(company)} AND company = %s
GROUP BY GROUP BY
month_year month_year
""", """,
(from_date, to_date, company),
as_dict=True, as_dict=True,
) )
monthly_total = results[0]["total"] if len(results) > 0 else 0 monthly_total = results[0]["total"] if len(results) > 0 else 0
frappe.db.set_value("Company", company, "total_monthly_sales", monthly_total) frappe.db.set_value("Company", company, "total_monthly_sales", monthly_total)

View File

@@ -241,6 +241,7 @@ class SerialandBatchBundle(Document):
"check_serial_nos": True, "check_serial_nos": True,
"serial_nos": serial_nos, "serial_nos": serial_nos,
} }
if self.voucher_type == "POS Invoice": if self.voucher_type == "POS Invoice":
kwargs["ignore_voucher_nos"] = [self.voucher_no] kwargs["ignore_voucher_nos"] = [self.voucher_no]
@@ -1976,9 +1977,9 @@ def get_available_serial_nos(kwargs):
order_by = "creation" order_by = "creation"
if kwargs.based_on == "LIFO": if kwargs.based_on == "LIFO":
order_by = "creation desc" order_by = "creation"
elif kwargs.based_on == "Expiry": elif kwargs.based_on == "Expiry":
order_by = "amc_expiry_date asc" order_by = "amc_expiry_date"
filters = {"item_code": kwargs.item_code} filters = {"item_code": kwargs.item_code}
@@ -2025,7 +2026,12 @@ def get_serial_nos_based_on_filters(filters, fields, order_by, kwargs):
doctype = frappe.qb.DocType("Serial No") doctype = frappe.qb.DocType("Serial No")
order_by_column = getattr(doctype, order_by) order_by_column = getattr(doctype, order_by)
query = frappe.qb.from_(doctype).orderby(order_by_column).limit(cint(kwargs.qty) or 10000000).for_update() query = frappe.qb.from_(doctype).limit(cint(kwargs.qty) or 10000000).for_update()
if kwargs.based_on == "LIFO":
query = query.orderby(order_by_column, order=frappe.query_builder.Order.desc)
else:
query = query.orderby(order_by_column)
for key, value in filters.items(): for key, value in filters.items():
column = getattr(doctype, key) column = getattr(doctype, key)

View File

@@ -1838,9 +1838,7 @@ class StockEntry(StockController):
if self.purpose == "Material Issue": if self.purpose == "Material Issue":
ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account") ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account")
if (self.purpose == "Manufacture" and not args.get("is_finished_item")) or not ret.get( if not ret.get("expense_account"):
"expense_account"
):
ret["expense_account"] = frappe.get_cached_value( ret["expense_account"] = frappe.get_cached_value(
"Company", self.company, "stock_adjustment_account" "Company", self.company, "stock_adjustment_account"
) )
@@ -1950,8 +1948,8 @@ class StockEntry(StockController):
"`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`item_code`",
"`tabStock Entry Detail`.`item_name`", "`tabStock Entry Detail`.`item_name`",
"`tabStock Entry Detail`.`description`", "`tabStock Entry Detail`.`description`",
"`tabStock Entry Detail`.`qty`", "sum(`tabStock Entry Detail`.qty) as qty",
"`tabStock Entry Detail`.`transfer_qty`", "sum(`tabStock Entry Detail`.transfer_qty) as transfer_qty",
"`tabStock Entry Detail`.`stock_uom`", "`tabStock Entry Detail`.`stock_uom`",
"`tabStock Entry Detail`.`uom`", "`tabStock Entry Detail`.`uom`",
"`tabStock Entry Detail`.`basic_rate`", "`tabStock Entry Detail`.`basic_rate`",
@@ -1970,6 +1968,7 @@ class StockEntry(StockController):
["Stock Entry Detail", "docstatus", "=", 1], ["Stock Entry Detail", "docstatus", "=", 1],
], ],
order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc", order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc",
group_by="`tabStock Entry Detail`.`item_code`",
) )
@frappe.whitelist() @frappe.whitelist()

View File

@@ -1285,7 +1285,7 @@ class TestStockEntry(FrappeTestCase):
self.assertEqual(se.value_difference, 0.0) self.assertEqual(se.value_difference, 0.0)
self.assertEqual(se.total_incoming_value, se.total_outgoing_value) self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
self.assertEqual(se.items[0].expense_account, "Stock Adjustment - _TC") self.assertEqual(se.items[0].expense_account, "_Test Account Cost for Goods Sold - _TC")
self.assertEqual(se.items[1].expense_account, "_Test Account Cost for Goods Sold - _TC") self.assertEqual(se.items[1].expense_account, "_Test Account Cost for Goods Sold - _TC")
@change_settings("Stock Settings", {"allow_negative_stock": 0}) @change_settings("Stock Settings", {"allow_negative_stock": 0})

View File

@@ -151,6 +151,13 @@ frappe.query_reports["Stock Balance"] = {
return value; return value;
}, },
onload: function (report) {
report.page.add_inner_button(__("View Stock Ledger"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Stock Ledger", filters);
});
},
}; };
erpnext.utils.add_inventory_dimensions("Stock Balance", 8); erpnext.utils.add_inventory_dimensions("Stock Balance", 8);

View File

@@ -134,6 +134,13 @@ frappe.query_reports["Stock Ledger"] = {
return value; return value;
}, },
onload: function (report) {
report.page.add_inner_button(__("View Stock Balance"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Stock Balance", filters);
});
},
}; };
erpnext.utils.add_inventory_dimensions("Stock Ledger", 10); erpnext.utils.add_inventory_dimensions("Stock Ledger", 10);

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["Stock Qty vs Batch Qty"] = {
filters: [
{
fieldname: "item",
label: __("Item"),
fieldtype: "Link",
options: "Item",
get_query: function () {
return {
filters: { has_batch_no: true },
};
},
},
{
fieldname: "batch",
label: __("Batch"),
fieldtype: "Link",
options: "Batch",
get_query: function () {
const item_code = frappe.query_report.get_filter_value("item");
return {
filters: { item: item_code, disabled: 0 },
};
},
},
],
onload: function (report) {
report.page.add_inner_button(__("Update Batch Qty"), function () {
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes
.map((i) => frappe.query_report.data[i])
.filter((row) => row.difference != 0);
if (selected_rows.length) {
frappe.call({
method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
args: {
selected_batches: selected_rows,
},
callback: function (r) {
if (!r.exc) {
report.refresh();
}
},
});
} else {
frappe.msgprint(__("Please select at least one row with difference value"));
}
});
},
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (column.fieldname == "difference" && data) {
if (data.difference > 0) {
value = "<span style='color:red'>" + value + "</span>";
} else if (data.difference < 0) {
value = "<span style='color:red'>" + value + "</span>";
}
}
return value;
},
get_datatable_options(options) {
return Object.assign(options, {
checkboxColumn: true,
});
},
};

View File

@@ -0,0 +1,28 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2025-10-07 20:03:45.952352",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letterhead": null,
"modified": "2025-11-18 11:35:04.615085",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Qty vs Batch Qty",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Item",
"report_name": "Stock Qty vs Batch Qty",
"report_type": "Script Report",
"roles": [
{
"role": "Item Manager"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,125 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from erpnext.stock.doctype.batch.batch import get_batch_qty
def execute(filters=None):
if not filters:
filters = {}
columns = get_columns()
data = get_data(filters)
return columns, data
def get_columns() -> list[dict]:
columns = [
{
"label": _("Item Code"),
"fieldname": "item_code",
"fieldtype": "Link",
"options": "Item",
"width": 200,
},
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 200},
{"label": _("Batch"), "fieldname": "batch", "fieldtype": "Link", "options": "Batch", "width": 200},
{"label": _("Batch Qty"), "fieldname": "batch_qty", "fieldtype": "Float", "width": 150},
{"label": _("Stock Qty"), "fieldname": "stock_qty", "fieldtype": "Float", "width": 150},
{"label": _("Difference"), "fieldname": "difference", "fieldtype": "Float", "width": 150},
]
return columns
def get_data(filters=None):
filters = filters or {}
item = filters.get("item")
batch_no = filters.get("batch")
batch_sle_data = (
get_batch_qty(
item_code=item,
batch_no=batch_no,
for_stock_levels=True,
consider_negative_batches=True,
ignore_reserved_stock=True,
)
or []
)
stock_qty_map = {}
for row in batch_sle_data:
batch = row.get("batch_no")
if not batch:
continue
stock_qty_map[batch] = stock_qty_map.get(batch, 0) + (row.get("qty") or 0)
batch = frappe.qb.DocType("Batch")
query = (
frappe.qb.from_(batch)
.select(batch.name, batch.item, batch.item_name, batch.batch_qty)
.where(batch.disabled == 0)
)
if item:
query = query.where(batch.item == item)
if batch_no:
query = query.where(batch.name == batch_no)
batch_records = query.run(as_dict=True) or []
result = []
for row in batch_records:
name = row.get("name")
batch_qty = row.get("batch_qty") or 0
stock_qty = stock_qty_map.get(name, 0)
difference = stock_qty - batch_qty
if difference != 0:
result.append(
{
"item_code": row.get("item"),
"item_name": row.get("item_name"),
"batch": name,
"batch_qty": batch_qty,
"stock_qty": stock_qty,
"difference": difference,
}
)
return result
@frappe.whitelist()
def update_batch_qty(selected_batches=None):
if not selected_batches:
return
selected_batches = json.loads(selected_batches)
for row in selected_batches:
batch_name = row.get("batch")
batches = get_batch_qty(
batch_no=batch_name,
item_code=row.get("item_code"),
for_stock_levels=True,
consider_negative_batches=True,
ignore_reserved_stock=True,
)
batch_qty = 0.0
if batches:
for batch in batches:
batch_qty += batch.get("qty")
frappe.db.set_value("Batch", batch_name, "batch_qty", batch_qty)
frappe.msgprint(_("Batch Qty updated successfully"), alert=True)

View File

@@ -1070,13 +1070,23 @@ class SerialBatchCreation:
for d in remove_list: for d in remove_list:
package.remove(d) package.remove(d)
def make_serial_and_batch_bundle(self): def make_serial_and_batch_bundle(
self, serial_nos=None, batch_nos=None
): # passing None instead of [] due to ruff linter error B006
serial_nos = serial_nos or []
batch_nos = batch_nos or []
doc = frappe.new_doc("Serial and Batch Bundle") doc = frappe.new_doc("Serial and Batch Bundle")
valid_columns = doc.meta.get_valid_columns() valid_columns = doc.meta.get_valid_columns()
for key, value in self.__dict__.items(): for key, value in self.__dict__.items():
if key in valid_columns: if key in valid_columns:
doc.set(key, value) doc.set(key, value)
if serial_nos:
self.serial_nos = serial_nos
if batch_nos:
self.batches = batch_nos
if self.type_of_transaction == "Outward": if self.type_of_transaction == "Outward":
self.set_auto_serial_batch_entries_for_outward() self.set_auto_serial_batch_entries_for_outward()
elif self.type_of_transaction == "Inward": elif self.type_of_transaction == "Inward":