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.
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).
RUNTIME BUG (caught by live smoke tests): the global auth dependency
queried the request-scoped session, auto-beginning its transaction — any
endpoint opening 'async with db.begin()' afterwards crashed with
'transaction already begun' (POST /materials 500). get_current_user /
get_actor now resolve in a short-lived session (expire_on_commit=False
keeps the context usable detached); inventory/pm scopes keep passing the
request session explicitly.
Smoke suites rewritten against CURRENT contracts:
- assertions are behavior-based (shapes, envelopes, filters), no magic
seed counts; dynamic lookups instead of hard-coded names/ids
- rerun-tolerant: tolerate 409-on-create by reusing prior artifacts
- aligned with current rules: revert blocked after inventory receipts
(v2.6), attachments allowed in every status, termsheets optional at
quote level
- SMOKE_USER/SMOKE_PASSWORD env for login (default tester/123456)
Verified on a fresh volume: migration + audit_log bootstrap, Casdoor
seed (bcrypt), demo data, and 29+76+61 smoke checks all green.
The topic grids (PM system + wizard) showed extra categories like «سیم»,
«کابل», «ثبثب», «dsfg». Those rows were leftover junk in material_categories
from earlier testing — previously invisible because the frontend hardcoded
the 8-topic list; the API-driven topic grid surfaced them. The rows are
deleted (they had no references) and the seeder now deletes any category
outside the 8 canonical slugs, so a re-seed always yields exactly the same
8 categories as before. Dump refreshed from the clean state.
- tools/seed.py: wipes app tables and seeds a clean realistic demo (3
projects x 2 warehouses, 4 suppliers, 12 materials across all 8 topic
categories, 4 quotes covering offered / in-transit / partial / completed
with receipts + matching stock); user roles bound to Casdoor identities
by username (seed_casdoor.py first)
- expand_service: the line-item receipt aggregate now carries the warehouse
allocations (target warehouse per received part) for the ledger's
«رسید شده» split
- inventory_service: FOUND («پیدا شده») is guarded so recovery cannot exceed
the missing balance for the same (warehouse, material, brand, specs) —
422 with the remaining balance; STOCK_COUNT semantics unchanged (absolute
counted qty, auto-diff)
- pm.py/inventory.py: remove db.begin() on already-begun sessions — the
scope dependencies read from the session first, so opening a new
transaction raised InvalidRequestError -> 500 on every receipt/transaction
(JWT path); get_db already commits/rolls back atomically
- pm.py: receipt response returns the real derived state (was hardcoded
'arrived'); dev-user creation is race-free (insert on conflict)
- expand_service: aggregate 1:N receipts per line item (received_qty sum +
count) so the ledger can show partial-receipt totals
- users.py: role changes are admin-only (RBAC); new POST /users/sync mirrors
Casdoor org users into the app users table (identity only, roles untouched)
- config/compose: CASDOOR_ADMIN_USER/PASSWORD for the sync endpoint
- seed_casdoor: redirect URI updated for the Traefik edge
- seed_casdoor.py: JSON type=login + cookie session (the old form-encoded
login-normal API is gone), application with grantTypes incl. password,
passwordType=plain for the seeded users, idempotent re-runs; adds a
dedicated tester user
- docker-compose: Casdoor env for the api service (endpoint casdoor:8000
in-network, issuer http://localhost:8080 as advertised by discovery) and
the DEV_LOGIN_ENABLED build arg for the frontend