Compare commits

..

21 Commits

Author SHA1 Message Date
Shllokkk
b5700831d8 Merge pull request #57791 from frappe/mergify/bp/version-16-hotfix/pr-57790
test: child warehouse account override in stock vs account value comparison (backport #57790)
2026-08-04 20:15:59 +05:30
Shllokkk
a703e7a462 test: child warehouse account override excluded in stock vs account value comparison
(cherry picked from commit ef7a3cb4c8)

# Conflicts:
#	erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
2026-08-04 18:54:44 +05:30
Mihir Kandoi
abc76eb49d Merge pull request #57789 from frappe/mergify/bp/version-16-hotfix/pr-57757
fix(opportunity): add validation for positive item quantities (backport #57757)
2026-08-04 17:13:29 +05:30
R-Jayaraman
37e96f931d chore: use flt() in qty check
(cherry picked from commit 69de8f2d62)
2026-08-04 11:24:02 +00:00
R-Jayaraman
a9f969e942 fix(opportunity): add validation for positive item quantities
(cherry picked from commit c47cc37441)
2026-08-04 11:24:01 +00:00
Mihir Kandoi
cd65a6d9ff Merge pull request #57785 from frappe/mergify/bp/version-16-hotfix/pr-57772
fix(accounts): skip party dashboard without invoice permission (backport #57772)
2026-08-04 16:48:17 +05:30
Sudharsanan11
ee6955d56c fix(accounts): skip party dashboard without invoice permission
(cherry picked from commit ed78dd37be)
2026-08-04 11:04:28 +00:00
Mihir Kandoi
02f407b82a Merge pull request #57779 from frappe/mergify/bp/version-16-hotfix/pr-57777
fix(manufacturing): reach the whole configurator from tree toolbar actions (backport #57777)
2026-08-04 16:11:23 +05:30
Mihir Kandoi
281e92fb6e fix(manufacturing): reach the whole configurator from tree toolbar actions
The toolbar handlers were copied onto view.events as unbound functions, so
`this` inside them was that object literal rather than the BOMConfigurator.
They worked only because the literal also carried `frm`, and broke as soon as
a handler called a method the literal did not list: get_item_code, added when
the tree started keying nodes on the row name, threw
"this.get_item_code is not a function" and killed Add Raw Material, Add Sub
Assembly and Convert to Sub Assembly.

Assign the instance instead of a hand-maintained whitelist. Every method is
reachable, `this.frm` keeps working, and no future method can be forgotten.

Fixes #57773

(cherry picked from commit 097ce0f348)
2026-08-04 10:09:12 +00:00
MochaMind
285aec3164 fix: sync translations from crowdin (#57741) 2026-08-04 10:00:24 +00:00
mergify[bot]
824ae57e44 fix: escape data in multiple templates (backport #57742) (#57770)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-04 06:52:25 +00:00
ruthra kumar
4193a441e6 Merge pull request #57767 from frappe/mergify/bp/version-16-hotfix/pr-57719
Fix/reversal journal entry custom remark (backport #57719)
2026-08-04 11:02:08 +05:30
Krishna Shirsath
b4dfca9ef1 fix: allow custom remark on reversal journal entry
(cherry picked from commit 5e0e9ba668)
2026-08-04 05:30:03 +00:00
Diptanil Saha
6153202231 fix(accounts): fetch deferred invoice docs on non-empty sales_docs or purchase_docs in repost accounting ledger (#57753) 2026-08-03 17:25:50 +05:30
Mihir Kandoi
4babce436f Merge pull request #57755 from mihir-kandoi/backport/secondary-item-valuation-fixes
fix(stock): correct secondary item valuation across stock entry purposes
2026-08-03 17:05:54 +05:30
Mihir Kandoi
aaa99f775d test(stock): cover secondary item valuation across stock entry purposes
Ports the five regression tests to this branch's `type` field name.
2026-08-03 16:53:20 +05:30
Mihir Kandoi
4ed03748fe fix(stock): correct secondary item valuation across stock entry purposes
Backport of five fixes merged to develop, adapted to this branch, where
the field is still named `type` and the stock entry rate logic has not
been split out of set_basic_rate.

- A secondary row with no BOM link is costed out of the finished good,
  as legacy scrap was. Finished goods are rated last so a single
  validate pass sees the secondary rows' amounts. (#57732)
- Repack no longer flags secondary rows as finished goods, so each side
  takes the share the BOM declares instead of the scrap absorbing the
  finished good's percentage. (#57735)
- A BOM allocation of 0% means the row carries no cost, rather than
  falling through to the item's own valuation rate. (#57736)
- Secondary Item Type no longer waives a quality inspection on purposes
  that do not produce secondary items. (#57737)
- The BOM allocation applies to the consumption entry's cost when the
  raw material cost comes from one. (#57738)

Replaces the individual backports, which could not be cherry-picked
cleanly: every hunk needed rewriting against the pre-rename field and
the un-refactored rate logic.
2026-08-03 16:53:20 +05:30
Mihir Kandoi
667b012065 Merge pull request #57750 from frappe/mergify/bp/version-16-hotfix/pr-57747
fix: disabled item attribute blocks unrelated edits to existing variants (backport #57747)
2026-08-03 16:33:22 +05:30
Mihir Kandoi
81e24442e3 test(stock): cover editing a variant whose attribute is disabled
Assert that a variant saves after its attribute is disabled when the edit
leaves the attribute rows alone, and that changing an attribute value still
throws.

(cherry picked from commit 8d5326196e)
2026-08-03 10:47:42 +00:00
Mihir Kandoi
00139081f6 fix(stock): validate only the variant attributes that changed
Disabling an Item Attribute writes `disabled = 1` into every Item Variant
Attribute row, including the rows on the template. `validate_variant` runs
on every save and walks the whole attribute table, so any later save of an
existing variant re-checked its untouched rows against the now-disabled
template row and threw. `update_variants` hit the same wall, which made a
single template save fail once an attribute was disabled.

The flag exists to keep an attribute out of new variants, not to freeze the
variants that already use it. item.js only reads it to drop the attribute
from the variant creation dialog.

Skip rows that are unchanged since the last save. New and edited rows are
still checked, so a disabled attribute cannot be added to an existing
variant, and the same guard covers the sibling checks for attributes and
values that the template no longer offers.

(cherry picked from commit 25cd793617)
2026-08-03 10:47:42 +00:00
Henil Maru
a5544d0bfb fix(pos): don't double-escape Item Group names in get_item_groups (#57673)
frappe.db.escape() wraps the value in quotes (e.g. "'Products'").
Callers pass the result into query-builder isin()/frappe.get_all
filters, which parameterize values themselves — so the pre-quoted
string never matches a real Item Group name, and POS shows no items
whenever a POS Profile restricts Item Groups.

Return raw names instead, matching develop.
2026-08-03 16:03:38 +05:30
51 changed files with 150432 additions and 66047 deletions

View File

@@ -567,7 +567,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.filter((field) => !["posting_date", "custom_remark", "remark"].includes(field.df.fieldname))
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -234,15 +234,18 @@ def get_item_groups(pos_profile):
for data in pos_profile.get("item_groups"):
item_groups.extend(
[
"%s" % frappe.db.escape(d.name)
d.name
for d in get_child_nodes("Item Group", data.item_group)
if not permitted_item_groups or d.name in permitted_item_groups
]
)
if not item_groups and permitted_item_groups:
item_groups = ["%s" % frappe.db.escape(d) for d in permitted_item_groups]
item_groups = list(permitted_item_groups)
# Return raw Item Group names; the callers parameterize them via the query builder
# (item_group.isin(...)) / frappe.get_all, which escapes them once. Pre-escaping here would
# double-escape (item_group IN ('''X''')) and match nothing.
return list(set(item_groups))

View File

@@ -467,19 +467,24 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
docs_with_deferred_revenue = ()
docs_with_deferred_expense = ()
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if sales_docs:
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
if purchase_docs:
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(

View File

@@ -854,9 +854,11 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
if not frappe.has_permission(doctype, "read"):
return None
current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]

View File

@@ -70,9 +70,23 @@ QI_OUTGOING_PURPOSES = (
)
SECONDARY_ITEM_PURPOSES = ("Manufacture", "Repack", "Disassemble")
def is_inspection_exempt_secondary_row(doc, row) -> bool:
"""Whether the row is a secondary item on a document that produces secondary items."""
if not (row.get("type") or row.get("is_legacy_scrap_item")):
return False
if doc.doctype == "Stock Entry":
return doc.purpose in SECONDARY_ITEM_PURPOSES
return True
def stock_entry_row_requires_inspection(purpose, row):
"""Check if this Stock Entry row need a Quality Inspection."""
if row.get("type") or row.get("is_legacy_scrap_item"):
if purpose in SECONDARY_ITEM_PURPOSES and (row.get("type") or row.get("is_legacy_scrap_item")):
return False
if purpose == "Manufacture":
return bool(row.is_finished_item)
@@ -1604,7 +1618,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry":
qi_required = stock_entry_row_requires_inspection(self.purpose, row)
if row.get("type") or row.get("is_legacy_scrap_item"):
if is_inspection_exempt_secondary_row(self, row):
continue
if qi_required: # validate row only if inspection is required on item level

View File

@@ -134,6 +134,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -144,6 +145,15 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
def validate_qty(self):
for item in self.items:
if flt(item.qty) <= 0:
frappe.throw(
_("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
item.idx, item.item_code
)
)
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

63124
erpnext/locale/ro.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,9 @@
<div class="row" style="border-bottom:1px solid var(--border-color); padding:4px 5px; margin-top: 3px;margin-bottom: 3px;">
<div class="col-sm-1">
{% if(row.image) { %}
<img style="width:50px;height:50px;" src="{{row.image}}">
<img style="width:50px;height:50px;" src="{{frappe.utils.escape_html(row.image)}}">
{% } else { %}
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(row.item_code, 2)}}</div>
<div style="width:50px;height:50px;background-color:var(--control-bg);text-align:center;padding-top:15px">{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}</div>
{% } %}
</div>
<div class="col-sm-3">
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
<p>
{{row.item_name}}
{{frappe.utils.escape_html(row.item_name)}}
</p>
{% } %}
@@ -52,10 +52,10 @@
</span>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ escape(row.item_code) }}">{{ __("Add") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Add") }}</button>
</div>
<div class="col-sm-1">
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ escape(row.item_code) }}">{{ __("Move") }}</button>
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-move" data-item-code="{{ frappe.utils.escape_html(row.item_code) }}">{{ __("Move") }}</button>
</div>
</div>
{% }); %}

View File

@@ -513,7 +513,7 @@ def get_workstations(**kwargs):
d.color = color_map.get(d.status, "red")
d.workstation_link = get_url_to_form("Workstation", d.name)
if d.status != "Production":
d.status_image = d.off_status_image
d.status_image = frappe.utils.escape_html(d.off_status_image)
d.workstation_off = "workstation-off"
return data

View File

@@ -32,18 +32,7 @@ class BOMConfigurator {
}
bind_events() {
frappe.views.trees["BOM Configurator"].events = {
frm: this.frm,
add_item: this.add_item,
add_sub_assembly: this.add_sub_assembly,
set_query_for_workstation: this.set_query_for_workstation,
get_sub_assembly_modal_fields: this.get_sub_assembly_modal_fields,
convert_to_sub_assembly: this.convert_to_sub_assembly,
delete_node: this.delete_node,
edit_bom: this.edit_bom,
load_tree: this.load_tree,
set_default_qty: this.set_default_qty,
};
frappe.views.trees["BOM Configurator"].events = this;
}
tree_options() {

View File

@@ -20,8 +20,10 @@ erpnext.stock.qi_outgoing_purposes = [
];
erpnext.stock.is_incoming_qi_purpose = (purpose) =>
purpose === "Manufacture" || erpnext.stock.qi_incoming_purposes.includes(purpose);
erpnext.stock.secondary_item_purposes = ["Manufacture", "Repack", "Disassemble"];
erpnext.stock.row_requires_quality_inspection = (purpose, row) => {
if (row.type || row.is_legacy_scrap_item) return false;
if (erpnext.stock.secondary_item_purposes.includes(purpose) && (row.type || row.is_legacy_scrap_item))
return false;
if (purpose === "Manufacture") return !!row.is_finished_item;
if (erpnext.stock.qi_incoming_purposes.includes(purpose)) return !!row.t_warehouse;
if (erpnext.stock.qi_outgoing_purposes.includes(purpose))

View File

@@ -168,7 +168,7 @@ class VisualPlantFloor {
.find(".workstation-image-container")
.append(
`<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">${frappe.get_abbr(
data.name,
frappe.utils.escape_html(data.name),
2
)}</div>`
);

View File

@@ -1,17 +1,19 @@
<div class="app-listing item-list image-view-container item-selector">
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
{% const item_name = frappe.utils.escape_html(item.name); %}
{% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
{% if (i % 4 === 0) { %}<div class="image-view-row">{% } %}
<div class="image-view-item" data-name="{{ item.name }}">
<div class="image-view-item" data-name="{{ item_name }}">
<div class="image-view-header doclist-row">
<div class="list-value">
<a class="grey list-id" data-name="{{item.name}}"
title="{{ item.item_name || item.name}}">
{{item.item_name || item.name}}</a>
<a class="grey list-id" data-name="{{ item_name }}"
title="{{ item_title }}">
{{ item_title }}</a>
</div>
</div>
<div class="image-view-body">
<a data-item-code="{{ item.name }}"
title="{{ item.item_name || item.name }}"
<a data-item-code="{{ item_name }}"
title="{{ item_title }}"
>
<div class="image-field"
style="
@@ -22,11 +24,11 @@
>
{% if (!item.image) { %}
<span class="placeholder-text">
{%= frappe.get_abbr(item.item_name || item.name) %}
{%= frappe.get_abbr(item_title) %}
</span>
{% } %}
{% if (item.image) { %}
<img src="{{ item.image }}" alt="{{item.item_name || item.name}}">
<img src="{{ frappe.utils.escape_html(item.image) }}" alt="{{ item_title }}">
{% } %}
</div>
</a>

View File

@@ -1,5 +1,6 @@
{% $.each(workstations, (idx, row) => { %}
<div class="workstation-wrapper" data-workstation="{{row.name}}">
{% const row_workstation_name = frappe.utils.escape_html(row.name); %}
<div class="workstation-wrapper" data-workstation="{{row_workstation_name}}">
<div class="workstation-status text-left" style="">
<span class="indicator-pill no-indicator-dot whitespace-nowrap {{row.color}}" style="margin: 8px 0px 0px 8px;">
<span class="workstation-status-title" style="font-size:10px">{{row.status}}</span>
@@ -10,12 +11,12 @@
{% if(row.status_image) { %}
<img class="workstation-image-cls" src="{{row.status_image}}">
{% } else { %}
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row.name, 2)}}</div>
<div class="workstation-image-cls workstation-abbr" style="margin:6px; height:82px">{{frappe.get_abbr(row_workstation_name, 2)}}</div>
{% } %}
</div>
<span class="ellipsis" title="{{row.name}}">
<span class="ellipsis" title="{{row_workstation_name}}">
<div style="font-size:11px; text-align:center;padding-bottom:8px">{{row.workstation_name}}</div>
</span>
</div>
</div>
{% }); %}
{% }); %}

View File

@@ -853,7 +853,17 @@ class Item(Document):
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
if based_on == "Item Attribute":
previous_doc = self.get_doc_before_save()
saved_attributes = (
{(row.attribute, row.attribute_value) for row in previous_doc.attributes}
if previous_doc
else set()
)
for d in self.attributes:
if (d.attribute, d.attribute_value) in saved_attributes:
continue
if not frappe.db.exists(
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
):

View File

@@ -410,6 +410,24 @@ class TestItem(ERPNextTestSuite):
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
def test_disabled_attribute_blocks_only_attribute_changes(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
variant.save()
attribute = frappe.get_doc("Item Attribute", "Test Size")
attribute.disabled = 1
attribute.save()
variant.reload()
variant.description = "Edited after the attribute was disabled"
variant.save()
variant.reload()
variant.attributes[0].attribute_value = "Small"
self.assertRaises(frappe.ValidationError, variant.save)
def test_rename_attribute_value_updates_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)

View File

@@ -83,20 +83,19 @@ from erpnext.controllers.subcontracting_inward_controller import SubcontractingI
form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"}
<<<<<<< HEAD
def _qty_tolerance(precision: int) -> float:
"""One unit at the column's precision -- absorbs float rounding without letting a real
(whole-unit) quantity divergence slip through."""
return 1.0 / (10**precision)
=======
def is_costed_out_of_finished_item(row) -> bool:
"""Whether the row takes its value out of the finished good instead of adding to it.
A secondary item that is not linked to a BOM has no cost allocation of its own, so it is
valued the way the legacy scrap item was: its cost is deducted from the finished good.
"""
return bool(row.is_legacy_scrap_item or (row.secondary_item_type and not row.bom_secondary_item))
>>>>>>> 5e81cd1540 (fix(stock): cost a BOM-less secondary item out of the finished good)
return bool(row.is_legacy_scrap_item or (row.type and not row.bom_secondary_item))
def _qty_tolerance(precision: int) -> float:
"""One unit at the column's precision -- absorbs float rounding without letting a real
(whole-unit) quantity divergence slip through."""
return 1.0 / (10**precision)
class StockEntry(StockController, SubcontractingInwardController):
@@ -1457,19 +1456,12 @@ class StockEntry(StockController, SubcontractingInwardController):
outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate)
has_consumption_basis = self.has_consumption_basis()
<<<<<<< HEAD
secondary_items_cost_basis = self.get_secondary_items_cost_basis(outgoing_items_cost)
items = []
# Set basic rate for incoming items
for d in self.get("items"):
=======
bom_cost_allocation_per = (
frappe.get_cached_value("BOM", self.bom_no, "cost_allocation_per") if self.bom_no else None
)
zero_valuation_items = []
finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item))
for d in finished_items_last:
>>>>>>> 5e81cd1540 (fix(stock): cost a BOM-less secondary item out of the finished good)
if d.s_warehouse or d.set_basic_rate_manually:
continue
@@ -1479,7 +1471,7 @@ class StockEntry(StockController, SubcontractingInwardController):
d.basic_amount = 0.0
continue
rate_derived_from_consumption = False
has_derived_rate = False
if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer":
d.basic_rate = 0.0
@@ -1489,26 +1481,25 @@ class StockEntry(StockController, SubcontractingInwardController):
d.basic_rate = self.get_basic_rate_for_manufactured_item(
d.transfer_qty, outgoing_items_cost, has_consumption_basis
)
rate_derived_from_consumption = has_consumption_basis
has_derived_rate = has_consumption_basis
elif self.purpose == "Repack":
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
# Repack rate comes from consumed source-warehouse rows, not consumption entries
rate_derived_from_consumption = any(item.s_warehouse for item in self.get("items"))
has_derived_rate = any(item.s_warehouse for item in self.get("items"))
if self.bom_no:
d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100
elif d.type and d.bom_secondary_item:
cost_allocation_per = frappe.get_value(
"BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per"
cost_allocation_per = flt(
frappe.get_value("BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per")
)
# Only recalculate when cost is actually allocated; otherwise preserve the
# user-entered rate (or fall through to get_valuation_rate below)
if cost_allocation_per and flt(d.transfer_qty):
d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty
if flt(d.transfer_qty):
d.basic_rate = (secondary_items_cost_basis * (cost_allocation_per / 100)) / d.transfer_qty
has_derived_rate = True
# A rate of zero derived from the consumed items is their actual cost, not a missing
# rate. Falling back to the item's valuation here would value free inputs as output.
if not d.basic_rate and not d.allow_zero_valuation_rate and not rate_derived_from_consumption:
# A rate of zero that was derived rather than left unset is a real cost. Falling back to
# the item's valuation here would value free inputs, or an unallocated row, as output.
if not d.basic_rate and not d.allow_zero_valuation_rate and not has_derived_rate:
if self.is_new():
raise_error_if_no_rate = False
@@ -1621,6 +1612,36 @@ class StockEntry(StockController, SubcontractingInwardController):
)
return flt(outgoing_items_cost / total_fg_qty)
def get_secondary_items_cost_basis(self, outgoing_items_cost) -> float:
"""The cost a BOM allocation splits: the consumed rows, or the entry that replaced them."""
if outgoing_items_cost or self.purpose != "Manufacture" or not self.work_order:
return outgoing_items_cost
settings = frappe.get_single("Manufacturing Settings")
if not (settings.material_consumption and settings.get_rm_cost_from_consumption_entry):
return outgoing_items_cost
if not self.get_consumption_entries():
return outgoing_items_cost
return self._fetch_consumption_entry_cost()
def _fetch_consumption_entry_cost(self):
SE = frappe.qb.DocType("Stock Entry")
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
return (
frappe.qb.from_(SE)
.left_join(SE_ITEM)
.on(SE.name == SE_ITEM.parent)
.select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
.where(
(SE.docstatus == 1)
& (SE.work_order == self.work_order)
& (SE.purpose == "Material Consumption for Manufacture")
)
).run()[0][0] or 0
def get_basic_rate_for_manufactured_item(
self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False
) -> float:
@@ -1664,20 +1685,7 @@ class StockEntry(StockController, SubcontractingInwardController):
)
)
SE = frappe.qb.DocType("Stock Entry")
SE_ITEM = frappe.qb.DocType("Stock Entry Detail")
outgoing_items_cost = (
frappe.qb.from_(SE)
.left_join(SE_ITEM)
.on(SE.name == SE_ITEM.parent)
.select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty))
.where(
(SE.docstatus == 1)
& (SE.work_order == self.work_order)
& (SE.purpose == "Material Consumption for Manufacture")
)
).run()[0][0] or 0
outgoing_items_cost = self._fetch_consumption_entry_cost()
# Estimate from the BOM only when nothing was consumed. A consumed cost of zero is a
# real cost, so substituting BOM rates would value free inputs as output.
@@ -2053,7 +2061,9 @@ class StockEntry(StockController, SubcontractingInwardController):
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if self.purpose == "Repack" or d.item_code == finished_item:
if d.type or d.is_legacy_scrap_item:
d.is_finished_item = 0
elif self.purpose == "Repack" or d.item_code == finished_item:
d.is_finished_item = 1
else:
d.is_finished_item = 0

View File

@@ -7,6 +7,7 @@ from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.exceptions import QualityInspectionRequiredError
from erpnext.stock.doctype.item.test_item import (
create_item,
make_item,
@@ -2737,38 +2738,6 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(fg_sle.incoming_rate, 0)
self.assertEqual(fg_sle.stock_value_difference, 0)
<<<<<<< HEAD
=======
def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self):
"""A stray secondary item type must not let a QI-required item through a receipt."""
item = make_item(
properties={
"is_stock_item": 1,
"valuation_rate": 50,
"inspection_required_before_purchase": 1,
}
).name
def receipt(secondary_item_type):
se = frappe.new_doc("Stock Entry")
se.purpose = se.stock_entry_type = "Material Receipt"
se.company = "_Test Company"
se.inspection_required = 1
se.append(
"items",
{
"item_code": item,
"t_warehouse": "_Test Warehouse - _TC",
"qty": 10,
"conversion_factor": 1,
"secondary_item_type": secondary_item_type,
},
)
return se
self.assertRaises(QualityInspectionRequiredError, receipt("").submit)
self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit)
def test_manufacture_balances_secondary_item_added_without_a_bom(self):
"""A secondary item with no BOM link is costed out of the finished good, as legacy scrap was."""
rm_item = make_item(properties={"is_stock_item": 1}).name
@@ -2800,7 +2769,7 @@ class TestStockEntry(ERPNextTestSuite):
"item_code": scrap_item,
"t_warehouse": warehouse,
"qty": 5,
"secondary_item_type": "Scrap",
"type": "Scrap",
"conversion_factor": 1,
},
)
@@ -2818,7 +2787,205 @@ class TestStockEntry(ERPNextTestSuite):
self.assertEqual(flt(se.total_outgoing_value), 1000.0)
self.assertEqual(flt(se.value_difference), 0.0)
>>>>>>> 7f47361ebd (test(stock): cover a secondary item added without a BOM)
def test_repack_allocates_cost_to_secondary_item(self):
"""A Repack secondary item takes its own BOM share, not the finished good's."""
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
warehouse = "_Test Warehouse - _TC"
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 10,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": rm_item, "qty": 10})
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
},
)
bom.insert()
bom.submit()
self.assertEqual(flt(bom.cost_allocation_per), 75.0)
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
se = frappe.new_doc("Stock Entry")
se.purpose = se.stock_entry_type = "Repack"
se.company = "_Test Company"
se.from_bom = 1
se.bom_no = bom.name
se.fg_completed_qty = 10
se.from_warehouse = warehouse
se.to_warehouse = warehouse
se.get_items()
se.save()
fg_row = next(d for d in se.items if d.is_finished_item)
scrap_row = next(d for d in se.items if d.type)
self.assertFalse(scrap_row.is_finished_item)
self.assertEqual(flt(scrap_row.basic_amount), 250.0)
self.assertEqual(flt(fg_row.basic_amount), 750.0)
self.assertEqual(flt(se.total_incoming_value), 1000.0)
self.assertEqual(flt(se.total_outgoing_value), 1000.0)
self.assertEqual(flt(se.value_difference), 0.0)
def test_secondary_item_with_zero_cost_allocation_carries_no_value(self):
"""A BOM that allocates 0% to a secondary item gives the finished good everything."""
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
warehouse = "_Test Warehouse - _TC"
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 10,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": rm_item, "qty": 10})
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 5,
"cost_allocation_per": 0,
"process_loss_per": 0,
},
)
bom.insert()
bom.submit()
self.assertEqual(flt(bom.cost_allocation_per), 100.0)
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
wo = make_wo_order_test_record(
production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse
)
se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
se.save()
scrap_row = next(d for d in se.items if d.type)
fg_row = next(d for d in se.items if d.is_finished_item)
self.assertEqual(flt(scrap_row.basic_rate), 0.0)
self.assertEqual(flt(scrap_row.basic_amount), 0.0)
self.assertEqual(flt(fg_row.basic_amount), 1000.0)
self.assertEqual(flt(se.value_difference), 0.0)
def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self):
"""A stray secondary item type must not let a QI-required item through a receipt."""
item = make_item(
properties={
"is_stock_item": 1,
"valuation_rate": 50,
"inspection_required_before_purchase": 1,
}
).name
def receipt(secondary_item_type):
se = frappe.new_doc("Stock Entry")
se.purpose = se.stock_entry_type = "Material Receipt"
se.company = "_Test Company"
se.inspection_required = 1
se.append(
"items",
{
"item_code": item,
"t_warehouse": "_Test Warehouse - _TC",
"qty": 10,
"conversion_factor": 1,
"type": secondary_item_type,
},
)
return se
self.assertRaises(QualityInspectionRequiredError, receipt("").submit)
self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit)
@ERPNextTestSuite.change_settings(
"Manufacturing Settings", {"material_consumption": 1, "get_rm_cost_from_consumption_entry": 1}
)
def test_secondary_item_allocation_uses_consumption_entry_cost(self):
"""A BOM allocation splits the consumption entry's cost, not an empty set of consumed rows."""
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_from_wo,
)
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
fg_item = make_item(properties={"is_stock_item": 1}).name
scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name
warehouse = "_Test Warehouse - _TC"
bom = frappe.get_doc(
{
"doctype": "BOM",
"item": fg_item,
"currency": "INR",
"quantity": 10,
"company": "_Test Company",
}
)
bom.append("items", {"item_code": rm_item, "qty": 10})
bom.append(
"secondary_items",
{
"type": "Scrap",
"item_code": scrap_item,
"item_name": scrap_item,
"qty": 5,
"cost_allocation_per": 25,
"process_loss_per": 0,
},
)
bom.insert()
bom.submit()
make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100)
wo = make_wo_order_test_record(
production_item=fg_item, bom_no=bom.name, qty=10, skip_transfer=1, source_warehouse=warehouse
)
consumption = frappe.get_doc(
make_stock_entry_from_wo(wo.name, "Material Consumption for Manufacture", 10)
)
consumption.submit()
self.assertEqual(flt(consumption.total_outgoing_value), 1000.0)
se = frappe.get_doc(make_stock_entry_from_wo(wo.name, "Manufacture", 10))
se.save()
scrap_row = next(d for d in se.items if d.type)
fg_row = next(d for d in se.items if d.is_finished_item)
self.assertEqual(flt(fg_row.basic_amount), 750.0)
self.assertEqual(flt(scrap_row.basic_amount), 250.0)
self.assertEqual(flt(se.total_incoming_value), 1000.0)
def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import (

View File

@@ -6,6 +6,8 @@ from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_reposting_entries,
execute,
@@ -55,3 +57,22 @@ class TestStockAndAccountValueComparison(ERPNextTestSuite):
filters={"based_on": "Item and Warehouse", "item_code": item},
)
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")
def test_child_account_override_excluded_from_group_account(self):
# A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override
# it with its own account. get_warehouses_based_on_account must return only warehouses whose
# effective account matches, excluding the overriding child.
group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=PI_COMPANY)
group_account = frappe.get_value("Warehouse", group, "account")
inheriting = create_warehouse(
"_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=PI_COMPANY
)
overriding = create_warehouse(
"_Test SAVC Transit WH", {"parent_warehouse": group}, company=PI_COMPANY
)
warehouses = get_warehouses_based_on_account(group_account, PI_COMPANY)
self.assertIn(inheriting, warehouses)
self.assertNotIn(overriding, warehouses)