Compare commits

..

11 Commits

Author SHA1 Message Date
rohitwaghchaure
607f0e943f fix: workspace for stock and manufacturing (#56906) 2026-07-06 11:48:15 +00:00
MochaMind
be93530b5f fix: sync translations from crowdin (#56866) 2026-07-06 10:49:55 +02:00
MochaMind
0688cedba2 chore: update POT file (#56899) 2026-07-05 20:29:59 +02:00
Mihir Kandoi
8aeca5922c Merge pull request #56905 from mihir-kandoi/pg-lock-races-and-advisory-valuation
fix(stock): close postgres locking races; gate batch valuation with a txn advisory lock
2026-07-05 22:42:18 +05:30
Mihir Kandoi
2ec469257e perf(stock): gate batch valuation with a txn advisory lock on postgres
On postgres, outward batch valuation row-locked the item's ENTIRE SLE / Serial and Batch Entry history (a separate plain SELECT ... FOR UPDATE per site, since FOR UPDATE is invalid with GROUP BY there). That writes a lock marker (xmax + WAL) on every historical tuple per outward movement - write amplification that grows with history forever - and locks nothing at all when the history is empty (negative-stock edge: two concurrent outwards don't serialize).

Replace all four history-wide postgres lock statements with one frappe.db.transaction_advisory_lock(("batch-valuation", item_code, warehouse)) at the top of BatchNoValuation.calculate_avg_rate's outward branch - every valuation read (bundle + the three deprecated paths) is downstream of it. Released at commit/rollback, participates in deadlock detection, and serializes regardless of history size, so the empty-history edge is closed by construction. Batch qty updates iterate in sorted order so concurrent vouchers lock Batch rows in the same sequence.

MariaDB is unchanged: it keeps the original grouped FOR UPDATE row locks (and its gap locks). Requires frappe#40621.

Test: outward delivery of a batched item must leave the xact advisory lock visible in pg_locks for the submitting transaction.
2026-07-05 22:31:45 +05:30
Mihir Kandoi
db58858c68 fix(stock): close postgres lock-then-read races in pick list and stock reservation
Postgres has no gap locks, so the lock-then-read pattern (plain SELECT ... FOR UPDATE before a grouped read) only serializes on rows that already exist. Two sites had reachable races where the lock set is empty or disjoint:

- Pick list: two pick lists against the same SO item submitted concurrently lock only docstatus=1 rows, so with no previously-submitted picks their lock sets are disjoint and both pass validate_picked_qty (over-pick; picked_qty last-writer-wins). Gate on the referenced Sales Order Item / Packed Item rows, which always exist.
- Stock reservation: the first concurrent reservations for an (item, warehouse) find no SRE rows to lock, so both pass and reserved qty can exceed actual. Gate on the Bin row, which exists once there is stock.

MariaDB is unchanged (its gap locks already serialize both; the gates are postgres-only). Also: ORDER BY on the small-set postgres lock selects for deterministic lock order, and the repost pre-lock in get_future_stock_vouchers selects a constant instead of shipping every matching SLE name to the client.
2026-07-05 22:31:29 +05:30
Nabin Hait
8abcb7decc Merge pull request #56301 from frappe/chore/payment-entry-purchase-coverage
test: purchase-side Payment Entry allocation coverage
2026-07-05 19:39:38 +05:30
Mihir Kandoi
c68bf89924 Merge pull request #56903 from mihir-kandoi/pg-second-order-groupby-docs
docs: catalog second-order GROUP BY traps (wrap-is-the-bug classes)
2026-07-05 19:11:14 +05:30
Mihir Kandoi
0922d85bf0 docs: catalog second-order GROUP BY traps (wrap-is-the-bug classes)
A full audit of the loose-GROUP-BY fixes found four recurring mistakes in the fixes themselves: incoherent Max/Min pairs over coupled columns, NULL-skipping Max on discriminators, Sum(x)*Max(y) fabricated arithmetic, and wrong-bound picks. Add them to the compatibility catalog (new 3.1) and to the Greptile review instructions so future PRs get flagged.
2026-07-05 19:00:47 +05:30
Nabin Hait
7d8d1eaec7 test: submit overpaid payment for GL coverage and sync received_amount 2026-07-02 14:15:38 +05:30
Nabin Hait
ac8b3f18c7 test: add purchase-side Payment Entry allocation coverage
- pay multiple purchase invoices with a single Payment Entry
- unallocated (advance) amount when a supplier payment is overpaid
- allocating more than a purchase invoice's outstanding amount is rejected
2026-06-22 14:13:18 +05:30
48 changed files with 82554 additions and 57305 deletions

View File

@@ -150,6 +150,34 @@ 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,6 +246,62 @@ 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,11 +422,6 @@ 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_"):
@@ -438,7 +433,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_") and col.get("fieldname") != "budget_against"
if col.get("fieldname", "").startswith("budget_")
]
budget_values = [0] * len(budget_fields)

View File

@@ -13,6 +13,7 @@ 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 (
@@ -1791,9 +1792,10 @@ 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.
# 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.
if frappe.db.db_type == "postgres":
frappe.qb.from_(SLE).select(SLE.name).where(conditions).for_update().run()
frappe.qb.from_(SLE).select(ConstantColumn(1)).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-03 13:44:07.420267",
"modified": "2026-07-05 16:32:01.858579",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",
@@ -463,6 +463,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "house",
"indent": 0,
"keep_closed": 0,
@@ -476,6 +477,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
@@ -489,6 +491,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
@@ -502,6 +505,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "factory",
"indent": 0,
"keep_closed": 0,
@@ -515,6 +519,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "person-standing",
"indent": 0,
"keep_closed": 0,
@@ -528,6 +533,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "package",
"indent": 0,
"keep_closed": 0,
@@ -541,6 +547,20 @@
{
"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,
@@ -553,6 +573,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -566,6 +587,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Plan",
@@ -578,6 +600,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -591,6 +614,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Master Production Schedule",
@@ -603,6 +627,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Sales Forecast",
@@ -615,6 +640,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -627,6 +653,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "wrench",
"indent": 1,
"keep_closed": 1,
@@ -639,6 +666,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Creator",
@@ -651,6 +679,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Update Tool",
@@ -663,6 +692,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Comparison Tool",
@@ -675,6 +705,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Entry",
@@ -687,6 +718,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 1,
"keep_closed": 1,
@@ -699,6 +731,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
@@ -711,6 +744,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Summary",
@@ -723,6 +757,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Summary",
@@ -735,6 +770,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Analysis",
@@ -747,6 +783,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Job Card Summary",
@@ -759,6 +796,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Search",
@@ -771,6 +809,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Production Analytics",
@@ -783,6 +822,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "BOM Operations Time",
@@ -795,6 +835,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Consumed Materials",
@@ -807,6 +848,7 @@
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "database",
"indent": 1,
"keep_closed": 1,
@@ -819,6 +861,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -832,6 +875,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -845,6 +889,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Operation",
@@ -857,6 +902,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
@@ -870,6 +916,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Workstation Type",
@@ -882,6 +929,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Plant Floor",
@@ -894,6 +942,7 @@
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Routing",
@@ -906,6 +955,7 @@
{
"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("home", "sm")}
${frappe.utils.icon("house", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-refresh" title="${__("Refresh")} (r)">
${frappe.utils.icon("refresh", "sm")}
${frappe.utils.icon("refresh-cw", "sm")}
</button>
<button class="btn btn-default btn-sm sf-btn-scan" title="${__("Scan Job Card")} (b)">
${frappe.utils.icon("scan", "sm")}
@@ -100,6 +100,9 @@ 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");
@@ -154,7 +157,7 @@ class ShopFloor {
if (this.view === "manager") {
this.topbar_left.html(`
<span class="sf-title">${__("Shop Floor")}</span>
<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>
${toggle}
<div class="sf-tabs">
${MANAGER_BUCKETS.map(
@@ -191,7 +194,9 @@ class ShopFloor {
this.toggle_job_cards_only(e.target.checked);
});
} else {
this.topbar_left.html(`<span class="sf-title">${__("Shop Floor")}</span>${toggle}`);
this.topbar_left.html(
`<span class="sf-title">${this.brand_icon}${__("Shop Floor")}</span>${toggle}`
);
this.build_operator_filters();
}
@@ -466,11 +471,19 @@ 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) {
@@ -1570,7 +1583,8 @@ 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); }
.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-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; }
@@ -1584,6 +1598,7 @@ 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; }
@@ -1637,10 +1652,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 2px var(--primary-color, var(--primary)); }
.sf-wo-card.sf-focused { box-shadow: 0 0 0 1px 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 3px var(--blue-500, #2490ef);
box-shadow: 0 0 0 1px var(--blue-500, #2490ef);
border-color: transparent;
}
.sf-wo-top { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
@@ -1666,7 +1681,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-300, #d1d5db); overflow: hidden; }
.sf-progress { display: flex; height: 10px; border-radius: 6px; background: var(--gray-200, #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,7 +83,11 @@
/* 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 2px var(--primary); border-radius: var(--border-radius); outline: none; }
[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;
}
.mes-job {
border: 1px solid var(--border-color);
background: var(--fg-color);

View File

@@ -144,12 +144,8 @@ class DeprecatedBatchNoValuation:
if self.sle.name:
conditions &= sle.name != self.sle.name
# 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()
# 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).
query = (
frappe.qb.from_(sle)
.select(
@@ -269,13 +265,8 @@ class DeprecatedBatchNoValuation:
if self.sle.name:
conditions &= sle.name != self.sle.name
# 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()
# 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).
query = (
frappe.qb.from_(sle)
.inner_join(batch)
@@ -402,21 +393,8 @@ class DeprecatedBatchNoValuation:
conditions &= bundle.name != self.sle.serial_and_batch_bundle
conditions &= bundle.voucher_type != "Pick List"
# 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()
)
# 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).
query = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)

View File

@@ -33,5 +33,6 @@ 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,10 +952,22 @@ 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; postgres rejects FOR UPDATE with GROUP BY, so lock the same rows
# in a separate plain SELECT first (held for the transaction).
# 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.
if frappe.db.db_type == "postgres":
frappe.qb.from_(pi_item).select(pi_item.name).where(conditions).for_update().run()
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()
else:
query = query.for_update()

View File

@@ -201,6 +201,33 @@ 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,10 +716,19 @@ 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; postgres rejects FOR UPDATE with an
# aggregate, so on postgres lock the same rows in a separate plain SELECT first (held for the txn).
# 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.
if frappe.db.db_type == "postgres":
frappe.qb.from_(sre).select(sre.name).where(conditions).for_update().run()
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()
query = (
frappe.qb.from_(sre)

View File

@@ -821,6 +821,15 @@ 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)
@@ -869,12 +878,9 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
if timestamp_condition:
conditions &= timestamp_condition
# 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()
# 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).
query = (
frappe.qb.from_(child)
.select(
@@ -1561,7 +1567,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 batches.items():
for batch, qty in sorted(batches.items()):
current_qty = get_batch_current_qty(batch)
current_qty += flt(qty, precision) * (-1 if docstatus == 2 else 1)

View File

@@ -1,78 +1,4 @@
{
"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": [
{
@@ -169,6 +95,16 @@
"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,
@@ -863,7 +799,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 12:11:34.739020",
"modified": "2026-07-05 12:08:07.187999",
"modified_by": "Administrator",
"module": "Stock",
"module_onboarding": "Stock Onboarding",
@@ -1215,6 +1151,19 @@
"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,