Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).
Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
the inventory serializer lookup) and the material category on /pm/on-the-way;
the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
unit_conversions tables (idempotent) and their CREATE statements; seed.py
truncate list updated
Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
refConverted, the categoryUnits + unitConversions store slices, api clients,
hooks and the "≈ converted quantity" line on quote detail and project pages
(it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
plus the lookup's categories fetch and catName helper
Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).
Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
Conflict in init_db.py resolved keeping the audit_log table ensure
function (new-one) — prod_v5 predates it and its side was empty.
Both sides' SQL lands: audit_log table plus the category-free materials
model.
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.
Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.
Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.
Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.
Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.
Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup
Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned
Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »
Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.
- new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids
- replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached
- delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials
- line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material
- storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object)
Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows
Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])
Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread
Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.
Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings
Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item
Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals
Verified: python compile + api/frontend image builds (tsc + vite) clean.
Multi-project quotes: the manager picks one project per line item in the
accept modal (item_projects), but accept_quote only auto-filled
decision.project_id from existing line_item_projects (≤1). For items
without a single existing line-project the decision stayed NULL, so
/ pm/on-the-way (filtered by ItemDecision.project_id) never showed those
items to any project manager — they accumulated only in the global admin
list. Now derivation order is: explicit decision.project_id →
payload.item_projects (single) → existing line_item_projects (single) →
NULL (office-only, never misattributed). Verified E2E: two items of one
quote accepted to two projects appear in exactly their own PM's
on-the-way.