mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-06 23:17:57 +00:00
Compare commits
1 Commits
develop
...
fix-budget
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48418eadb0 |
28
.github/POSTGRES_COMPATIBILITY.md
vendored
28
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
3665
erpnext/locale/ar.po
3665
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/bg.po
3659
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
3685
erpnext/locale/bs.po
3685
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/cs.po
3661
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/da.po
3659
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/de.po
3665
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/eo.po
3665
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/es.po
3663
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/fa.po
3665
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/fr.po
3663
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/hi.po
3659
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3681
erpnext/locale/hr.po
3681
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/hu.po
3661
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/id.po
3661
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/it.po
3661
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/ko.po
3661
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/my.po
3659
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/nb.po
3659
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/nl.po
3663
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
3659
erpnext/locale/pl.po
3659
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/pt.po
3661
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/ru.po
3665
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3661
erpnext/locale/sl.po
3661
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/sr.po
3665
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3769
erpnext/locale/sv.po
3769
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/th.po
3665
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
3663
erpnext/locale/tr.po
3663
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
22141
erpnext/locale/uz.po
22141
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/vi.po
3665
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
3665
erpnext/locale/zh.po
3665
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user