Compare commits

..

1 Commits

Author SHA1 Message Date
Mohd Haris
48418eadb0 fix(budget-variance): correct month shift in comparison chart
The Budget Variance Report chart plotted the actual expense one month
earlier than the table (e.g. July actual shown under June).

build_comparison_chart_data() collected budget columns using
fieldname.startswith("budget_"). The dimension column "budget_against"
also matches that prefix, so it was added as an extra leading entry to
budget_fields and labels, while actual_fields had no such leading entry.
This shifted every actual value one position ahead of its label.

Skip the "budget_against" dimension column so budget/actual values and
labels stay aligned per month.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:06:43 +05:30
48 changed files with 57316 additions and 82565 deletions

View File

@@ -150,34 +150,6 @@ Add it only when it is functionally dependent on the existing select columns; ot
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
unchanged.
### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug
The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means
provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full
audit of these fixes found four recurring mistakes:
- **Incoherent pair** — two semantically-coupled columns (a flag + a link:
`is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent*
`Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed.
MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers
tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery +
join-back) so every column comes from the same line.
- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator
(an `original_item`-style column) *deterministically* returns the non-NULL value where
MariaDB could return NULL — deterministically wrong where the old behavior was only
intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates,
dict keys).
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.
Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is
not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or
pick a bound for a stated reason, and cover the varying-group case with a test.
---
## 4. False positives — do NOT flag these

File diff suppressed because one or more lines are too long

View File

@@ -246,62 +246,6 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_pay_multiple_purchase_invoices_in_one_entry(self):
pi1 = make_purchase_invoice() # outstanding 250
pi2 = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi1.name, bank_account="_Test Cash - _TC")
pe.append(
"references",
{
"reference_doctype": "Purchase Invoice",
"reference_name": pi2.name,
"total_amount": pi2.grand_total,
"outstanding_amount": pi2.outstanding_amount,
"allocated_amount": pi2.outstanding_amount,
},
)
pe.paid_amount = pe.received_amount = (
pe.references[0].allocated_amount + pe.references[1].allocated_amount
)
pe.insert()
pe.submit()
self.assertEqual(pe.total_allocated_amount, 500)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi1.name, "outstanding_amount"), 0)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi2.name, "outstanding_amount"), 0)
def test_unallocated_amount_on_overpaid_purchase_payment(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.paid_amount = pe.references[0].allocated_amount + 200 # overpay -> 200 advance
pe.received_amount = pe.paid_amount
pe.insert()
pe.submit()
self.assertEqual(pe.docstatus, 1)
self.assertEqual(pe.unallocated_amount, 200)
# end-to-end: submitting posts a balanced GL for the full paid amount (250
# settling the invoice + 200 advance)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": pe.name, "is_cancelled": 0},
fields=["debit", "credit"],
)
self.assertTrue(gl_entries, "Submitted payment produced no GL entries")
self.assertEqual(flt(sum(e.debit for e in gl_entries)), flt(sum(e.credit for e in gl_entries)))
self.assertEqual(flt(sum(e.debit for e in gl_entries)), 450)
def test_overallocation_against_purchase_invoice_throws(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.references[0].allocated_amount += 100 # 350 > 250 outstanding
pe.paid_amount = pe.received_amount = pe.references[0].allocated_amount
self.assertRaises(frappe.ValidationError, pe.insert)
def test_payment_against_sales_invoice_to_check_status(self):
si = create_sales_invoice(
customer="_Test Customer USD",

View File

@@ -422,6 +422,11 @@ def build_comparison_chart_data(filters, columns, data):
if not fieldname:
continue
# skip the dimension column ("budget_against"), it only matches the
# "budget_" prefix by coincidence and would shift the actual values by one
if fieldname == "budget_against":
continue
if fieldname.startswith("budget_"):
budget_fields.append(fieldname)
elif fieldname.startswith("actual_"):
@@ -433,7 +438,7 @@ def build_comparison_chart_data(filters, columns, data):
labels = [
col["label"].replace("Budget", "").strip()
for col in columns
if col.get("fieldname", "").startswith("budget_")
if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against"
]
budget_values = [0] * len(budget_fields)

View File

@@ -13,7 +13,6 @@ from frappe.desk.reportview import build_match_conditions
from frappe.model.meta import get_field_precision
from frappe.model.naming import determine_consecutive_week_number
from frappe.query_builder import AliasedQuery, Case, Criterion, Field, Table
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Count, IfNull, Max, Min, Round, Sum
from frappe.query_builder.utils import DocType
from frappe.utils import (
@@ -1792,10 +1791,9 @@ def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, f
# transaction can't modify them mid-flight (the original DISTINCT ... FOR UPDATE did this).
# MariaDB carries the lock on the grouped query below; postgres rejects FOR UPDATE alongside
# GROUP BY, so lock the matching rows in a separate pass first -- the row locks are held until
# the surrounding transaction ends, giving the same protection. Select a constant, not the
# name: a deep backdated repost can match millions of rows and only the locks are needed.
# the surrounding transaction ends, giving the same protection.
if frappe.db.db_type == "postgres":
frappe.qb.from_(SLE).select(ConstantColumn(1)).where(conditions).for_update().run()
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
# distinct vouchers in chronological order; expressed as GROUP BY + Min() so it's valid on
# postgres (SELECT DISTINCT can't ORDER BY non-selected cols, and FOR UPDATE is invalid with both).

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

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

@@ -432,7 +432,7 @@
"type": "Link"
}
],
"modified": "2026-07-05 16:32:01.858579",
"modified": "2026-07-03 13:44:07.420267",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",
@@ -463,7 +463,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "house",
"indent": 0,
"keep_closed": 0,
@@ -477,7 +476,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
@@ -491,7 +489,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
@@ -505,7 +502,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "factory",
"indent": 0,
"keep_closed": 0,
@@ -519,7 +515,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "person-standing",
"indent": 0,
"keep_closed": 0,
@@ -533,7 +528,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "package",
"indent": 0,
"keep_closed": 0,
@@ -547,20 +541,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Shop Floor",
"link_to": "shop-floor",
"link_type": "Page",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
@@ -573,7 +553,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -587,7 +566,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Plan",
@@ -600,7 +578,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -614,7 +591,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Master Production Schedule",
@@ -627,7 +603,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Sales Forecast",
@@ -640,7 +615,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -653,7 +627,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "wrench",
"indent": 1,
"keep_closed": 1,
@@ -666,7 +639,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Creator",
@@ -679,7 +651,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Update Tool",
@@ -692,7 +663,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Comparison Tool",
@@ -705,7 +675,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Entry",
@@ -718,7 +687,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 1,
"keep_closed": 1,
@@ -731,7 +699,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -744,7 +711,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Summary",
@@ -757,7 +723,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Summary",
@@ -770,7 +735,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Analysis",
@@ -783,7 +747,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Job Card Summary",
@@ -796,7 +759,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Search",
@@ -809,7 +771,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Analytics",
@@ -822,7 +783,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Operations Time",
@@ -835,7 +795,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Consumed Materials",
@@ -848,7 +807,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "database",
"indent": 1,
"keep_closed": 1,
@@ -861,7 +819,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -875,7 +832,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -889,7 +845,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Operation",
@@ -902,7 +857,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -916,7 +870,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Workstation Type",
@@ -929,7 +882,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Plant Floor",
@@ -942,7 +894,6 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Routing",
@@ -955,7 +906,6 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "settings",
"indent": 0,
"keep_closed": 0,

View File

@@ -80,10 +80,10 @@ class ShopFloor {
<div class="sf-topbar-right">
<button class="btn btn-default btn-sm sf-btn-theme"></button>
<button class="btn btn-default btn-sm sf-btn-home" title="${__("Home")}">
${frappe.utils.icon("house", "sm")}
${frappe.utils.icon("home", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-refresh" title="${__("Refresh")} (r)">
${frappe.utils.icon("refresh-cw", "sm")}
${frappe.utils.icon("refresh", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-scan" title="${__("Scan Job Card")} (b)">
${frappe.utils.icon("scan", "sm")}
@@ -100,9 +100,6 @@ class ShopFloor {
`);
this.app = this.wrapper.find(".sf-app");
this.brand_icon = `<img class="sf-brand-icon" src="/assets/erpnext/images/erpnext-logo.svg" alt="${__(
"ERPNext"
)}">`;
this.topbar_left = this.wrapper.find(".sf-topbar-left");
this.topbar_center = this.wrapper.find(".sf-topbar-center");
this.body = this.wrapper.find(".sf-body");
@@ -157,7 +154,7 @@ class ShopFloor {
if (this.view === "manager") {
this.topbar_left.html(`
<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>
<span class="sf-title">${__("Shop Floor")}</span>
${toggle}
<div class="sf-tabs">
${MANAGER_BUCKETS.map(
@@ -194,9 +191,7 @@ class ShopFloor {
this.toggle_job_cards_only(e.target.checked);
});
} else {
this.topbar_left.html(
`<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>${toggle}`
);
this.topbar_left.html(`<span class="sf-title">${__("Shop Floor")}</span>${toggle}`);
this.build_operator_filters();
}
@@ -471,19 +466,11 @@ class ShopFloor {
this.workstation = workstation;
this.work_order = work_order;
this.compute_state();
this.dedupe_today_sessions();
this.render_operator($container);
},
});
}
// A job card already shown under Completed Operations shouldn't repeat in
// Today's Sessions — keep it in Completed Operations only.
dedupe_today_sessions() {
const shown = new Set((this.completed || []).map((jc) => jc.name));
this.today_sessions = (this.today_sessions || []).filter((s) => !shown.has(s.name));
}
// Re-fetch whichever operator content is currently on screen (used after every action).
reload() {
if (this.view === "manager" && this.selected_wo) {
@@ -1583,8 +1570,7 @@ class ShopFloor {
.sf-toggle input { cursor: pointer; width: 15px; height: 15px; margin: 0; }
.sf-topbar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.sf-btn-theme { font-size: 15px; line-height: 1; min-width: 30px; }
.sf-title { font-size: 18px; font-weight: 700; color: var(--text-color); display: inline-flex; align-items: center; gap: 8px; }
.sf-title .sf-brand-icon { flex-shrink: 0; width: 22px; height: 22px; border-radius: 5px; }
.sf-title { font-size: 18px; font-weight: 700; color: var(--text-color); }
.sf-view-toggle { display: inline-flex; border: 1px solid var(--border-color); border-radius: var(--border-radius); overflow: hidden; }
.sf-view-btn { border: none; background: var(--fg-color); padding: 5px 12px; font-size: 13px; color: var(--text-muted); cursor: pointer; }
@@ -1598,7 +1584,6 @@ class ShopFloor {
font-size: 14px; color: var(--text-muted); cursor: pointer;
}
.sf-tab:hover { background: var(--bg-color); }
.sf-tab:focus, .sf-tab:focus-visible { outline: none; box-shadow: none; }
.sf-tab.active { background: var(--control-bg); color: var(--text-color); font-weight: 600; }
.sf-tab-count { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.sf-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
@@ -1652,10 +1637,10 @@ class ShopFloor {
drawn with it disappears on dark cards. Light theme keeps the dark ring; dark theme
needs an accent colour — a light-gray ring on gray cards is still too subtle. */
.sf-wo-card.sf-selected { border-color: var(--primary-color, var(--primary)); }
.sf-wo-card.sf-focused { box-shadow: 0 0 0 1px var(--primary-color, var(--primary)); }
.sf-wo-card.sf-focused { box-shadow: 0 0 0 2px var(--primary-color, var(--primary)); }
[data-theme="dark"] .sf-wo-card.sf-selected { border-color: var(--blue-500, #2490ef); }
[data-theme="dark"] .sf-wo-card.sf-focused {
box-shadow: 0 0 0 1px var(--blue-500, #2490ef);
box-shadow: 0 0 0 3px var(--blue-500, #2490ef);
border-color: transparent;
}
.sf-wo-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
@@ -1681,7 +1666,7 @@ class ShopFloor {
.sf-wo-progress-block { margin-bottom: 12px; }
.sf-wo-progress-label { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.sf-wo-progress-count { font-weight: 600; color: var(--text-color); font-variant-numeric: tabular-nums; }
.sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-200, #d1d5db); overflow: hidden; }
.sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-300, #d1d5db); overflow: hidden; }
[data-theme="dark"] .sf-progress { background: var(--gray-700, #374151); }
.sf-progress-seg { height: 100%; transition: width 0.3s ease; }
.sf-seg-done { background: var(--green-400, #9ae6b4); }

View File

@@ -83,11 +83,7 @@
/* Active job card — denser than before */
/* Keyboard focus highlight for arrow-key navigation in the operator view. */
[data-sf-focusable].sf-focused {
box-shadow: 0 0 0 1px var(--primary-color, var(--primary)); border-radius: var(--border-radius); outline: none;
border: 1px solid var(--border-color);
border-radius: 12px;
}
[data-sf-focusable].sf-focused { box-shadow: 0 0 0 2px var(--primary); border-radius: var(--border-radius); outline: none; }
.mes-job {
border: 1px solid var(--border-color);
background: var(--fg-color);

View File

@@ -144,8 +144,12 @@ class DeprecatedBatchNoValuation:
if self.sle.name:
conditions &= sle.name != self.sle.name
# MariaDB carries a row lock on the grouped query below; on postgres the caller
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse).
# Lock the scanned SLE rows so a concurrent stock posting can't change them mid-valuation.
# MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so
# lock the same rows in a separate plain SELECT first (held for the transaction).
if frappe.db.db_type == "postgres":
frappe.qb.from_(sle).select(sle.name).where(conditions).for_update().run()
query = (
frappe.qb.from_(sle)
.select(
@@ -265,8 +269,13 @@ class DeprecatedBatchNoValuation:
if self.sle.name:
conditions &= sle.name != self.sle.name
# MariaDB carries a row lock on the grouped query below; on postgres the caller
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse).
# Lock the scanned SLE rows so a concurrent stock posting can't change them mid-valuation.
# MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so
# lock the same SLE rows in a separate plain SELECT first. The batch.use_batchwise_valuation
# refinement below only narrows the set, so locking without the join is a safe superset.
if frappe.db.db_type == "postgres":
frappe.qb.from_(sle).select(sle.name).where(conditions).for_update().run()
query = (
frappe.qb.from_(sle)
.inner_join(batch)
@@ -393,8 +402,21 @@ class DeprecatedBatchNoValuation:
conditions &= bundle.name != self.sle.serial_and_batch_bundle
conditions &= bundle.voucher_type != "Pick List"
# MariaDB carries a row lock on the grouped query below; on postgres the caller
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse).
# Lock the scanned bundle rows so a concurrent stock posting can't change them mid-valuation.
# MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so
# lock the same rows in a separate plain SELECT first (the batch.use_batchwise_valuation
# refinement below only narrows the set, so omitting that join is a safe superset).
if frappe.db.db_type == "postgres":
(
frappe.qb.from_(bundle)
.inner_join(bundle_child)
.on(bundle.name == bundle_child.parent)
.select(bundle_child.name)
.where(conditions)
.for_update()
.run()
)
query = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)

View File

@@ -33,6 +33,5 @@ def get_data():
{"label": _("Traceability"), "items": ["Serial No", "Batch"]},
{"label": _("Stock Movement"), "items": ["Stock Entry", "Stock Reconciliation"]},
{"label": _("Lead Time"), "items": ["Item Lead Time"]},
{"label": _("Standard Cost"), "items": ["Item Standard Cost"]},
],
}

View File

@@ -952,22 +952,10 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
)
# Lock the picked-qty rows so a concurrent pick can't change them mid-transaction. MariaDB carries
# the lock on the grouped query (its gap locks also block rows other in-flight picks are about to
# submit); postgres has no gap locks, so first serialize on the referenced SO/packed item rows
# (they always exist), then lock the matching picked rows in a separate plain SELECT.
# the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so lock the same rows
# in a separate plain SELECT first (held for the transaction).
if frappe.db.db_type == "postgres":
parent = frappe.qb.DocType("Packed Item" if contains_packed_items else "Sales Order Item")
(
frappe.qb.from_(parent)
.select(parent.name)
.where(parent.name.isin(items))
.orderby(parent.name)
.for_update()
.run()
)
frappe.qb.from_(pi_item).select(pi_item.name).where(conditions).orderby(
pi_item.name
).for_update().run()
frappe.qb.from_(pi_item).select(pi_item.name).where(conditions).for_update().run()
else:
query = query.for_update()

View File

@@ -201,33 +201,6 @@ class TestSerialandBatchBundle(ERPNextTestSuite):
self.assertEqual(flt(stock_value_difference, 2), -5000)
def test_outward_batch_valuation_takes_transaction_advisory_lock(self):
if frappe.db.db_type != "postgres":
return
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
item_code = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-ADV-LCK-.#####",
"is_stock_item": 1,
},
).name
make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=5, rate=100)
def held_advisory_locks():
return frappe.db.sql(
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND pid = pg_backend_pid()"
)[0][0]
before = held_advisory_locks()
create_delivery_note(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=2, rate=200)
self.assertGreater(held_advisory_locks(), before)
def test_old_batch_valuation(self):
frappe.flags.ignore_serial_batch_bundle_validation = True
frappe.flags.use_serial_and_batch_fields = True

View File

@@ -716,19 +716,10 @@ def get_available_qty_to_reserve(
conditions &= sre.name != ignore_sre
# Lock the rows being aggregated so a concurrent reservation can't change them mid-transaction.
# MariaDB carries the lock on the aggregate query itself (its gap locks also serialize two
# FIRST reservations, when no SRE rows exist yet); postgres has no gap locks, so gate on the
# Bin row (exists once there is stock), then lock the matching SREs in a plain SELECT.
# MariaDB carries the lock on the aggregate query itself; postgres rejects FOR UPDATE with an
# aggregate, so on postgres lock the same rows in a separate plain SELECT first (held for the txn).
if frappe.db.db_type == "postgres":
bin_table = frappe.qb.DocType("Bin")
(
frappe.qb.from_(bin_table)
.select(bin_table.name)
.where((bin_table.item_code == item_code) & (bin_table.warehouse == warehouse))
.for_update()
.run()
)
frappe.qb.from_(sre).select(sre.name).where(conditions).orderby(sre.name).for_update().run()
frappe.qb.from_(sre).select(sre.name).where(conditions).for_update().run()
query = (
frappe.qb.from_(sre)

View File

@@ -821,15 +821,6 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount"
)
else:
# Serialize concurrent valuations of this (item, warehouse) on postgres. MariaDB's
# grouped FOR UPDATE + gap locks do this via the history reads below; postgres has no
# gap locks, and row-locking the whole history writes a lock marker on every tuple --
# a txn-scoped advisory lock (released at commit/rollback) serializes without either.
if frappe.db.db_type == "postgres":
frappe.db.transaction_advisory_lock(
("batch-valuation", self.sle.item_code, self.sle.warehouse)
)
entries = self.get_batch_stock_before_date()
self.stock_value_change = 0.0
self.batch_avg_rate = defaultdict(float)
@@ -878,9 +869,12 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
if timestamp_condition:
conditions &= timestamp_condition
# MariaDB carries a row lock on the grouped query below; on postgres the caller
# (calculate_avg_rate) serializes via a txn-scoped advisory lock on (item, warehouse)
# instead of row-locking the whole history (FOR UPDATE is invalid with GROUP BY there).
# Lock the scanned rows so a concurrent stock transaction can't change them mid-valuation.
# MariaDB carries the lock on the grouped query; postgres rejects FOR UPDATE with GROUP BY, so
# lock the same rows in a separate plain SELECT first (held for the transaction).
if frappe.db.db_type == "postgres":
frappe.qb.from_(child).select(child.name).where(conditions).for_update().run()
query = (
frappe.qb.from_(child)
.select(
@@ -1567,7 +1561,7 @@ def update_batch_qty(voucher_type, voucher_no, docstatus, via_landed_cost_vouche
return
precision = frappe.get_precision("Batch", "batch_qty")
for batch, qty in sorted(batches.items()):
for batch, qty in batches.items():
current_qty = get_batch_current_qty(batch)
current_qty += flt(qty, precision) * (-1 if docstatus == 2 else 1)

View File

@@ -1,4 +1,78 @@
{
"allowed_users": [
{
"user": "Administrator"
},
{
"user": "Guest"
},
{
"user": "accounts@test.com"
},
{
"user": "ankush@erpnext.com"
},
{
"user": "faris@erpnext.com"
},
{
"user": "mention_test_user@example.com"
},
{
"user": "project@frappe.io"
},
{
"user": "rushabh@erpnext.com"
},
{
"user": "saqib@erpnext.com"
},
{
"user": "soham@frappe.io"
},
{
"user": "sohamengineer123@gmail.com"
},
{
"user": "sohamkulkarns9@gmail.com"
},
{
"user": "stock@xyz.com"
},
{
"user": "sydel@frappe.io"
},
{
"user": "test'5@example.com"
},
{
"user": "test1@example.com"
},
{
"user": "test2@example.com"
},
{
"user": "test3@example.com"
},
{
"user": "test4@example.com"
},
{
"user": "test@example.com"
},
{
"user": "test@portal.com"
},
{
"user": "testpassword@example.com"
},
{
"user": "testperm@example.com"
},
{
"user": "web@web.com"
}
],
"app": "erpnext",
"charts": [
{
@@ -95,16 +169,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Item Standard Cost",
"link_count": 0,
"link_to": "Item Standard Cost",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -799,7 +863,7 @@
"type": "Link"
}
],
"modified": "2026-07-05 12:08:07.187999",
"modified": "2026-07-03 12:11:34.739020",
"modified_by": "Administrator",
"module": "Stock",
"module_onboarding": "Stock Onboarding",
@@ -1151,19 +1215,6 @@
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Item Standard Cost",
"link_to": "Item Standard Cost",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,