mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-01 11:19:09 +00:00
Merge pull request #51280 from frappe/version-15-hotfix
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user