74 Commits
Author SHA1 Message Date
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
Milad e9fef5a104 feat(pm-panel): pm panel level gating — RequirePmPanel/HomeRoute/menu honor panels.pm (full/limited); pm-only users land directly in the PM panel 2026-09-29 13:45:37 +03:30
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30
Milad c7d860f431 feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon 2026-09-27 18:42:05 +03:30
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
Behrad 49dd159fb1 feat: improve line items layout, number inputs and supplier click-to-call 2026-09-22 23:06:17 +03:30
Behrad 972b1b49cb fix(dashboard): restore expand toggle on mobile quote cards and add explicit expand action bar 2026-09-21 19:57:15 +03:30
Behrad b176f0f5ff fix(wizard): optimize stepper and line items layout for mobile 2026-09-21 19:39:12 +03:30
Behrad 9386d4846f feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal 2026-09-21 19:26:52 +03:30
ZCode d69ec7f7a9 fix(analytics): the Track API payload field is 'name', not 'event'
Second half of the envelope fix: ITrackPayload is {name, properties, profileId} — sending
'event' is rejected with 400 "payload/name Invalid input: expected string", which the
module swallows by design. Verified against packages/validation/src/track.validation.ts
and the SDK's track(name, properties) signature.
2026-09-21 09:16:21 +00:00
ZCode 34290b9371 fix(analytics): declare the script build arg and send the Track API's real envelope
Two blockers found while wiring OpenPanel for real:

- frontend/Dockerfile declared ARG for VITE_OPENPANEL_URL/CLIENT_ID/SENTRY_DSN but not
  VITE_OPENPANEL_SCRIPT_URL, so passing it as a build arg was silently ignored and the
  loader compiled out (import.meta.env value undefined).
- backend/app/services/analytics.py posted a flat {type, event, properties} body; the
  Track API expects {type, payload:{event, properties, profileId}} (verified against the
  SDK source in packages/sdks/sdk). The API answered 400 and the module swallows every
  error by design, so all server-side events were being dropped without a trace.

Verified after deploy: the bundle carries the client id + op1.js URL + the queueing shim,
op1.js serves 200, and a track event posted with the real envelope lands in OpenPanel.
2026-09-21 09:15:02 +00:00
ZCode cff35e3660 fix(analytics): actually load the OpenPanel browser script
analytics.ts called window.op?.(…) but nothing ever defined that global — no script tag
in index.html and @openpanel/web was never imported — so every call was a silent no-op
and product analytics would have stayed empty no matter how the build args were set.

Now, when VITE_OPENPANEL_SCRIPT_URL is configured, the loader installs OpenPanel's
documented queueing shim (window.op becomes a queue) and appends op1.js async; the
existing op("init"/"identify"/"track") calls are queued and drained when it loads, so
ordering cannot break them. Without the URL the behaviour is unchanged (no-op), which
keeps local dev and air-gapped builds silent.

Build args: VITE_OPENPANEL_URL (API base, e.g. https://stats.<host>/api),
VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats.<host>/op1.js).
The client *secret* is deliberately not a frontend arg — it belongs to the API only.
2026-09-21 09:13:27 +00:00
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
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.
2026-09-20 19:44:23 +00:00
ZCode 2fd72fecf7 feat(dashboard): drop the category filter
The category filter is not used: remove it end to end — the toolbar's category
combobox (desktop + mobile drawer), its option list, the active-filter count and
the filterQuotes() clause it drove, plus the now-unused `category` field in the
Filters type. Supplier and status filters are unchanged.

The backend's own quote filtering by category is untouched (it filters on the
quote's supplier category when ?category_id= is passed), so the API keeps its
contract; only the dashboard UI stops offering the filter.
2026-09-20 19:25:19 +00:00
ZCode ae62a0391b fix(catalog): resolve material category through the linked supplier (v2.7 follow-up)
v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.

Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.

Backend:
- expand_service: category_by_material() resolves a material's display category from its
  FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
  the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
  supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.

Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
  rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
  name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
  shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
  composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
  the backend's quote filtering); supplier categories now come from the supplier FK
  instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
  materialOptions' unused "cat" branch.

Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
2026-09-20 18:51:29 +00:00
MMD 7057ac4f96 fix(suppliers): read the supplier's own category instead of inferring it
Supplier topic/categories were derived from quote line items' material
category and topic. The catalog v2.7 refactor decoupled materials from
categories, so that derivation now yields nothing and every supplier lost
its topic/category — the suppliers dashboard rendered without names,
because it groups topic → category → supplier.

The backend already returns the supplier's own category
(category_id FK + nested category{id,slug,name}); adaptSupplier simply
never read it. Now it is the primary source, with quote-derived values and
the local registry still taking precedence when present.

Verified against the live stack: all 7 suppliers return their category
from /suppliers, and the suppliers page renders names again.
2026-09-20 21:01:16 +03:30
MMD 36414863d4 Merge remote-tracking branch 'origin/prod_v2' into integration 2026-09-20 20:35:19 +03:30
MMD baa32a2652 feat(files): preview attachments via short-lived signed URLs
Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.

Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
  with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
  (they leave the global auth dep, so the other six file routes now carry
  it explicitly — nothing is open by omission)

Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
  quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
  canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
  in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
  the modal header

Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
  served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
  hangs when the package mirror is unreachable (502s)
2026-09-20 20:35:16 +03:30
Behrad 14a4a29431 feat(procurement): sync status pipeline cards, add supplier lead time badges, and enhance badge contrast 2026-09-20 15:10:10 +03:30
Behrad d2b9af5278 feat(dashboard): filter quotes by clicking status cards in StatsBar 2026-09-20 14:40:27 +03:30
MMD e637d80b76 Merge latest prod_v2 (FileList preview modal, EditModal tabs) into integration
prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).

- FileList: kept prod_v2's design (icons, preview modal, per-row actions)
  and adapted it — previews now fetch the bytes with a Bearer token and
  use a local objectURL (api.files.blobUrl), with revoke on close/unmount;
  the row download uses the authenticated onDownload.
- EditModal: took prod_v2's tabbed redesign wholesale (it already carried
  our globalSpecs, clean material name and target_project work) and
  converted its FileList to onDownload + loadPreview.
- AcceptModal: converted the remaining urlOf sites and removed duplicated
  file/date blocks the earlier merge had left outside the scroll body.
- api.ts: restored the global handle401 (definition + all four fetch
  sites) alongside the new blobUrl helper.
2026-09-20 14:23:19 +03:30
Behrad 3c7ae80b02 feat(ui): add inline preview modal for invoice files in FileList 2026-09-19 20:09:27 +03:30
Behrad de177f3520 style(dashboard): redesign EditModal with solid light theme and tabs 2026-09-19 18:57:18 +03:30
MMD b6cbcc30a5 Merge prod_v5 (catalog v2.7, warehouse locking, per-project accept) into integration
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.
2026-09-19 18:36:29 +03:30
MMD 08ec341c52 Merge prod_v2 (AcceptModal/MilestoneRow layout) into integration
Conflicts resolved keeping the hardened/current side:
- docker-compose.yml: kept new-one's db-backup service, hardened env
  (ENVIRONMENT/DEV_LOGIN/CORS/issuer var) and build args; prod_v2's
  8083/8085 dev port remaps dropped as they conflict with the hardened
  Casdoor issuer expectation.
- AcceptModal.tsx: kept the per-item target_project model (43ff048) over
  prod_v2's older projUnion candidate-project logic, kept the
  authenticated file upload/download blocks, and adopted prod_v2's
  sticky footer.
2026-09-19 18:36:08 +03:30
Behrad b5dc3e585a fix(ui): fix layout alignment in AcceptModal and MilestoneRow 2026-09-19 17:44:21 +03:30
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
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.
2026-09-19 15:11:57 +03:30
whqreza b1edf6eed3 feat(inventory)!: lock warehouses permanently to their parent project
Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.

Backend: PATCH /inventory/warehouses/{id} no longer applies project_id — any attempt to move a warehouse to a different project returns 400 with «پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد» (sending the current project stays a harmless no-op). The WarehouseUpdate schema documents project_id as accept-only-to-reject; creation still requires project_id and delete is unchanged.

Frontend: the Organization page's per-warehouse project dropdown («انتقال به پروژه دیگر») and its moveWarehouse handler are removed — warehouses render statically inside their project card with only the dashboard link and delete; the warehouses.update API type no longer accepts project_id. WarehousesPage already had no move UI (rename/describe only).

Verified end-to-end against the running stack: rename 200; same-project project_id no-op 200; cross-project reassignment 400 with the guard message; warehouse remains in its original project; test data cleaned up. python compile + api/frontend builds clean.
2026-09-19 14:32:46 +03:30
whqreza a181a8753d feat(quotes,units): official-invoice badge, conversion text wrapping, seeded fixed conversions, universal proforma, EditModal fixes
1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial.

2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line.

3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart.

4) Universal proforma attachments: the Edit modal now has a پیش‌فاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere.

5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly.

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
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).
2026-09-19 13:19:26 +03:30
MMD ca55192311 docs(monitoring): Docker Desktop local-run notes (host-metrics profile) 2026-09-19 01:03:29 +03:30
MMD 948727d73b fix(monitoring): Linux-only exporters behind host-metrics profile; loki 3.5 key; prometheus joins shared network
- node-exporter + cadvisor need Linux rootfs mounts (fail on Docker
  Desktop) — moved to 'host-metrics' profile; VPS command updated
- loki 3.5: ingestion_burstsize_mb → ingestion_burst_size_mb
- prometheus joins erp_shared so job 'erp-api' resolves (verified: up=1)
2026-09-19 01:02:09 +03:30
MMD e1495afee9 fix(auth): auth deps use their own DB session; smoke suites made dataset-independent
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.
2026-09-19 00:49:15 +03:30
MMD e03d94207b fix(compose): duplicate networks block on api service; overridable NPM_REGISTRY build arg 2026-09-19 00:11:24 +03:30
MMD 8eddb74951 chore(frontend): lockfile for @openpanel/web + @sentry/react (verified: tsc -b && vite build pass) 2026-09-18 23:54:11 +03:30
MMD 9364915be0 docs: operations runbook, README verify section, frontend env example
- docs/OPERATIONS.md: deploy flow + rollback, health endpoints, backup/
  restore procedure, audit queries, monitoring stack routine
- README: verification now points at readiness/metrics endpoints and the
  authenticated smoke tests (bare quotes curl is 401 by design)
- frontend/.env.example: observability vars documented
2026-09-18 22:30:16 +03:30
MMD 32323565e6 feat(hardening): login rate limiting, per-service memory limits, pinned images
- slowapi limiter on POST /auth/login (10/min per IP, Persian error msg);
  edge-level coarse limits stay in Traefik
- mem_limit on every main-compose service (~4.4GB worst case, protects the
  8GB shared host during rebuilds)
- monitoring images pinned to verified tags (prom v3.4.1, grafana 11.6.3,
  loki/promtail 3.5.0, node-exporter v1.9.1, postgres-exporter v0.17.1,
  cadvisor v0.49.1); exporter image name fixed to postgres-exporter
2026-09-18 22:27:59 +03:30
MMD e5d6c77c3c feat(data-safety): scheduled DB backups + audit trail + verified actor attribution
- db-backup sidecar: pg_dump -Fc of procurement+casdoor every
  BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS
- audit_log table (migration.sql + runtime ensure_audit_table) with
  model + transactional helper (utils/audit.record) — wired into user
  create/role-change and inventory transaction creation
- get_actor: quote_events.actor now stores the VERIFIED user's display
  name instead of the raw bearer token
2026-09-18 22:23:34 +03:30
MMD e17d1f0883 feat(monitoring): observability stack + app instrumentation
- /health/ready (DB+MinIO+Casdoor checks, webhook post-deploy gate),
  /metrics via prometheus-fastapi-instrumentator, Sentry/GlitchTip capture,
  X-Request-ID middleware + JSON access logging (PROCUREMENT_LOG_FORMAT)
- server-side analytics (OpenPanel Track API): login, quote lifecycle,
  receipts — fire-and-forget, disabled without env config
- monitoring/ compose project: GlitchTip(+pg), Prometheus(+alerts),
  Grafana(provisioned ERP dashboard), Loki+Promtail, node/postgres
  exporters, cAdvisor — mem-limited (~2.2GB), loopback admin UIs,
  separate from app deploys; erp_shared network for scraping
- frontend: @openpanel/web + @sentry/react (build-arg gated, no-op when
  unset), funnel events (login, wizard steps, quote submitted)
- compose: api healthcheck, observability env wiring, .env.example keys
2026-09-18 22:18:02 +03:30
MMD f7b1f7f751 feat(security): JWT required on every API route, gated dev fallback, role checks
- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
2026-09-18 22:02:09 +03:30
MMD 43e317eeba chore(security): remove committed secrets/dumps, .env-based config, hardened deploy webhook
- Delete backups/*.dump and all real credentials from README/docs
  (prod MinIO creds, Casdoor passwords, server IP, admin password)
- All stack secrets now come from git-ignored .env (.env.example added);
  docker-compose.prod.yml refuses to boot without them (${VAR:?})
- seed_casdoor.py: bcrypt password hashing, generated console/client secrets
- Harden infra/deploy-webhook: HMAC-verified Gitea webhook receiver
  + systemd unit + README (replaces unauthenticated deploy endpoint)
- docs/SECRET-MANAGEMENT.md: secret inventory + rotation checklist
2026-09-18 21:55:24 +03:30
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
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.
2026-09-17 18:04:57 +03:30
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
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).
2026-09-17 16:13:27 +03:30
Behrad c395fba8dd fix(inventory): replace native select with touch chips in ChangeInventoryPage 2026-09-17 14:04:01 +03:30
Behrad 66216c2b2f fix(inventory): optimize mobile layout with card view and chip filters 2026-09-17 13:53:24 +03:30
Behrad 435e6cbb21 fix(procurement): improve mobile layout for QuoteCard and EditModal 2026-09-17 13:19:14 +03:30
MMD 7b3ae3cb60 feat(wizard): cache مشخصه globally instead of per topic/category/brand
The specs combobox drew only from materials in the selected topic, further
narrowed by the row's category and brand, so a spec was offered only when
some material in that topic shared the same category AND brand. On a new
quote the user therefore lost specs recorded in earlier quotes.

- api.specsList(): new global source reading the attribute-definitions
  registry (topic/category/brand independent)
- materialOptions('spec'): unions the registry, specs of all materials,
  and specs typed in sibling rows — no topic/category/brand filter
- EditModal: same global registry added to its spec options

Verified the registry endpoint returns labels and that the union includes
specs absent from material attributes (e.g. 'مسی').
2026-09-16 20:31:01 +03:30
MMD ae977e5e66 fix(wizard): store material name cleanly instead of brand+category+specs
Adding a new item wrote the material name as 'brand + category + specs'
(e.g. 'پارس لوله لوله PE100 سایز ۱۶'), while specs were also stored
correctly in EAV attributes. The wizard then tried to recover the base
name by stripping brand and specs out of that string, but never stripped
the injected category — so the spec stayed baked into the cached material
name and the مشخصه appeared as part of the material name on later visits.

- api.ts / WizardPage / EditModal: build name from the material itself
  only (brand goes to brand_ids, specs to attributes)
- materialBaseName: strip category too, never strip a part that would
  empty the name, and reject a spec-only result; legacy rows whose name
  cannot be disentangled fall back to the stored name rather than a guess
- WizardPage: match existing materials on category+brand so legacy rows
  are still reused instead of duplicated
2026-09-16 20:24:45 +03:30
MMD b65938ef06 Merge prod_v3 (quote locking, per-item target project, title dedup) into production
Brings in prod_v3 5084623 on top of production 9e8a236. Merged without
conflicts: prod_v3's base (7ead60c) is an ancestor of production, and
production's extra tab-bar refinements (17c7ae6) touch disjoint regions.
2026-09-16 19:43:25 +03:30
whqreza 5084623be5 feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
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.
2026-09-16 19:14:06 +03:30
MMD 9e8a2368ee Merge main_v2 mobile tabbar/inventory UI refinements into production
Brings in main_v2 17c7ae6 (tab bar sizing, inventory actions, date picker
proportions, stats lead time).

Conflicts resolved in favour of main_v2, which deliberately removed the
'تغییر موجودی' top button (nav lives in the tab bar / InventoryTopNav now)
and rewrote the proforma date field. Dropped the now-unused Button import.
2026-09-16 17:42:46 +03:30
Behrad 17c7ae63c8 fix(ui): improve mobile tabbar sizing, inventory actions, date picker proportions and stats lead time 2026-09-16 16:17:57 +03:30
MMD 7ead60cd5c Merge main_v2 mobile UI into production, restore mobile bottom tab bar
Merges main_v2 a110741 (mobile UI optimizations) into production and
reinstates the mobile bottom navigation that f0e4243 had removed.

Conflicts in DashboardPage, QuoteCard and InventoryPage resolved in
favour of main_v2's mobile-first markup, keeping production's newer
design system (Button variant="primary" on InventoryPage).

f0e4243's removal was reverted in effect: MobileTabBar is restored and
remounted in App.tsx, and PageLayout regains max-md:pb-24 clearance so
content is not hidden behind the fixed bar.
2026-09-16 14:14:59 +03:30
Behrad a110741870 feat: mobile UI optimizations 2026-09-16 12:12:28 +03:30
whqreza 1f16e44874 feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly

Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)

Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
2026-09-15 19:23:50 +03:30
whqreza 3f4bbee6de fix(files): downloads with Persian filenames no longer 500
Both download endpoints (invoice/proforma) put the stored file_name raw into the Content-Disposition header. HTTP headers are latin-1, so any non-ASCII name (i.e. every Persian filename) raised UnicodeEncodeError before the body was sent -> HTTP 500. Uploads were unaffected.

Content-Disposition is now built by a helper following RFC 6266 / RFC 5987: the UTF-8 name is percent-encoded in filename*=UTF-8''..., with a header-safe ASCII fallback in filename= (quotes and control characters stripped; a name that folds away entirely becomes attachment<ext>).

Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
2026-09-15 17:38:47 +03:30
whqreza 4e59f6e25c docs(readme): add server section with MinIO tunnel; refresh stale sections
New section 'Working with the deployed system (server)': public app and Casdoor panel URLs (aramesh / casdoor .bennu-api-bahrestan.ir) and step-by-step MinIO console access via an SSH tunnel (ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 -> http://localhost:9001), including the aramesh-minio credentials, the procurement-files bucket, why the console is loopback-only (port 9000 is reserved for the deploy-webhook receiver), the secret-handling warning and the tunnel teardown.

Also refreshed sections that went stale this session: the login form description no longer mentions the removed optional organization field; the restore section now documents the committed admin-only seed dumps in backups/علی محقق/ (the old restore.ps1/sh scripts and demo dumps were deleted) with the current login and the still-available demo seeders.
2026-09-15 16:57:28 +03:30
whqreza 593c149478 feat(auth): add password visibility toggle and remove the unused Organization field
Part 1 — show/hide password: an eye/eye-off button inside the login password input switches its type between password and text. It sits on the right (the input is LTR) with pr-10 clearance so dots never overlap the icon; type=button so a click cannot submit the form, aria-label Show/Hide password, aria-pressed + aria-controls, plus a Persian tooltip.

Part 2 — Organization field removed end-to-end after the audit proved it was a no-op: LoginPage input/state/payload spread (main + dev-mode admin login), api.ts payload type, backend LoginRequest.organization (declared but never forwarded to Casdoor — a bogus org value still logged in successfully), and the casdoor-auth.md flow diagram. The organization actually used remains CASDOOR_ORGANIZATION=inventory for /users/sync.

Verified: api + frontend images rebuilt and redeployed; login works with {username, password}; the served LoginPage chunk contains the toggle and zero org-field leftovers.
2026-09-15 16:29:54 +03:30
MMD e7f465f74f style(ui): maximize topic card images and fix RTL wizard arrows
Frontend-only application of prod_v2 (55e959b, 9e99b46).
Dev docker-compose.yml changes excluded.
2026-09-15 14:30:43 +03:30
MMD 2573f1e583 refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev
docker-compose.yml change (b11cb36) and backups churn excluded.
2026-09-13 18:07:27 +03:30
whqreza a542e4177e chore(backups): add admin-only seed dumps (علی محقق)
Replaces the removed mock-data snapshots with minimal recovery seeds
that provision exactly one super user:

- casdoor.dump: full casdoor schema + built-in console admin (admin/123),
  org 'inventory', app 'inventory-app', the JWT signing cert, and the
  single org user ali.mohaghegh (علی محقق, plain password) — tokens,
  sessions and records cleared.
- procurement.dump: full procurement schema + the single users row
  (علی محقق, role=admin) linked by casdoor_id — all other tables empty;
  init_db re-seeds categories/units at startup.

Login: ali.mohaghegh (Casdoor rejects spaces in usernames, so the
spaceful 'ali mohaghegh' is not a valid login name).

Restore on a fresh volume with:
  docker compose stop api casdoor
  docker cp 'backups/علی محقق/casdoor.dump' procurement-postgres:/tmp/
  docker cp 'backups/علی محقق/procurement.dump' procurement-postgres:/tmp/
  docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
  docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
  docker compose start casdoor api

Both dumps were round-trip validated (pg_restore into scratch DBs:
casdoor = 2 users / 2 orgs / 2 apps / 1 cert; procurement = 1 admin user,
all other tables empty).
2026-09-13 15:46:09 +03:30
whqreza c454b03b4d chore(backups): remove mock-data dumps and restore scripts
The committed casdoor.dump/procurement.dump snapshots carried the demo
dataset (razaei/mousavi/mohaghegh/tester + demo quotes/stock) and the
restore scripts existed only to load them. Recovery now uses the new
admin-only seed dump (see the following commit).
2026-09-13 15:46:09 +03:30
MMD 50f8d8e64e feat(ui): modernize button design system and update Dashboard and Inventory pages
Frontend-only cherry-pick of prod_v2 3bdb05f; the dev compose
api port change (8000 -> 8001) is intentionally excluded.
2026-09-13 00:13:57 +03:30
whqreza 3b032d0460 idk anymore what going on...! 2026-09-10 11:52:48 +00:00
whqreza f263cfb9e3 update docker for coolify 2026-09-10 10:45:18 +00:00
whqreza 4609940856 fix(infra): make the Coolify compose file Linux-safe
docker compose config absolutized bind mounts to the Windows dev path
(C:\Users\Reza\Desktop\...), which cannot exist on the Linux Coolify
server and broke parsing. Also normalized the expanded structures back
to compact, Coolify-friendly syntax:

- bind volume sources are now repo-relative (./sql/..., ./infra/...)
- long-syntax volumes (type/source/target/volume objects) -> short
  'src:target[:ro]'
- long-syntax ports (mode:ingress/target/published/protocol objects) ->
  short 'host:container' strings
- dropped the compose-generated top-level 'name:' key

Verified: 'docker compose -f docker-compose.coolify.yml config' resolves
hosts, TLS, and healthchecks with no warnings; minio-init's escaped
shell variables ($${MINIO_*}) are preserved.
2026-09-08 19:51:55 +03:30
whqreza 3c3880aec9 fix(infra): point PROCUREMENT_CASDOOR_ISSUER at the public auth host
The base file bakes http://localhost:8080 as the JWT issuer; casdoor's
production origin is https://${AUTH_HOST}, so token iss checks would
reject every login. The merged Coolify file now derives the issuer from
${AUTH_HOST} like the casdoor origin.
2026-09-08 18:10:37 +03:30
whqreza 545b5dd473 chore(infra): add merged compose file for Coolify deployment
Coolify applies a single self-contained compose file, but the repo splits
production config across docker-compose.yml (base) and
docker-compose.prod.yml (overlay). This is the result of

  docker compose -f docker-compose.yml -f docker-compose.prod.yml config

with build contexts made relative and the production host variables kept
as ${APP_HOST}/${API_HOST}/${AUTH_HOST}/${S3_HOST}/${STORAGE_HOST}/
${TRAEFIK_ACME_EMAIL} placeholders for Coolify's environment settings
(minio-init's $${MINIO_*} shell escapes are preserved).

Verified: 'docker compose -f docker-compose.coolify.yml config' renders
Host(...) routers, the Let's Encrypt resolver email, and the casdoor
origin (https://${AUTH_HOST}) from sample values.
2026-09-08 18:10:10 +03:30
whqreza 7528cc3bbe chore: ignore the machine-local compose override
docker-compose.override.yml publishes the frontend on an extra host
port for LAN/phone access; it is machine-specific, so compose keeps
auto-loading it locally while other machines' stacks stay unchanged.
2026-09-08 16:04:05 +03:30
whqreza b45a6cd033 fix(inventory): keep Organization page actions within mobile bounds
On /inventory/org two container rows could not shrink or wrap and
overflowed past the left edge on phones (RTL), forcing horizontal
scrolling:

- the add-warehouse form was a non-wrapping flex row of fixed-width
  inputs (w-44 + w-52) plus the button (~500px total); it now stacks
  vertically below sm (full-width fields and button) and returns to the
  inline layout at sm+
- each warehouse row's action cluster (dashboard / move-project select /
  delete) is now flex-wrap with justify-end, and the name block is
  flex-1 so a long select option can no longer push the buttons out of
  the card on narrow viewports

Desktop layout is unchanged; containerized rows stay within the page.
2026-09-08 16:04:02 +03:30
whqreza f0e424316e fix(web): remove the mobile bottom navigation bar
The fixed bottom tab bar (MobileTabBar) was rendered app-wide on
screens <768px, duplicating navigation that already exists in the
header/hamburger menus.

- unmount <MobileTabBar /> in App.tsx and drop its import
- delete components/layout/MobileTabBar.tsx (no remaining references)
- drop the max-md:pb-24 clearance in PageLayout (Header.tsx) that only
  existed to keep page content above the removed fixed bar

Desktop and header-menu navigation are unaffected; mobile pages keep a
full-height viewport without dead bottom padding.
2026-09-08 16:03:56 +03:30
158 changed files with 11197 additions and 4091 deletions
+71
View File
@@ -0,0 +1,71 @@
# ============================================================
# ERP stack configuration — copy to .env and fill in real values.
# .env is git-ignored; NEVER commit real credentials.
# Local dev: cp .env.example .env (defaults work as-is)
# Production VPS: /srv/erp/.env with STRONG values for every entry —
# docker-compose.prod.yml refuses to boot without them.
# ============================================================
# --- Public hosts (production; local defaults live in docker-compose.yml) ---
APP_HOST=aramesh.bennu-api-bahrestan.ir
API_HOST=api.bennu-api-bahrestan.ir
AUTH_HOST=casdoor.bennu-api-bahrestan.ir
S3_HOST=s3.bennu-api-bahrestan.ir
STORAGE_HOST=storage.bennu-api-bahrestan.ir
# --- Traefik / Let's Encrypt ---
TRAEFIK_ACME_EMAIL=you@example.com
# --- Environment: "prod" enables the fail-fast startup checks in the API ---
ENVIRONMENT=prod
# --- PostgreSQL (app + Casdoor share the instance) ---
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
# --- MinIO object storage root credentials ---
MINIO_ROOT_USER=CHANGE_ME_minio_user
MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password
# --- Casdoor OIDC application (must match the Casdoor application record) ---
CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret
CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password
# --- CORS: exact browser origins allowed to call the API (comma-separated) ---
CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
# --- Developer Mode (X-User-* header identity + login panel) ---
# MUST stay false in production. Only for local debugging without tokens.
DEV_LOGIN_ENABLED=false
# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) ---
GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret
# --- Backups (db-backup sidecar; see docker-compose.yml) ---
BACKUP_DIR=/srv/erp/backups
BACKUP_INTERVAL_SECONDS=86400
BACKUP_KEEP_DAYS=14
# --- Monitoring stack (see monitoring/README.md) ---
# API log format for Loki ingestion ("text" locally, "json" in production)
LOG_FORMAT=json
# GlitchTip (error tracking). GLITCHTIP_HOST must be a public DNS name —
# the browser SDK posts errors there.
GLITCHTIP_SECRET_KEY=CHANGE_ME_random_django_secret
GLITCHTIP_DOMAIN=https://errors.bennu-api-bahrestan.ir
GLITCHTIP_HOST=errors.bennu-api-bahrestan.ir
GLITCHTIP_DB_PASSWORD=CHANGE_ME_glitchtip_db_password
GLITCHTIP_FROM_EMAIL=noreply@bennu-api-bahrestan.ir
GLITCHTIP_EMAIL_URL=consolemail://
# Grafana (dashboards) — UI is loopback-only (:3001) unless you add Traefik labels
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password
GRAFANA_ROOT_URL=http://localhost:3001/
# OpenPanel (product analytics) — client id/secret from the OpenPanel project
OPENPANEL_API_URL=http://openpanel-api:3333
OPENPANEL_CLIENT_ID=
OPENPANEL_CLIENT_SECRET=
# Public origin of the OpenPanel dashboard/API the BROWSER talks to
# (e.g. https://stats.bennu-api-bahrestan.ir — see monitoring/README.md)
OPENPANEL_PUBLIC_URL=
# Sentry/GlitchTip DSNs created in the GlitchTip UI after first boot
SENTRY_DSN_BACKEND=
SENTRY_DSN_FRONTEND=
+13
View File
@@ -11,14 +11,27 @@ __pycache__/
venv/
# ---- Env / secrets ----
# All credentials live in git-ignored .env files (server-side and local).
# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md.
.env
.env.*
!*.env.example
backend/.env
frontend/.env
*.local
# ---- Database dumps (contain user rows + signing material) ----
*.dump
*.sql.gz
# ---- Local session / backups ----
.zcode/
v1.2 - Copy/
inv dash/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml
# ---- Screenshots (root) ----
*.png
*.jpg
+101 -33
View File
@@ -81,7 +81,7 @@ v1.3/
## 🔐 Login
The whole system is behind a login page (`/login`). Login is **in-page**:
the form (username / password / optional organization) is sent to
the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
@@ -105,14 +105,78 @@ for production builds).
| What | Where | Credentials |
|---|---|---|
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | `admin` / `123` |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
---
## 🌐 Working with the deployed system (server)
The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
| What | Address |
|---|---|
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
The MinIO console is deliberately **loopback-only** on the server
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
```
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ Those credentials are **full admin access to the file storage**. They live
only in the server-side `.env`; if one ever leaks, rotate it immediately
(checklist in `docs/SECRET-MANAGEMENT.md`).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
---
## Prerequisites (target machine)
@@ -150,15 +214,23 @@ All should show `Up` (postgres also `(healthy)`).
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
| Metrics | http://localhost:8000/metrics |
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
Quick end-to-end check (Traefik → frontend proxy → API → DB):
Automated end-to-end checks (authenticates via `SMOKE_USER` /
`SMOKE_PASSWORD` — the seeder users):
```powershell
curl http://localhost/api/v1/quotes?expand=supplier,project,line_items,termsheets,events,decision,receipt
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
```
> Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
---
## MinIO (object storage for invoice / proforma files)
@@ -197,38 +269,33 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
---
## Restoring the demo data (for the team)
## Recovery / seeding
The databases are **not** in git — Docker volumes hold the data. To let
everyone see the same system state, the repo ships exact database dumps in
`backups/`. After a fresh boot:
The databases are **not** in git — Docker volumes hold the data, and
database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up:
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders:
```powershell
powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1 # Windows
bash backups/restore.sh # Linux / Git Bash
```
This **replaces all data** in the `procurement` and `casdoor` databases with
the committed state — a clean, realistic demo: 3 projects × 2 warehouses,
4 suppliers, 12 materials across all 8 categories, and 4 quotes covering
every lifecycle state (offered / in-transit / partially received /
completed) with matching stock.
Two alternative ways to get a clean state:
1. **App seeder** (realistic demo data, keeps the Casdoor org as-is):
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Casdoor seeder** (creates org `inventory` + app + users razaei /
mousavi / mohaghegh / tester — run once after a fresh volume):
2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
```powershell
python backend/tools/seed_casdoor.py # from the host, or from the api container
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
To create a fresh dump of your own current state: stop `api` + `casdoor`,
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc`
(+ the `casdoor` DB) into `backups/`, then start them again.
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations
@@ -243,10 +310,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol
## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`,
`/inventory` and `/pm` are protected (401 without a token, 403 without a role).
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still
open — dev placeholder, do not expose to the public internet.
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers.
+123 -14
View File
@@ -26,8 +26,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.utils.errors import (
ForbiddenError,
ServiceUnavailableError,
@@ -103,24 +103,32 @@ async def _verify_token(token: str) -> dict:
class UserContext:
user: User
role: str | None
project_id: object | None # uuid.UUID | None
project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str
# لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...}
panels: dict | None = None
# عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM)
member_project_ids: list | None = None
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
"""Auto-provision the `users` row from token claims; resolve role + PM project.
"""Auto-provision the `users` row from token claims; resolve role + PM projects.
First login: creates the row with role NULL (an admin assigns it later).
PMs: resolves their single project via projects.pm_user_id.
PMs: resolves EVERY project they manage (projects.pm_user_id = user);
`project_id` stays the first one (oldest) for backward compatibility.
"""
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
username = str(claims.get("name") or claims.get("preferred_username") or "")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None:
user = User(
casdoor_id=casdoor_id,
username=username or None,
first_name=str(
claims.get("firstName")
or claims.get("firstname")
@@ -133,21 +141,85 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
)
db.add(user)
await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
project_id = None
if user.role == "pm":
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
project_id = project.id if project else None
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
panels: dict = {}
panel_rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
)
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
await db.execute(
select(UserProjectMembership)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
]
pm_projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
return UserContext(
user=user,
role=user.role,
project_id=project_id,
project_ids=project_ids,
display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
)
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
pm_rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
Raises UnauthorizedError for a present-but-invalid token; callers decide
their own fallback only for the no-token case (e.g. dev headers).
IMPORTANT: when ``db`` is omitted (pure auth-dependency usage), a
short-lived session of its own is used — querying the REQUEST session
here would auto-begin its transaction and break endpoints that open
explicit ``async with db.begin()`` blocks afterwards.
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
@@ -155,13 +227,22 @@ async def get_optional_user(request: Request, db: AsyncSession) -> UserContext |
token = auth.split(" ", 1)[1].strip()
if not token:
return None
if db is None:
async with AsyncSessionLocal() as own_session:
claims = await _verify_token(token)
return await context_from_claims(claims, own_session)
claims = await _verify_token(token)
return await context_from_claims(claims, db)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
ctx = await get_optional_user(request, db)
async def get_current_user(request: Request) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned).
Resolves the user in its own session (see get_optional_user) so the
global auth dependency never interferes with an endpoint's explicit
transaction handling.
"""
ctx = await get_optional_user(request)
if ctx is None:
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None:
@@ -176,3 +257,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
return _dep
def require_procurement_writer():
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
(دوره مهاجرت از مدل نقش به مدل پنل)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") == "full":
return ctx
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
return _dep
def allow_quote_create():
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
(محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
return ctx
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
return _dep
+47 -8
View File
@@ -1,7 +1,14 @@
"""Application configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file.
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env``
file. The docker-compose files inject them from the git-ignored ``.env`` —
real credentials never live in this repository.
``ENVIRONMENT`` is the safety switch: when it is ``"prod"``, the API runs
fail-fast startup checks (see app.main._verify_production_settings) that
refuse to boot with wildcard CORS, default credentials or dev login
enabled.
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -14,7 +21,10 @@ class Settings(BaseSettings):
extra="ignore",
)
# PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist.
# "dev" (local defaults allowed) | "prod" (fail-fast checks on startup).
ENVIRONMENT: str = "dev"
# PostgreSQL DSN — the schema (migration.sql) must already exist.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Echo SQL statements to the console (dev aid).
@@ -22,6 +32,8 @@ class Settings(BaseSettings):
# MinIO object storage — invoice / proforma attachments (S3-compatible).
# Endpoint is the compose service name (`minio:9000`) inside Docker.
# Defaults are LOCAL DEV ONLY — production values come from .env and the
# prod startup checks reject them.
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
@@ -36,27 +48,54 @@ class Settings(BaseSettings):
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# CORS (dev-friendly default; the frontend is a static site).
CORS_ORIGINS: list[str] = ["*"]
# CORS — comma-separated exact browser origins (NOT "*" with
# credentials; the prod checks reject wildcard). Example:
# PROCUREMENT_CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
CORS_ORIGINS: str = "http://localhost,http://localhost:3000,http://localhost:5173"
# Developer Mode: in-page dev login panel (frontend build arg) AND the
# X-User-Role / X-User-Name-B64 header identity fallback in the
# inventory/pm routers. MUST stay false in production.
DEV_LOGIN_ENABLED: bool = False
# Casdoor (OIDC) — JWT verification against the issuer's JWKS.
CASDOOR_ENDPOINT: str = "http://localhost:8080"
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
CASDOOR_CLIENT_ID: str = "inventory-app-client"
CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
CASDOOR_CLIENT_SECRET: str = "" # no default secret — set via env/.env
CASDOOR_ORGANIZATION: str = "inventory"
CASDOOR_APP_NAME: str = "inventory-app"
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
# Casdoor console admin — used by POST /users/sync to mirror Casdoor
# identities into the app's users table (dev defaults: admin / 123).
# identities into the app's users table. No default password.
CASDOOR_ADMIN_USER: str = "admin"
CASDOOR_ADMIN_PASSWORD: str = "123"
CASDOOR_ADMIN_PASSWORD: str = ""
# Inventory dev fallback (only when no Authorization header is sent).
# Inventory dev fallback defaults (only used when DEV_LOGIN_ENABLED).
INVENTORY_DEFAULT_ROLE: str = "pm"
INVENTORY_DEFAULT_PROJECT_ID: str = ""
# ---- Observability (Phase 2) -------------------------------------------
# "text" (human console) | "json" (JSON lines for Loki/Promtail).
LOG_FORMAT: str = "text"
# Sentry/GlitchTip DSN — backend exception capture (empty = disabled).
SENTRY_DSN: str = ""
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
# OpenPanel server-side event ingestion (empty = disabled).
OPENPANEL_API_URL: str = "" # e.g. http://openpanel-api:8080 (self-hosted)
OPENPANEL_CLIENT_ID: str = ""
OPENPANEL_CLIENT_SECRET: str = ""
@property
def cors_origin_list(self) -> list[str]:
"""Comma-separated CORS_ORIGINS → list for CORSMiddleware."""
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@property
def is_prod(self) -> bool:
return self.ENVIRONMENT.lower() == "prod"
settings = Settings()
+71 -26
View File
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
_DASHBOARD_DDL = [
# One reference unit per category (units.html «واحد مرجع هر دسته»)
"""
CREATE TABLE IF NOT EXISTS category_reference_units (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
ref_unit TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
"""
CREATE TABLE IF NOT EXISTS unit_conversions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
brand TEXT,
from_unit TEXT NOT NULL,
to_unit TEXT NOT NULL,
factor NUMERIC(18,6) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
""",
@@ -193,7 +174,7 @@ _V26_DDL = [
""",
"""
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"""
@@ -302,6 +283,11 @@ _V26_DDL = [
"""
CREATE INDEX IF NOT EXISTS idx_suppliers_category_id ON suppliers(category_id)
""",
# PM → projects is 1:N (a PM may manage several projects). No UNIQUE on
# pm_user_id — just a lookup index for "projects of this PM" queries.
"""
CREATE INDEX IF NOT EXISTS idx_projects_pm_user_id ON projects(pm_user_id)
""",
# supplier_materials (v2.5) + its triggers (functions are CREATE OR REPLACE below).
"""
CREATE TABLE IF NOT EXISTS supplier_materials (
@@ -340,6 +326,18 @@ BASE_UNITS: list[dict] = [
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
]
# تبدیل‌های ثابت جهانی («تبدیل‌های ثابت — همه متریال‌ها») — ضرایب استاندارد و
# فراگیر که در هر استارت‌آپ به‌صورت idempotent ذخیره می‌شوند. کلید یکتا:
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
# قالبی که فرانت‌اند در standard_conversions می‌نویسد.
STANDARD_CONVERSIONS: list[dict] = [
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
]
@@ -374,16 +372,63 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u,
)
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
# Seed the global fixed conversions (idempotent — unique key from/to unit).
for c in STANDARD_CONVERSIONS:
await run_statement(
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
"VALUES (:from_unit, :to_unit, :factor) "
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
c,
)
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("""
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price
AFTER INSERT OR UPDATE OF status ON item_decisions
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
""")
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
These three tables backed features that were never populated (0 rows on every
volume): per-category reference units and conversion factors (the
per-reference-unit price line) and the category -> attribute mapping. Dropping
them is idempotent and loses no data. material_categories itself stays — every
supplier carries one as its label.
"""
async with engine.begin() as conn:
await conn.execute(text(
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
))
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS audit_log (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor TEXT,
action TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT,
details JSONB NOT NULL DEFAULT '{}'
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id)",
"CREATE INDEX IF NOT EXISTS idx_audit_log_occurred ON audit_log(occurred_at DESC)",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("audit_log table ensured")
+136 -3
View File
@@ -7,30 +7,56 @@
"""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_fastapi_instrumentator import Instrumentator
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text as sql_text
from sqlalchemy.exc import IntegrityError
from app.auth import get_current_user
from app.config import settings
from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import (
ensure_attachment_table,
ensure_audit_table,
ensure_drop_category_tables,
ensure_dashboard_tables,
ensure_material_topic_column,
ensure_quote_projects,
ensure_v26,
)
from app.observability import RequestContextMiddleware, setup_logging
from app.routers import ROUTERS
from app.services.quote_service import auto_expire_quotes
from app.utils.errors import AppError
from app.utils.ratelimit import limiter
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
setup_logging(settings.LOG_FORMAT)
logger = logging.getLogger("procurement")
if settings.SENTRY_DSN:
# GlitchTip/Sentry exception capture — after setup_logging so the SDK's
# own logs go through our formatter too. Disabled entirely without a DSN.
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
environment=settings.ENVIRONMENT,
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
integrations=[FastApiIntegration()],
send_default_pii=False,
)
logger.info("Sentry/GlitchTip exception capture enabled")
API_V1_PREFIX = "/api/v1"
@@ -45,13 +71,42 @@ async def _auto_expire_loop() -> None:
logger.exception("Auto-expiry pass failed")
def _verify_production_settings() -> None:
"""Fail-fast guard: refuse to boot in prod with dev defaults.
These exact misconfigurations are what previously shipped (wildcard CORS
with credentials, default MinIO/Postgres/Casdoor credentials, and the
always-on X-User-Role identity fallback). Ops fix: set the real values
in the server-side .env — see docs/SECRET-MANAGEMENT.md.
"""
problems: list[str] = []
if not settings.cors_origin_list or "*" in settings.cors_origin_list:
problems.append("PROCUREMENT_CORS_ORIGINS must list exact origins (no '*')")
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
problems.append("MinIO credentials are still the default minioadmin")
if not settings.CASDOOR_CLIENT_SECRET:
problems.append("PROCUREMENT_CASDOOR_CLIENT_SECRET is not set")
if not settings.CASDOOR_ADMIN_PASSWORD:
problems.append("PROCUREMENT_CASDOOR_ADMIN_PASSWORD is not set")
if settings.DEV_LOGIN_ENABLED:
problems.append("PROCUREMENT_DEV_LOGIN_ENABLED must be false in production")
if problems:
raise RuntimeError(
"Refusing to start in production — fix the environment:\n - " + "\n - ".join(problems)
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
if settings.is_prod:
_verify_production_settings()
await ensure_dashboard_tables(engine)
await ensure_attachment_table(engine)
await ensure_material_topic_column(engine)
await ensure_quote_projects(engine)
await ensure_v26(engine)
await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try:
yield
@@ -74,11 +129,21 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestContextMiddleware)
# Rate limiting — login brute-force brake (edge rate limit lives in Traefik).
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Prometheus /metrics (requests, latency, errors) — mounted at app level,
# outside the authenticated /api/v1 prefix; scrape target for the
# monitoring stack.
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
# ---------------------------------------------------------------------------
@@ -119,6 +184,7 @@ def _fa_validation_msg(msg: str) -> str:
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_request: Request, exc: RequestValidationError):
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
first = exc.errors()[0]
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
field = ".".join(loc) if loc else None
@@ -157,11 +223,78 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
# JWT auth is required on EVERY router; the auth router stays public
# (POST /auth/login issues the tokens) and /health lives below, outside the
# /api/v1 prefix. Routers with their own finer-grained scopes (inventory,
# pm, users) keep them — the global dependency guarantees nothing is open
# by omission.
from app.routers.auth import router as _auth_router # noqa: E402
# The files router enforces its own access check per route: a browser cannot
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
# routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402
for router in ROUTERS:
if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX)
else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
@app.get("/health", tags=["system"])
async def health():
return {"status": "ok", "service": "procurement-api"}
async def _check_db() -> bool:
try:
async with AsyncSessionLocal() as session:
await session.execute(sql_text("SELECT 1"))
return True
except Exception:
logger.exception("readiness: database check failed")
return False
async def _check_minio() -> bool:
try:
from app.services.storage import get_client
get_client().bucket_exists(settings.MINIO_BUCKET)
return True
except Exception:
logger.exception("readiness: MinIO check failed")
return False
async def _check_casdoor() -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(
settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
)
return resp.status_code == 200
except Exception:
logger.exception("readiness: Casdoor JWKS check failed")
return False
@app.get("/health/ready", tags=["system"])
async def health_ready():
"""Readiness: verifies the dependencies the API actually needs.
The deploy webhook polls this after a rebuild, so a deploy that breaks
the DB/MinIO/Casdoor connectivity is visible immediately.
"""
checks = {
"database": await _check_db(),
"minio": await _check_minio(),
"casdoor": await _check_casdoor(),
}
ok = all(checks.values())
return JSONResponse(
status_code=200 if ok else 503,
content={"status": "ok" if ok else "degraded", "service": "procurement-api", "checks": checks},
)
+1
View File
@@ -1,5 +1,6 @@
"""ORM models — import order matters only for registration in Base.metadata."""
from app.db.base import Base
from app.models.audit import AuditLog
from app.models.catalog import (
AttributeDefinition,
Brand,
+19
View File
@@ -0,0 +1,19 @@
"""Audit log model — general activity trail (v2.7)."""
import uuid
from sqlalchemy import Column, DateTime, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
occurred_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
actor = Column(Text, nullable=True) # display name of the authenticated user
action = Column(String(64), nullable=False) # e.g. "user.role_changed"
entity = Column(String(64), nullable=False) # e.g. "user", "inventory_tx"
entity_id = Column(Text, nullable=True)
details = Column(JSONB, nullable=False, server_default="{}")
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base):
__tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column(
@@ -149,7 +146,6 @@ class Material(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
+54 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum(
"pm", "procurement_manager", "admin",
"pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True,
)
quote_status_enum = Enum(
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
username: Mapped[str | None] = mapped_column(Text)
first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text)
@@ -65,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base):
__tablename__ = "suppliers"
@@ -148,12 +190,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
start_date: Mapped[date | None] = mapped_column(Date)
planned_start: Mapped[date | None] = mapped_column(Date)
planned_end: Mapped[date | None] = mapped_column(Date)
actual_start: Mapped[date | None] = mapped_column(Date)
actual_end: Mapped[date | None] = mapped_column(Date)
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
# v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Quote(Base):
+86
View File
@@ -0,0 +1,86 @@
"""Observability plumbing — logging format, request IDs, request log.
- ``setup_logging()``: human-readable text by default, JSON lines when
``PROCUREMENT_LOG_FORMAT=json`` (what Promtail/Loki prefers).
- ``request_context`` middleware: assigns an X-Request-ID to every request
(honours one sent by the edge), echoes it in the response, and logs one
structured line per request (method, path, status, duration, request id)
— this is what makes access logs useful in Loki.
"""
import contextvars
import json
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
_SKIP_ACCESS_LOG = {"/health", "/health/ready", "/metrics"}
class JsonFormatter(logging.Formatter):
"""Minimal JSON-lines formatter for Loki/Promtail ingestion."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": request_id_var.get(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
for key in ("http_method", "http_path", "http_status", "duration_ms"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
return json.dumps(payload, ensure_ascii=False)
def setup_logging(log_format: str = "text", level: int = logging.INFO) -> None:
formatter = JsonFormatter() if log_format.lower() == "json" else logging.Formatter(
"%(asctime)s %(levelname)s %(name)s — %(message)s"
)
root = logging.getLogger()
for handler in root.handlers[:]:
root.removeHandler(handler)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
root.addHandler(handler)
root.setLevel(level)
class RequestContextMiddleware(BaseHTTPMiddleware):
"""X-Request-ID + one access-log line per request."""
async def dispatch(self, request: Request, call_next) -> Response:
rid = request.headers.get("x-request-id") or uuid.uuid4().hex
token = request_id_var.set(rid)
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = round((time.perf_counter() - started) * 1000, 1)
logging.getLogger("procurement.access").exception(
"unhandled exception",
extra={"http_method": request.method, "http_path": request.url.path,
"http_status": 500, "duration_ms": duration_ms},
)
request_id_var.reset(token)
raise
duration_ms = round((time.perf_counter() - started) * 1000, 1)
response.headers["X-Request-ID"] = rid
request_id_var.reset(token)
if request.url.path not in _SKIP_ACCESS_LOG:
logging.getLogger("procurement.access").info(
"%s %s -> %s",
request.method, request.url.path, response.status_code,
extra={"http_method": request.method, "http_path": request.url.path,
"http_status": response.status_code, "duration_ms": duration_ms},
)
return response
-6
View File
@@ -4,8 +4,6 @@ from app.routers import (
auth,
brand_materials,
brands,
category_attributes,
category_ref_units,
files,
inventory,
item_decisions,
@@ -24,7 +22,6 @@ from app.routers import (
supplier_materials,
suppliers,
termsheets,
unit_conversions,
units,
users,
)
@@ -35,7 +32,6 @@ ROUTERS = [
brands.router,
material_categories.router,
attribute_definitions.router,
category_attributes.router,
materials.router,
brand_materials.router,
suppliers.router,
@@ -51,8 +47,6 @@ ROUTERS = [
item_receipts.router,
quote_events.router,
files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router,
inventory.router,
pm.router,
+16 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_procurement_writer()
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
@router.post("", status_code=201)
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
async def create_attribute_definition(
payload: AttributeDefinitionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}")
async def update_attribute_definition(
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
definition_id,
payload: AttributeDefinitionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@router.delete("/{definition_id}")
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
async def delete_attribute_definition(
definition_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
+21 -7
View File
@@ -13,15 +13,17 @@ Error mapping (documented in docs/casdoor-auth.md):
import logging
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import _verify_token, context_from_claims
from app.config import settings
from app.db.engine import get_db
from app.routers.users import _user_project, user_to_dict
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
from app.routers.users import _user_projects, user_to_dict
from app.services import analytics
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth")
@@ -31,11 +33,11 @@ router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
@router.post("/login")
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
@limiter.limit("10/minute", error_message="تلاش‌های ورود بیش از حد مجاز — کمی بعد دوباره امتحان کنید")
async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Exchange username/password for a Casdoor JWT (OAuth password grant)."""
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
form = {
@@ -75,12 +77,24 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
# the app-side user (auto-provision on first login).
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
projects = await _user_projects(db, ctx.user)
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track(
"login_success",
{"username": payload.username, "role": ctx.role},
profile_id=ctx.user.casdoor_id,
)
await analytics.identify(
ctx.user.casdoor_id,
{"name": ctx.display_name, "role": ctx.role, "username": payload.username},
)
return {
"data": {
"access_token": access_token,
"token_type": "bearer",
"expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, project),
"user": await user_to_dict(ctx.user, projects, db),
}
}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
@@ -70,7 +72,11 @@ async def list_brand_materials(
@router.post("", status_code=201)
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
async def create_brand_material(
payload: BrandMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None:
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
@router.patch("/{bm_id}")
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
async def update_brand_material(
bm_id,
payload: BrandMaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@router.delete("/{bm_id}")
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
async def delete_brand_material(
bm_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@router.post("", status_code=201)
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
async def create_brand(
payload: BrandCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}")
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
async def update_brand(
brand_id,
payload: BrandUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@router.delete("/{brand_id}")
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
async def delete_brand(
brand_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
-136
View File
@@ -1,136 +0,0 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
-69
View File
@@ -1,69 +0,0 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+121 -12
View File
@@ -13,18 +13,27 @@ The same four endpoints exist under /proforma. Bytes live in MinIO
Both invoice and proforma uploads are allowed in every quote status (the
user can attach the invoice from the edit page at any time).
"""
import os
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, get_optional_user, require_role
from app.db.engine import get_db
from app.models.procurement import Quote
from app.services import file_service
from app.utils.errors import NotFoundError, ValidationError
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"])
# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
}
@@ -34,6 +43,58 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
def _content_disposition(file_name: str, inline: bool = False) -> str:
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients.
``inline=True`` makes the browser render the file (PDF in a viewer,
image in the tab) instead of downloading it — required by the preview
modal, whose <iframe> would otherwise trigger a download.
"""
raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters
fallback = "".join(ch for ch in raw if ch >= " " and ch != '"').strip()
ext = os.path.splitext(file_name)[1]
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic name that keeps the extension
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
"preview" if inline else "attachment"
)
kind = "inline" if inline else "attachment"
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
async def _require_file_access(
request: Request,
token: str | None,
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Authorise a file read by session header OR a signed ``?t=`` token.
These two GET routes sit outside the global Bearer dependency because a
browser cannot attach an Authorization header to <iframe>/<img> loads.
A caller is accepted when it presents either a valid Casdoor session
(resolved by get_optional_user) or a signed token scoped to exactly
this file, which expires within minutes.
"""
if request is not None and request.headers.get("authorization"):
user = await get_optional_user(request, db)
if user is not None and user.role is not None:
return
if token and verify_preview_token(token, quote_id, kind, attachment_id):
return
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
@@ -65,7 +126,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201)
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
@@ -76,29 +137,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()}
@router.get("/{quote_id}/invoice")
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice")
return {"data": [a.to_dict() for a in attachments]}
# ---------------------------------------------------------------------------
# Preview URL — a real, short-lived URL the browser can load in an
# <iframe>/<img> (which cannot send an Authorization header). Using a real
# URL instead of a blob: avoids download managers that hijack blob URLs and
# leave 0-byte files behind.
# ---------------------------------------------------------------------------
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
async def preview_url(
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_ctx: UserContext = Depends(get_current_user),
):
if kind not in ("invoice", "proforma"):
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
await _quote_or_404(db, quote_id)
await _attachment_or_404(db, quote_id, attachment_id, kind)
token = make_preview_token(quote_id, kind, attachment_id)
# مسیر نسبی برگردانده می‌شود؛ فرانت با BASE_URL خودش کاملش می‌کند
return {
"data": {
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
"token": token,
}
}
@router.get("/{quote_id}/invoice/{attachment_id}")
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def download_invoice(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/invoice/{attachment_id}")
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
@@ -110,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201)
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
@@ -121,7 +220,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()}
@router.get("/{quote_id}/proforma")
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "proforma")
@@ -129,21 +228,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
@router.get("/{quote_id}/proforma/{attachment_id}")
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def download_proforma(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/proforma/{attachment_id}")
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+127 -109
View File
@@ -43,6 +43,7 @@ from app.utils.errors import (
UnauthorizedError,
ValidationError,
)
from app.utils.audit import record as audit_record
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
@@ -57,41 +58,74 @@ class Scope:
role: str
project_id: uuid.UUID | None
actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope:
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback."""
# Primary path: Casdoor JWT. Role from users.role; PM project from
# projects.pm_user_id (single PM per project).
async def inventory_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: داده‌های همین پروژه)"),
db: AsyncSession = Depends(get_db),
) -> Scope:
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback.
The active project is explicit via `?project_id=…`. For a PM the backend
verifies they manage that project (projects.pm_user_id, 1:N); without the
param it falls back to their first assigned project. Admin / procurement
manager may pass it as a filter (no param = all projects).
"""
if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
if user.role == "procurement_manager":
return Scope(role="admin", project_id=None, actor=user.display_name)
if user.role not in ("pm", "admin"):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
if user.role == "pm":
if user.project_id is None:
raise ValidationError(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
return Scope(role="admin", project_id=None, actor=user.display_name)
panels = user.panels or {}
if user.role is None and not panels:
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
if "inventory" in panels:
if panels["inventory"] == "full":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor":
allowed = list(user.project_ids or [])
if not allowed:
raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role")
# Dev fallback: header-based identity (fork's placeholder mode) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401.
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
# Casdoor). Disabled by default: unauthenticated header identity in
# production would hand admin access to anyone who can reach the API.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
if role == "procurement_manager":
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
if role not in (ROLE_PM, ROLE_ADMIN):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
# The explicit query param wins; otherwise the dev header carries it.
if project_id is None:
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
project_id = None
if raw_project:
try:
project_id = uuid.UUID(str(raw_project))
@@ -111,9 +145,9 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
return Scope(role=role, project_id=project_id, actor=actor)
def _require_pm(scope: Scope) -> None:
if scope.role != ROLE_PM:
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
def _require_inventory_modifier(scope: Scope) -> None:
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# ---------------------------------------------------------------------------
@@ -198,8 +232,16 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db),
):
stmt = select(Warehouse)
if scope.role == ROLE_PM:
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name)
@@ -231,9 +273,11 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id")
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if payload.project_id not in allowed:
raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", field="project_id")
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
@@ -250,19 +294,21 @@ async def update_warehouse(
db: AsyncSession = Depends(get_db),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM:
if scope.project_id != wh.project_id:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None:
wh.name = payload.name
if payload.description is not None:
wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id")
wh.project_id = payload.project_id
raise ValidationError(
"پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
field="project_id",
)
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@@ -274,7 +320,9 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
# inventory rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists.
@@ -296,7 +344,6 @@ async def delete_warehouse(
@router.get("")
async def list_inventory(
project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None,
material_id=None,
sort: str | None = None,
@@ -306,7 +353,19 @@ async def list_inventory(
db: AsyncSession = Depends(get_db),
):
stmt = select(InventoryItem)
if scope.role == ROLE_PM:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
elif allowed:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id.in_(allowed)
)
else:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id
)
@@ -314,10 +373,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
if category_id is not None:
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
Material.category_id == category_id
)
if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None:
@@ -339,10 +394,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
categories = {}
if materials:
cat_ids = {m.category_id for m in materials.values()}
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
brands = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -352,7 +403,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [
inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id],
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
None, brands.get(i.brand_id), units.get(i.unit_id),
)
for i in items
]
@@ -379,7 +430,15 @@ async def list_transactions(
stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
@@ -445,7 +504,9 @@ async def inventory_history(
if item is None:
raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute(
select(InventoryTransaction)
@@ -465,19 +526,31 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
_require_pm(scope)
_require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure.
with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if scope.role in (ROLE_PM, "supervisor"):
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id != scope.project_id:
if dest.project_id not in allowed:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record(
db,
actor=scope.actor,
action="inventory.transaction_created",
entity="inventory",
entity_id=str(txs[0].inventory_id) if txs else None,
details={"type": payload.transaction_type, "quantity": str(payload.quantity),
"warehouse_id": str(payload.warehouse_id),
"tx_ids": [str(tx.id) for tx in txs]},
)
return {"data": [transaction_to_dict(tx) for tx in txs]}
@@ -504,58 +577,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+11 -3
View File
@@ -4,11 +4,16 @@ State machine (P1 cross-endpoint rules):
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
pending -> rejected
chosen/rejected -> pending/anything-else = 409
Auth: JWT required globally (main.py). The state-changing endpoints are a
purchase decision — restricted to procurement_manager / admin via
require_role; reads stay open to every authenticated role.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
@@ -21,6 +26,9 @@ router = APIRouter(
tags=["item-decisions"],
)
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
_decision_writer = require_role("procurement_manager", "admin")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
@@ -59,7 +67,7 @@ async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db
return {"data": decision_full(decision)}
@router.put("")
@router.put("", dependencies=[Depends(_decision_writer)])
async def put_decision(
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
):
@@ -93,7 +101,7 @@ async def put_decision(
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
@router.patch("/status")
@router.patch("/status", dependencies=[Depends(_decision_writer)])
async def patch_decision_status(
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
):
@@ -118,7 +126,7 @@ async def patch_decision_status(
return {"data": decision_full(decision)}
@router.patch("")
@router.patch("", dependencies=[Depends(_decision_writer)])
async def patch_decision(
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
):
+16 -2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"],
)
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
@router.post("", status_code=201)
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
async def create_receipt(
quote_id,
line_item_id,
payload: ReceiptCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
@router.patch("")
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
async def update_receipt(
quote_id,
line_item_id,
payload: ReceiptUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
+18 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"],
)
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
@router.post("", status_code=201)
async def create_item_termsheet(
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
quote_id,
line_item_id,
payload: ItemTermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
@@ -93,7 +99,12 @@ async def create_item_termsheet(
@router.patch("/{item_termsheet_id}")
async def update_item_termsheet(
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
quote_id,
line_item_id,
item_termsheet_id,
payload: ItemTermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}")
async def delete_item_termsheet(
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
quote_id,
line_item_id,
item_termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
+9 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@router.put("")
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
async def put_line_item_specs(
quote_id,
line_item_id,
payload: SpecsPutIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
+32 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -50,7 +52,12 @@ async def list_line_items(
@router.post("", status_code=201)
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
async def create_line_item(
quote_id,
payload: LineItemCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}")
async def update_line_item(
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
quote_id,
line_item_id,
payload: LineItemUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
@@ -108,6 +119,7 @@ async def update_line_item(
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
await ensure_brand_material_belong(db, effective_bm, new_material_id)
old_material_id = li.material_id
if payload.material_id is not None:
li.material_id = payload.material_id
# v2.5: keep the supplier<->material catalog edge in sync.
@@ -121,11 +133,21 @@ async def update_line_item(
if payload.notes is not None:
li.notes = payload.notes
await db.flush()
if payload.material_id is not None and payload.material_id != old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@router.delete("/{line_item_id}")
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
async def delete_line_item(
quote_id,
line_item_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -133,6 +155,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
old_material_id = li.material_id
await db.delete(li) # specs / links / decision / receipt cascade via DDL
await db.flush()
if old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": {"id": str(line_item_id), "deleted": True}}
+27 -7
View File
@@ -3,14 +3,17 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict:
@@ -45,7 +48,11 @@ async def list_categories(
@router.post("", status_code=201)
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
async def create_category(
payload: MaterialCategoryCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -69,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}")
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
async def update_category(
category_id,
payload: MaterialCategoryUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
@@ -91,14 +103,22 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@router.delete("/{category_id}")
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
async def delete_category(
category_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
# v2.7: materials no longer carry a category, so what blocks deletion is the
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
# material of that category is reached through those suppliers.
linked = await db.scalar(
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
)
if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست")
raise ConflictError(f"این دسته‌بندی برای {linked} تأمین‌کننده ثبت شده و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL
await db.flush()
return {"data": {"id": str(category_id), "deleted": True}}
+36 -10
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import (
AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str):
@@ -97,7 +99,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
async def list_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None,
status: str | None = None,
q: str | None = None,
@@ -107,8 +108,6 @@ async def list_materials(
db: AsyncSession = Depends(get_db),
):
stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
if brand_id:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
@@ -128,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201)
async def create_material(
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
payload: MaterialCreate,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -143,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}")
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
async def update_material(
material_id,
payload: MaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -152,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@router.delete("/{material_id}")
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
async def delete_material(
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -251,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201)
async def create_material_unit(
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
material_id,
payload: MaterialUnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
await _material_or_404(db, material_id)
@@ -290,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit(
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
material_id,
mu_id,
payload: MaterialUnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
mu = await db.scalar(
@@ -322,7 +340,12 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
async def delete_material_unit(
material_id,
mu_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
@@ -368,7 +391,10 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values")
async def put_material_attribute_values(
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
material_id,
payload: MaterialAttributeValuesPut,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin():
+72 -35
View File
@@ -19,8 +19,9 @@ Rules enforced here:
the canonical unit for auto-posted purchases.
- quote.status flips to 'arrived' when ALL chosen items are fully received.
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
X-Project-Id for pm) when no Authorization header — same pattern as the
Auth: Casdoor JWT (a global dependency in main.py) resolves the caller;
optional dev-header fallback (X-User-Role: pm|admin + X-Project-Id for pm)
exists only when PROCUREMENT_DEV_LOGIN_ENABLED=true — same pattern as the
inventory router.
"""
import base64
@@ -29,14 +30,15 @@ from contextlib import nullcontext
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
@@ -48,6 +50,7 @@ from app.models.procurement import (
Supplier,
)
from app.schemas.procurement import PMReceiptCreate
from app.services import analytics
from app.services.inventory_service import canonical_specs_hash
from app.utils.errors import (
ForbiddenError,
@@ -66,37 +69,54 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
class ArrivalScope:
role: str # "pm" | "admin" (admin = admin or procurement_manager)
user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's single project; None for admin
project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope:
async def arrivals_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"),
db: AsyncSession = Depends(get_db),
) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to his own project. Admin / procurement manager → all projects.
PM / supervisor → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter).
"""
if request.headers.get("authorization"):
ctx: UserContext | None = await get_optional_user(request, db)
if ctx is not None:
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm":
if ctx.project_id is None:
if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
project_id = None
if role == "pm":
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id")
if not raw_project:
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
@@ -173,8 +193,16 @@ async def pm_on_the_way(
.where(ItemDecision.status == "chosen")
)
if ctx.role == "pm":
if ctx.project_id is None:
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -183,18 +211,6 @@ async def pm_on_the_way(
# unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions}
cats = {}
if mat_ids:
rows = (
await db.execute(
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
out = []
for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id)
@@ -207,8 +223,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"material": {"id": str(m.id), "name": m.name},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
@@ -238,8 +253,12 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.role == "pm" and decision.project_id != ctx.project_id:
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if decision.project_id not in allowed:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id)
@@ -412,6 +431,16 @@ async def pm_confirm_receipt(
quote.arrived_date = payload.received_at
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
await analytics.track(
"receipt_confirmed",
{
"receipt_id": str(receipt.id),
"quote_id": str(quote.id),
"quantity": str(payload.received_qty),
"state": item_state,
},
profile_id=ctx.display_name,
)
return {"data": {
"receipt_id": str(receipt.id),
"state": item_state,
@@ -437,8 +466,16 @@ async def pm_receipts(
.join(Project, Project.id == ItemDecision.project_id)
)
if ctx.role == "pm":
if ctx.project_id is None:
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows]
+81 -12
View File
@@ -1,17 +1,24 @@
"""Projects CRUD + per-project stats."""
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate
from app.services.quote_service import project_stats
from app.utils.errors import ConflictError, NotFoundError
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
"""JWT caller when present — used for RBAC on PM assignment (PATCH)."""
return await get_optional_user(request, db)
async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict:
@@ -27,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = {
"id": str(project.id),
"name": project.name,
"description": project.description,
"location": project.location or "",
"project_manager": project.project_manager or "",
"description": project.description or "",
"start_date": to_date(project.start_date),
"status": project.status or "ACTIVE",
"progress": float(project.progress) if project.progress is not None else 0.0,
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at),
}
@@ -36,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif project.project_manager:
d["pm_name"] = project.project_manager
return d
@@ -49,7 +62,14 @@ async def list_projects(
):
stmt = select(Project)
if q:
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
stmt = stmt.where(
or_(
Project.name.ilike(f"%{q}%"),
Project.description.ilike(f"%{q}%"),
Project.location.ilike(f"%{q}%"),
Project.project_manager.ilike(f"%{q}%"),
)
)
stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items)
@@ -58,24 +78,38 @@ async def list_projects(
d = {
"id": str(p.id),
"name": p.name,
"description": p.description,
"location": p.location or "",
"project_manager": p.project_manager or "",
"description": p.description or "",
"start_date": to_date(p.start_date),
"status": p.status or "ACTIVE",
"progress": float(p.progress) if p.progress is not None else 0.0,
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at),
}
pm = pm_map.get(p.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif p.project_manager:
d["pm_name"] = p.project_manager
data.append(d)
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
async def create_project(
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = Project(
name=payload.name,
description=payload.description,
name=payload.name.strip(),
location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else "",
description=payload.description.strip() if payload.description else "",
start_date=payload.start_date,
planned_start=payload.start_date,
status="ACTIVE",
)
db.add(project)
await db.flush()
@@ -91,22 +125,57 @@ async def get_project(project_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{project_id}")
async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession = Depends(get_db)):
async def update_project(
project_id,
payload: ProjectUpdate,
request: Request,
ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
if payload.name is not None:
project.name = payload.name
project.name = payload.name.strip()
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.description is not None:
project.description = payload.description
project.description = payload.description.strip()
if payload.start_date is not None:
project.start_date = payload.start_date
project.planned_start = payload.start_date
if payload.status is not None:
project.status = payload.status
# PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None:
project.pm_user_id = None
else:
pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id
if not project.project_manager:
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
await db.flush()
return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}")
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
async def delete_project(
project_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
+8 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_procurement_writer()
@router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@router.post("", status_code=201)
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
async def create_event(
quote_id,
payload: NoteEventIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+92 -26
View File
@@ -8,9 +8,19 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
from app.models.procurement import (
ItemDecision,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteProjects,
Supplier,
Termsheet,
)
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
@@ -27,24 +37,18 @@ from app.services.expand_service import (
serialize_quote_with_children,
serialize_quotes,
)
from app.utils.errors import NotFoundError
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import sort_query
from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_procurement_writer()
# --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
def _quote_search(q: str) -> exists:
like = f"%{q}%"
@@ -72,7 +76,6 @@ async def get_quote_stats(
project_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db),
):
return {
@@ -82,7 +85,6 @@ async def get_quote_stats(
project_id=project_id,
quote_date_from=quote_date_from,
quote_date_to=quote_date_to,
category_id=category_id,
)
}
@@ -98,7 +100,6 @@ async def list_quotes(
status: str | None = None,
supplier_id=None,
project_id=None,
category_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
q: str | None = None,
@@ -112,9 +113,23 @@ async def list_quotes(
if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id:
stmt = stmt.where(Quote.project_id == project_id)
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
# any relevant link: legacy mirror, quote-level junction, line-item
# candidates, or an accepted (decision) target — NOT just the mirror
stmt = stmt.where(
Quote.id.in_(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
)
if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
@@ -145,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
async def create_quote(
payload: QuoteCreate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
ctx: UserContext = Depends(allow_quote_create()),
_auth=None,
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
@@ -156,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
@router.patch("/{quote_id}")
async def update_quote(
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
quote_id,
payload: QuoteUpdate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())}
@@ -164,7 +200,11 @@ async def update_quote(
@router.patch("/{quote_id}/children")
async def replace_quote_children(
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
quote_id,
payload: QuoteChildrenReplaceIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -177,7 +217,11 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept")
async def accept_quote(
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
quote_id,
payload: AcceptQuoteIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)}
@@ -213,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive")
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
async def arrive_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_quote(db, quote_id, actor)
return {
"data": {
@@ -226,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/arrive-items")
async def arrive_items(
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
quote_id,
payload: ArriveItemsIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return {
@@ -248,14 +301,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject")
async def reject_quote(
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
quote_id,
payload: RejectQuoteIn | None = None,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire")
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
async def expire_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -265,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
# ---------------------------------------------------------------------------
@router.delete("/{quote_id}")
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
async def delete_quote(
quote_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
result = await quote_service.delete_quote(db, quote_id)
return {"data": result}
+20 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"],
)
_procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
@@ -57,7 +59,11 @@ async def list_schedule_steps(
@router.post("", status_code=201)
async def create_schedule_step(
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
quote_id,
termsheet_id,
payload: ScheduleStepCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}")
async def update_schedule_step(
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
quote_id,
termsheet_id,
step_id,
payload: ScheduleStepUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no:
@@ -109,7 +120,13 @@ async def update_schedule_step(
@router.delete("/{step_id}")
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
async def delete_schedule_step(
quote_id,
termsheet_id,
step_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where(
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@router.post("", status_code=201)
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
async def create_standard_conversion(
payload: StandardConversionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(
select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit,
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{sc_id}")
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
async def update_standard_conversion(
sc_id,
payload: StandardConversionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@router.delete("/{sc_id}")
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
async def delete_standard_conversion(
sc_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
+14 -9
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
@@ -58,26 +60,29 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
supplier_id,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
return {"data": link_to_dict(link, material, category)}
return {"data": link_to_dict(link, material, None)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
async def delete_supplier_material(
supplier_id,
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@router.post("", status_code=201)
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
async def create_supplier(
payload: SupplierCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await _category_or_404(db, payload.category_id)
supplier = Supplier(
name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}")
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
async def update_supplier(
supplier_id,
payload: SupplierUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@router.delete("/{supplier_id}")
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
async def delete_supplier(
supplier_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@router.post("", status_code=201)
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
async def create_termsheet(
quote_id,
payload: TermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}")
async def update_termsheet(
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
quote_id,
termsheet_id,
payload: TermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status")
async def update_termsheet_status(
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
quote_id,
termsheet_id,
payload: TermsheetStatusUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}")
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
async def delete_termsheet(
quote_id,
termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
-146
View File
@@ -1,146 +0,0 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@router.post("", status_code=201)
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
async def create_unit(
payload: UnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}")
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
async def update_unit(
unit_id: int,
payload: UnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@router.delete("/{unit_id}")
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
async def delete_unit(
unit_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
+257 -50
View File
@@ -1,12 +1,14 @@
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
Organization panel endpoints (admin / procurement manager — same person):
- GET /users — list every user with role + assigned project.
- GET /users — list every user with role + assigned projects.
- POST /users — define a new user (manual identity, casdoor_id optional).
- PATCH /users/{id} — set/clear role; clearing (or switching away from
«pm») also unassigns the user from their project.
- POST /users/{id}/project — assign/unassign the user's single project
(project_id=None unassigns; one PM per project).
«pm») also unassigns the user from every project.
- POST /users/{id}/project — assign/unassign ONE project (multi-project:
other assignments are kept; project_id=None
clears all; one PM per project).
- GET /users/me/projects — every project assigned to the caller (PM).
Role management is open to BOTH «admin» and «procurement_manager» (they are
the same person in this system), unlike the plain require_role("admin").
@@ -15,14 +17,21 @@ import uuid
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import select, update
from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import (
ConflictError,
ForbiddenError,
@@ -38,39 +47,104 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات",
"admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
}
def user_to_dict(user: User, project: Project | None = None) -> dict:
return {
PANEL_LEVELS = ("full", "limited")
PANELS = ("procurement", "inventory", "pm")
async def _panels_of(db: AsyncSession, user_id) -> dict:
"""panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها)."""
rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
)
).scalars().all()
return {row.panel: row.level for row in rows}
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
"""عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته."""
rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user_id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return [str(pid) for pid in rows]
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility.
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or []
first = projects[0] if projects else None
out = {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"role": user.role,
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
"project_id": str(project.id) if project else None,
"project_name": project.name if project else None,
"project_id": str(first.id) if first else None,
"project_name": first.name if first else None,
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at),
}
if db is not None:
out["panels"] = await _panels_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management."""
if ctx.role not in ("admin", "procurement_manager"):
"""Admin, procurement manager, or project manager — organization management."""
if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = (
await db.execute(
select(Project)
.where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
async def _user_project(db: AsyncSession, user: User) -> Project | None:
if user.role != "pm":
return None
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
"""First (oldest) project of a PM — legacy single-project view."""
projects = await _user_projects(db, user)
return projects[0] if projects else None
async def _unassign_projects(db: AsyncSession, user_id) -> None:
"""Clear the user's pm assignment on every project (one project per PM)."""
"""Clear the user's pm assignment on every project (role revocation)."""
await db.execute(
update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None)
)
@@ -104,8 +178,31 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
return {"data": user_to_dict(ctx.user, project)}
projects = await _user_projects(db, ctx.user)
return {"data": await user_to_dict(ctx.user, projects, db)}
@router.get("/me/projects")
async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Every project assigned to the caller (empty list for non-PM roles).
Drives the PM project switcher: the frontend validates its persisted
active project against this list and falls back gracefully when a
project is revoked."""
projects = await _user_projects(db, ctx.user)
# عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود
member_ids = set(ctx.member_project_ids or [])
have = {p.id for p in projects}
if member_ids:
extra = (
await db.execute(
select(Project)
.where(Project.id.in_(member_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
projects = list(projects) + [p for p in extra if p.id not in have]
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@router.get("")
@@ -113,15 +210,22 @@ async def list_users(
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Every user (with role + assigned project) — Organization panel."""
"""Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {}
if supervisor_ids:
projs = (
await db.execute(
select(Project)
.where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users)},
}
@@ -129,7 +233,7 @@ async def list_users(
@router.post("", status_code=201)
async def create_user(
payload: UserCreate,
_: UserContext = Depends(_org_manager),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Define a new user (manual identity — casdoor_id optional)."""
@@ -146,28 +250,31 @@ async def create_user(
)
db.add(user)
await db.flush()
return {"data": user_to_dict(user)}
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": await user_to_dict(user)}
@router.patch("/{user_id}")
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
assign or elevate roles. Revoking (or switching away from «pm») also
unassigns the user from their project."""
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
if payload.role == "admin" and ctx.role != "admin":
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role == "pm" and payload.role != "pm":
previous_role = user.role
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
await _unassign_projects(db, user.id)
user.role = payload.role
await db.flush()
project = await _user_project(db, user)
return {"data": user_to_dict(user, project)}
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
projects = await _user_projects(db, user)
return {"data": await user_to_dict(user, projects)}
@router.post("/sync")
@@ -188,8 +295,15 @@ async def sync_casdoor_users(
continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
by_uname = await db.scalar(select(User).where(User.username == uname))
if by_uname is not None:
by_uname.casdoor_id = cid
continue
db.add(User(
casdoor_id=cid,
username=uname,
first_name=str(
cu.get("firstName")
or cu.get("displayName")
@@ -200,16 +314,27 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None,
))
created += 1
elif not existing.username:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
existing.username = uname
await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {}
if supervisor_ids:
projs = (
await db.execute(
select(Project)
.where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users), "created": created},
}
@@ -221,26 +346,108 @@ async def assign_user_project(
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Assign the user as PM of one project (project_id=None unassigns).
"""Assign the user as PM of ONE project (multi-project aware).
Rules: the user must hold the «pm» role; a PM manages ONE project at a
time (assignment on other projects is cleared); assigning a new PM to a
project replaces the previous PM there.
- project_id given: that project gets this PM (a previous PM there is
replaced); the user's OTHER project assignments are kept.
- project_id=None: clears ALL of the user's project assignments.
Rules: the user must hold the «pm» role; a project has at most one PM.
"""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
if user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
await _unassign_projects(db, user.id)
if payload.project_id is None:
await _unassign_projects(db, user.id)
if user.username:
await db.execute(
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
{"uname": user.username}
)
await db.flush()
return {"data": user_to_dict(user)}
return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id
if user.username:
await db.execute(
text("""
INSERT INTO project_assignments (id, user_id, project_id)
SELECT gen_random_uuid(), pu.id, :pid
FROM pm_users pu
WHERE pu.username = :uname
AND NOT EXISTS (
SELECT 1 FROM project_assignments pa
WHERE pa.user_id = pu.id AND pa.project_id = :pid
)
"""),
{"pid": project.id, "uname": user.username}
)
await db.flush()
return {"data": user_to_dict(user, project)}
return {"data": await user_to_dict(user, await _user_projects(db, user))}
# ---------------------------------------------------------------------------
# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
# ---------------------------------------------------------------------------
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
for panel, level in payload.panels.items():
if panel not in PANELS:
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
if level not in PANEL_LEVELS:
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
for panel, level in payload.panels.items():
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
wanted = list(dict.fromkeys(payload.project_ids))
if wanted:
found = (
await db.execute(select(Project).where(Project.id.in_(wanted)))
).scalars().all()
found_ids = {p.id for p in found}
unknown = [str(pid) for pid in wanted if pid not in found_ids]
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await db.execute(
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
)
for pid in wanted:
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
class WarehouseUpdate(APIModel):
"""Rename/re-describe a warehouse.
`project_id` is accepted only to be rejected: a warehouse belongs
permanently to its parent project and cannot be reassigned (the router
answers 400 unless the value equals the current project).
"""
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
project_id: UUID | None = None
+29 -4
View File
@@ -37,21 +37,29 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
"""Link a global material to a supplier."""
material_id: UUID
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None
start_date: date | None = None
status: str | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None
# ---------------------------------------------------------------------------
@@ -200,17 +208,21 @@ class UserCreate(APIModel):
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserProjectUpdate(APIModel):
"""Assign/unassign the user's single project (project_id=None unassigns)."""
"""Assign the user to one project (multi-project aware).
project_id given → that project gets this PM (previous PM replaced,
the user's other projects kept); None → clears every assignment.
"""
project_id: UUID | None = None
@@ -334,3 +346,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None
quality_note: str | None = None
class UserPanelsUpdate(APIModel):
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود."""
panels: dict[str, str] = Field(default_factory=dict)
class UserPanelProjectsUpdate(APIModel):
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
project_ids: list[UUID] = Field(default_factory=list)
+77
View File
@@ -0,0 +1,77 @@
"""Server-side product analytics — OpenPanel Track API.
Fire-and-forget business events (quote lifecycle, receipts, logins) so the
OpenPanel dashboards show what actually happens inside the ERP, not just
page views. Never raises: analytics must not break a business flow.
Configuration (all optional — disabled when any is missing):
PROCUREMENT_OPENPANEL_API_URL e.g. http://openpanel-api:3333 (self-hosted)
PROCUREMENT_OPENPANEL_CLIENT_ID project client id (write mode)
PROCUREMENT_OPENPANEL_CLIENT_SECRET project client secret
Contract (OpenPanel Track API): POST {API_URL}/track with headers
``openpanel-client-id`` / ``openpanel-client-secret`` and a JSON body
``{"type": "track"|"identify", "profileId": ..., "event"/"properties": ...}``.
"""
import logging
from typing import Any
import httpx
from app.config import settings
logger = logging.getLogger("procurement.analytics")
_TIMEOUT = httpx.Timeout(3.0)
def _configured() -> bool:
return bool(
settings.OPENPANEL_API_URL
and settings.OPENPANEL_CLIENT_ID
and settings.OPENPANEL_CLIENT_SECRET
)
async def track(event: str, properties: dict[str, Any] | None = None, profile_id: str | None = None) -> None:
"""Record a named event; swallows every error."""
if not _configured():
return
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
# sends) — a flat body is rejected with 400, which this module swallows by design.
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
# rejected with a 400 that this module swallows.
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
if profile_id:
inner["profileId"] = profile_id
body = {"type": "track", "payload": inner}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json=body,
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001 — analytics must never raise
logger.debug("analytics track failed (%s): %s", event, exc)
async def identify(profile_id: str, properties: dict[str, Any] | None = None) -> None:
"""Create/update a user profile (e.g. on login)."""
if not _configured():
return
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001
logger.debug("analytics identify failed: %s", exc)
+18 -13
View File
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
return {"id": str(project.id), "name": project.name}
def decision_brief(d: ItemDecision) -> dict:
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(d.id),
"status": d.status,
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"project": project_brief(project) if project is not None else None,
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"decided_at": to_iso(d.decided_at),
}
@@ -205,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
if "category" in expand:
cat_ids = {m.category_id for m in materials}
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
maps["categories"] = {c.id: c for c in rows}
unit_ids: set[int] = set()
if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials}
@@ -256,10 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at),
}
# Category is a supplier attribute now, not a material one — the key stays
# in the payload (null) so the contract shape is unchanged.
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
d["category"] = None
else:
d["category_id"] = str(m.category_id)
d["category_id"] = None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
@@ -313,6 +311,7 @@ class _LineItemData:
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
self.projects_by_id: dict[uuid.UUID, Project] = {}
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
@@ -324,9 +323,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set()
for bm in bms:
@@ -375,6 +371,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
if "decision" in expand or "receipt" in expand:
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
data.decisions = {d.line_item_id: d for d in decisions}
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
missing_pids = dec_proj_ids - set(proj_map.keys())
if missing_pids:
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
for p in extra_projs:
proj_map[p.id] = p
data.projects_by_id = proj_map
dec_ids = [d.id for d in decisions]
if dec_ids:
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
@@ -441,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"category": None,
"brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, [])
@@ -469,8 +472,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
)
decision = data.decisions.get(li.id)
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
if "decision" in expand:
d["decision"] = decision_brief(decision) if decision is not None else None
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
# termsheet_ids در جلوی برنامه، id ترم‌شیت واقعی هم ارسال می‌شود.
if decision is not None and decision.chosen_item_termsheet_id is not None:
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
@@ -110,9 +109,7 @@ async def validate_attributes(
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
+245 -48
View File
@@ -4,6 +4,7 @@ Every mutation here runs in ONE ``async with db.begin()`` block; any failure
rolls the whole operation back. All state changes log a quote_event in the
same transaction.
"""
import asyncio
import logging
from datetime import date, datetime, timezone
from uuid import UUID
@@ -33,6 +34,7 @@ from app.schemas.procurement import (
QuoteUpdate,
RejectQuoteIn,
)
from app.services import analytics
from app.services.material_service import ensure_brand_material_belong
from app.utils.errors import ConflictError, NotFoundError, ValidationError
@@ -56,6 +58,19 @@ def utcnow() -> datetime:
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
# Product analytics (fire-and-forget, never blocks or raises): the whole
# quote lifecycle funnels through here, so one hook covers every
# status transition for the OpenPanel dashboards.
try:
asyncio.get_running_loop().create_task(
analytics.track(
f"quote_{event_type}",
{"quote_id": str(quote_id), "actor": actor, **(payload or {})},
profile_id=actor,
)
)
except RuntimeError: # no running loop (defensive — callers are async)
pass
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
@@ -279,13 +294,89 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
return quote
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
"""Returns True if any warehouse receipt has been posted for the quote's items."""
count = await db.scalar(
select(func.count(ItemReceipt.id))
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
)
return (count or 0) > 0
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
"""Check candidate material_ids and purge any that have zero references.
A material is considered an orphan if:
1. It is not referenced by ANY line_items across all quotes.
2. It is not referenced by ANY inventory stock items in warehouses.
If count == 0:
- Auxiliary records in supplier_materials, brand_materials, material_units,
and material_attribute_values are cleaned up.
- The material row is deleted from the central materials table.
Returns the list of purged material UUIDs.
"""
purged: list[UUID] = []
unique_ids = {m for m in material_ids if m is not None}
if not unique_ids:
return purged
from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
)
from app.models.inventory import InventoryItem
from app.models.procurement import LineItem, SupplierMaterial
for mat_id in unique_ids:
# Check active usage in line_items across the whole system
line_item_count = await db.scalar(
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
)
if (line_item_count or 0) > 0:
continue
# Check active usage in warehouse inventory
inv_count = await db.scalar(
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
)
if (inv_count or 0) > 0:
continue
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
await db.execute(
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
)
await db.execute(
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
)
await db.execute(
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
)
await db.execute(
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
)
await db.execute(
delete(Material).where(Material.id == mat_id)
)
purged.append(mat_id)
logger.info("Purged orphan material %s from central catalog", mat_id)
return purged
async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered — acceptance data
(decisions/receipts) would otherwise be destroyed.
Only allowed while the quote is still offered and has no warehouse receipts.
Automatically purges any materials detached during this replacement if they become orphans.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
@@ -294,6 +385,16 @@ async def replace_quote_children(
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
field="status",
)
if await quote_has_receipts(db, quote.id):
raise ConflictError(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status",
)
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
@@ -302,16 +403,33 @@ async def replace_quote_children(
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
)
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
await db.flush()
# 2. Identify detached materials: previous materials no longer present in the updated line items
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
detached_material_ids = old_material_ids - new_material_ids
# 3. Clean up any detached materials that are now orphans (0 usage)
if detached_material_ids:
await cleanup_orphan_materials(db, detached_material_ids)
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقال‌های عقب‌گرد پذیرفته می‌شود", field="status")
if await quote_has_receipts(db, quote.id):
raise ConflictError(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status",
)
if new_status == "offered":
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
quote.accepted_date = None
@@ -325,17 +443,18 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute(
update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.where(ItemDecision.line_item_id.in_(li_ids))
.values(
status="pending",
decided_at=None,
chosen_item_termsheet_id=None,
adjusted_unit_price=None,
adjusted_notes=None,
project_id=None,
)
.execution_options(synchronize_session=False)
)
# item_receipts are deliberately NOT deleted (contract P0-17).
# LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
@@ -502,21 +621,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
# termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids:
@@ -692,7 +797,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
async with db.begin():
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
quote = await get_quote_or_404(db, quote_id)
if quote.status in ("accepted", "arrived"):
raise ConflictError("پیشنهادهای پذیرفته‌شده یا رسیده قابل حذف نیستند")
@@ -712,9 +818,36 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
# collect candidate material_ids for orphan cleanup before deleting quote
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
# collect attachment keys before deleting quote row
from app.models.files import QuoteAttachment
from app.services import storage
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
att_keys = [att.object_key for att in attachments]
quote_prefix = f"{quote.id}/"
await db.delete(quote)
await db.flush()
# Purge any materials that no longer have any references in the system
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
for key in att_keys:
try:
await storage.remove_object(key)
except Exception:
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
try:
await storage.remove_prefix(quote_prefix)
except Exception:
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
return {
"id": str(quote.id),
"id": str(quote_id),
"deleted": True,
"cascaded": {
"line_items": len(line_items),
@@ -722,10 +855,14 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
"attachments": len(attachments),
"purged_orphan_materials": len(purged_material_ids),
},
}
# ---------------------------------------------------------------------------
# Statistics
# ---------------------------------------------------------------------------
@@ -736,7 +873,6 @@ async def quote_stats(
project_id: UUID | None = None,
quote_date_from=None,
quote_date_to=None,
category_id: UUID | None = None,
) -> dict:
conditions = []
if supplier_id is not None:
@@ -747,8 +883,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = (
await db.execute(
@@ -780,38 +914,46 @@ async def quote_stats(
}
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for one project.
"""Counts + total value per quote status for ONE project, isolated per project.
total_value: SUM(quantity × unit_price) over line items using the chosen
termsheet link first, else the first link. Rejected line items are
excluded from value sums (contract P1-14).
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
least one non-rejected line item's decision targets this project
(item_decisions.project_id). A quote merely linked to the project as a
candidate (quote-level link, line-item candidates, or accepted for a
DIFFERENT project) stays in «offered» — this project never accepted it.
total_value: SUM(quantity × unit_price) over the bucket's line items,
using the chosen termsheet link first, else the first link. Rejected
line items are excluded from value sums (contract P1-14).
"""
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
# the route passes the raw path string — normalize for the Python-side
# UUID comparisons below (decision.project_id == project_id etc.)
project_id = UUID(str(project_id))
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
if not quotes:
# --- every quote RELEVANT to this project (any kind of link) ---
quote_id_rows = await db.execute(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
quote_ids = [row[0] for row in quote_id_rows.all()]
if not quote_ids:
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty}
quote_ids = [q.id for q in quotes]
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
li_ids = [li.id for li in line_items]
@@ -819,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {}
# line items explicitly linked to this project as candidates
candidate_li_ids = set(
(
await db.execute(
select(LineItemProjects.line_item_id).where(
LineItemProjects.line_item_id.in_(li_ids),
LineItemProjects.project_id == project_id,
)
)
).scalars().all()
) if li_ids else set()
item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
@@ -827,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it)
def _items_of(quote: Quote) -> list[LineItem]:
return [li for li in line_items if li.quote_id == quote.id]
def _accepted_for_project(quote: Quote) -> bool:
"""≥1 non-rejected line item whose decision targets THIS project."""
return any(
(decision := decisions.get(li.id)) is not None
and decision.status == "chosen"
and decision.project_id == project_id
for li in _items_of(quote)
)
def _has_any_target(quote: Quote) -> bool:
"""Whether any decision of this quote carries a target project at all
(legacy quotes accepted before the decision-project era have none —
they keep the old global-status classification)."""
return any(
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
for li in _items_of(quote)
)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes:
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
else:
per_status[q.status]["count"] += 1
for li in line_items:
quote = next(q for q in quotes if q.id == li.quote_id)
decision = decisions.get(li.id)
# --- which status bucket does this line item's value belong to? ---
accepted_here = (
decision is not None
and decision.status == "chosen"
and decision.project_id == project_id
)
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
if accepted_here:
bucket = quote.status
elif li.id in candidate_li_ids:
# the quote is still just a proposal for this project — the
# item's candidate value stays in the offered bucket
bucket = "offered"
else:
continue # this item targets another project entirely
else:
bucket = quote.status
if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -842,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None
if price_it is None:
continue
quote = next(q for q in quotes if q.id == li.quote_id)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
+19
View File
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key)
async def remove_prefix(prefix: str) -> None:
"""Delete all objects matching a prefix in the bucket (best-effort)."""
client = get_client()
def _sync_remove_prefix():
try:
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
for obj in objects:
try:
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
except Exception:
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
except Exception:
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
await asyncio.to_thread(_sync_remove_prefix)
@@ -1,10 +1,6 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
Linking a material to a supplier derives materials.category_id from the
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
Suppliers can supply any global material across categories.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
"""Upsert a supplier<->material link (idempotent).
Caller is responsible for the surrounding transaction.
"""
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
"""Remove the link."""
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
+41
View File
@@ -0,0 +1,41 @@
"""Audit helper — one row per sensitive mutation, written in the SAME
transaction as the change (rolled back together with it).
await record(db, actor=ctx.display_name, action="user.role_changed",
entity="user", entity_id=str(user.id),
details={"from": old_role, "to": user.role})
Never raises: an audit failure must not fail the business operation.
"""
import logging
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
logger = logging.getLogger("procurement.audit")
async def record(
db: AsyncSession,
*,
actor: str | None,
action: str,
entity: str,
entity_id: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
try:
db.add(
AuditLog(
actor=actor,
action=action,
entity=entity,
entity_id=str(entity_id) if entity_id else None,
details=details or {},
)
)
await db.flush()
except Exception: # noqa: BLE001 — audit must never break the caller
logger.exception("failed to write audit_log row (%s/%s)", action, entity)
+71
View File
@@ -0,0 +1,71 @@
"""Ephemeral, URL-safe file-preview tokens.
Why this exists
---------------
File downloads require an ``Authorization: Bearer`` header, but a browser
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
src>`` — which is what the preview modal uses. The previous workaround
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
outside the originating document, and hand the browser a 0-byte file under
a bare UUID name — so the preview broke and a junk download appeared.
Instead we mint a short-lived HMAC token scoped to a single attachment and
put it in the query string, giving the iframe a *real* URL that also
carries its own authorization. The token is not a session credential: it
authorises exactly one (quote_id, kind, attachment_id) triple and expires
in minutes.
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
"""
from __future__ import annotations
import hashlib
import hmac
import time
import uuid
from app.config import settings
# پیش‌نمایش کوتاه‌عمر است — فقط برای باز شدن مودال، نه اشتراک‌گذاری لینک
DEFAULT_TTL_SECONDS = 300
def _secret() -> bytes:
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
base = (
settings.CASDOOR_CLIENT_SECRET
or settings.MINIO_SECRET_KEY
or "procurement-preview-fallback"
)
return base.encode("utf-8")
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
def make_preview_token(
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
) -> str:
expiry = int(time.time()) + ttl
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
def verify_preview_token(
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
) -> bool:
"""True when the token is well-formed, unexpired and matches this file."""
if not token or "." not in token:
return False
expiry_raw, signature = token.split(".", 1)
try:
expiry = int(expiry_raw)
except ValueError:
return False
if expiry < int(time.time()):
return False
expected = _sign(quote_id, kind, attachment_id, expiry)
return hmac.compare_digest(expected, signature)
+11
View File
@@ -0,0 +1,11 @@
"""Shared rate limiter (slowapi) — in-memory, single-worker friendly.
Login brute-force brake: the edge (Traefik) has a coarse per-IP limit for
the whole API host; this adds a tight limit on the credential endpoint
itself. Kept in its own module so routers can import it without a circular
import on app.main.
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+28 -9
View File
@@ -1,14 +1,33 @@
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
"""Attribution for quote_events.actor — the VERIFIED user, not the raw token.
Historically this stored the bearer token string verbatim (unverifiable and
a token leak into the database). All routes that use it now sit behind the
global JWT dependency, so the token is resolved to the authenticated user
and the display name is recorded instead.
"""
from fastapi import Request
from app.auth import get_optional_user
def get_actor(request: Request) -> str | None:
"""Extract the bearer token (if any) to attribute quote_events.actor.
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
async def get_actor(request: Request) -> str | None:
"""Resolve the caller's display name from the Casdoor Bearer token.
Uses its own session (get_optional_user default) — same reason as
get_current_user: quote routers open explicit db.begin() blocks.
"""
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
ctx = await get_optional_user(request)
if ctx is not None:
return ctx.display_name
# Dev-mode header fallback (only active when DEV_LOGIN_ENABLED) carries
# the name in X-User-Name / X-User-Name-B64 — keep attribution useful.
name = request.headers.get("x-user-name")
import base64
b64 = request.headers.get("x-user-name-b64")
if b64:
try:
name = base64.b64decode(b64.encode()).decode("utf-8")
except Exception:
pass
return name
+10
View File
@@ -10,3 +10,13 @@ minio>=7.2.0
# v2.6 — Casdoor JWT auth
pyjwt[crypto]>=2.8.0
httpx>=0.27.0
# Casdoor seeding — bcrypt password hashes (never plaintext)
bcrypt>=4.1.0
# Observability — metrics + Sentry/GlitchTip exception capture
prometheus-fastapi-instrumentator>=7.0.0
sentry-sdk[fastapi]>=2.0.0
# Rate limiting (login brute-force brake; edge limit lives in Traefik)
slowapi>=0.1.9
+4 -4
View File
@@ -209,7 +209,7 @@ async def run() -> None:
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions "
"standard_conversions "
"RESTART IDENTITY CASCADE"
))
# Only the 8 canonical topic categories may exist — any leftover slug
@@ -261,9 +261,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
category_id=cat.id,
name=name,
canonical_unit_id=unit_ids[unit_code],
status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush()
suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
target_slug = sup_cat_slug.get(sup.name)
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
for m in cat_materials[:2]:
db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True,
+34 -15
View File
@@ -20,22 +20,34 @@ import json
import os
import urllib.request
import bcrypt
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
# Casdoor's own bootstrap default for the built-in console admin is "123" —
# the seeder LOGS IN with it (it cannot change the console password).
# Override with CASDOOR_ADMIN_PW after rotating (docs/SECRET-MANAGEMENT.md);
# production MUST set a real value here once the console password is rotated.
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
ORG = "inventory"
APP = "inventory-app"
CLIENT_ID = "inventory-app-client"
CLIENT_SECRET = "inventory-app-secret-2026"
# Must MATCH the API's PROCUREMENT_CASDOOR_CLIENT_SECRET (compose default
# below) — changing one without the other breaks POST /auth/login.
CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "inventory-app-secret-2026")
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD;
# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext.
SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456")
USERS = [
# (name, first_name, last_name, email, password)
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"),
("tester", "کاربر", "تست", "tester@sample.ir", "123456"),
# (name, first_name, last_name, email)
("razaei", "آقای", "رضایی", "razaei@sample.ir"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
("tester", "کاربر", "تست", "tester@sample.ir"),
]
@@ -69,17 +81,21 @@ def _login():
return jar
def _bcrypt(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def main():
cookies = _login()
if cookies is None:
return
# 1) organization (passwordType "plain" = dev: passwords stored as-is)
# 1) organization (bcrypt — passwords are never stored plaintext)
status, body = _request("POST", "/api/add-organization", {
"owner": "admin",
"name": ORG,
"displayName": "سازمان تدارکات",
"passwordType": "plain",
"passwordType": "bcrypt",
}, cookies)
print("add-organization:", status, body.get("msg", body.get("error", "")))
@@ -102,7 +118,8 @@ def main():
print("add-application:", status, body.get("msg", body.get("error", "")))
# 3) users (roles are NOT stored here — they live in the app's users table)
for name, first, last, email, pw in USERS:
pw_hash = _bcrypt(SEED_PASSWORD)
for name, first, last, email in USERS:
status, body = _request("POST", "/api/add-user", {
"owner": ORG,
"name": name,
@@ -110,21 +127,23 @@ def main():
"firstName": first,
"lastName": last,
"email": email,
"password": pw,
"passwordType": "plain",
"password": pw_hash,
"passwordType": "bcrypt",
"phone": "",
"type": "normal-user",
}, cookies)
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
# Ensure passwordType even for already-existing users (this Casdoor
# version refuses login when both user and org passwordType are empty).
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", {
"passwordType": "plain",
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", {
"passwordType": "bcrypt",
"password": pw_hash,
}, cookies)
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
print("Login in the app: http://localhost:3000/login")
print("\nDone. Casdoor console: http://localhost:8080")
print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
print("Login in the app: http://localhost/login")
if __name__ == "__main__":
+134 -50
View File
@@ -1,9 +1,14 @@
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
Run: .venv/Scripts/python.exe tools/smoke_test.py
Dataset-independent: assertions verify BEHAVIOR (status codes, envelopes,
shapes, filtering) rather than exact seed counts, so the suite works with
any seed version. Run from the api container or a host with access:
Run: python tools/smoke_test.py
Env: SMOKE_USER / SMOKE_PASSWORD (default tester / 123456 — seeded users)
"""
import os
import sys
from pprint import pprint
import httpx
@@ -23,84 +28,150 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- catalog ----------
r = c.get("/units", params={"page_size": 5})
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
units = r.json()["data"] if r.status_code == 200 else []
check("GET /units", r.status_code == 200 and len(units) == 5 and "si_factor" in units[0])
r = c.get("/units", params={"dimension": "mass"})
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
if units:
dim = units[0]["dimension"]
r = c.get("/units", params={"dimension": dim, "page_size": 100})
body = r.json()
check(
"GET /units?dimension filter",
r.status_code == 200 and body["meta"]["total"] >= 1
and all(u["dimension"] == dim for u in body["data"]),
r.text[:200],
)
r = c.get("/brands")
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
brands = r.json()["data"] if r.status_code == 200 else []
check("GET /brands", r.status_code == 200 and brands and r.json()["meta"]["total"] >= 1)
r = c.get("/material-categories", params={"q": "سیمان"})
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
# category q-search (only when categories exist)
r = c.get("/material-categories", params={"page_size": 100})
cats = r.json()["data"] if r.status_code == 200 else []
check("GET /material-categories", r.status_code == 200 and isinstance(cats, list))
if cats:
needle = cats[0]["name"][:3]
r = c.get("/material-categories", params={"q": needle})
check(
"GET /material-categories?q (unicode search)",
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
)
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
# materials: q-search against a REAL seeded name (fetched, not hard-coded)
r = c.get("/materials", params={"page_size": 100})
materials = r.json()["data"] if r.status_code == 200 else []
check("GET /materials", r.status_code == 200 and materials and r.json()["meta"]["total"] >= 1)
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
if r.status_code == 200 and r.json()["data"]:
m = r.json()["data"][0]
if materials:
target = materials[0]
needle = target["name"].split()[0]
r = c.get("/materials", params={"q": needle})
check(
"GET /materials?q (unicode search)",
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
)
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": needle})
expanded = [m for m in r.json().get("data", []) if m["id"] == target["id"]]
if r.status_code == 200 and expanded:
m = expanded[0]
check(
"GET /materials expand",
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
)
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
check("expand brands shape", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
else:
check("GET /materials expand", False, r.text[:300])
# dynamic EAV filter: attr_rebar_diameter=14
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
if r.status_code == 200:
data = r.json()["data"]
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
# ---------- EAV filters (positive filter only when the seed has values) ----------
r = c.get("/attribute-definitions", params={"page_size": 100})
attr_defs = r.json()["data"] if r.status_code == 200 else []
if attr_defs and materials:
r = c.get("/materials", params={"expand": "attributes", "page_size": 100})
with_attrs = [m for m in r.json().get("data", []) if m.get("attributes")]
if with_attrs:
m0 = with_attrs[0]
key = m0["attributes"][0]["attribute_key"]
value = str(m0["attributes"][0]["value"])
r = c.get("/materials", params={f"attr_{key}": value, "expand": "attributes"})
check(
f"GET /materials?attr_{key} filter",
r.status_code == 200 and any(x["id"] == m0["id"] for x in r.json().get("data", [])),
r.text[:200],
)
else:
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
print(" SKIP EAV positive filter — seed defines no material attribute values")
else:
print(" SKIP EAV positive filter — seed defines no attribute definitions")
# EAV boolean filter: attr_rebar_ribbed=true
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
# brand filter
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand_id = r.json()["data"][0]["id"]
r = c.get("/materials", params={"brand_id": brand_id})
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
# unknown attr filter -> 400
# unknown attr filter -> 400 (dataset-independent)
r = c.get("/materials", params={"attr_nonexistent": "1"})
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
r = c.get("/materials?page_size=999")
check("page_size>100 -> 400", r.status_code == 400)
# brand filter via a brand that actually has material links
r = c.get("/brand-materials", params={"page_size": 100})
brand_links = r.json()["data"] if r.status_code == 200 else []
if brand_links:
brand_id = brand_links[0]["brand_id"]
r = c.get("/materials", params={"brand_id": brand_id})
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
r = c.get("/brand-materials", params={"brand_id": brand_id})
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
else:
check("GET /materials?brand_id=", False, "seed has no brand-material links")
# ---------- suppliers / projects ----------
r = c.get("/suppliers")
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
suppliers = r.json()["data"] if r.status_code == 200 else []
check("GET /suppliers", r.status_code == 200 and suppliers)
r = c.get("/projects")
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
projects = r.json()["data"] if r.status_code == 200 else []
check("GET /projects", r.status_code == 200 and projects)
r = c.get("/attribute-definitions", params={"expand": "unit"})
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
r = c.get("/attribute-definitions")
attr_shape_ok = r.status_code == 200 and all(
set(a) >= {"attribute_key", "label", "data_type"} for a in r.json().get("data", [])
)
check("GET /attribute-definitions", attr_shape_ok, r.text[:200])
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
r = c.get("/brand-materials", params={"brand_id": brand_id})
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
if cats:
r = c.get(f"/material-categories/{cats[0]['id']}/attributes")
check(
"GET category attributes (nested)",
r.status_code == 200 and isinstance(r.json()["data"], list)
and all("attribute" in a for a in r.json()["data"]),
r.text[:200],
)
# ---------- quotes ----------
r = c.get("/quotes", params={"page_size": 5})
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
quotes = r.json()["data"] if r.status_code == 200 else []
check("GET /quotes", r.status_code == 200 and quotes and r.json()["meta"]["total"] >= 1)
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
if r.status_code == 200 and r.json()["data"]:
q = r.json()["data"][0]
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project"})
accepted = [q for q in r.json().get("data", []) if q.get("line_items")] if r.status_code == 200 else []
if accepted:
q = accepted[0]
li = q["line_items"][0]
check(
"GET /quotes expand full shape",
@@ -111,21 +182,34 @@ if r.status_code == 200 and r.json()["data"]:
r.text[:400],
)
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
if q["termsheets"]:
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
else:
check("GET /quotes expand full shape", False, r.text[:400])
r = c.get("/quotes/stats")
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
stats_ok = r.status_code == 200 and "average_lead_time_days" in r.json()["data"]
if quotes:
stats_ok = stats_ok and r.json()["data"]["total"] == r.json()["data"]["offered"] + \
r.json()["data"]["accepted"] + r.json()["data"]["arrived"] + \
r.json()["data"]["rejected"] + r.json()["data"]["expired"]
check("GET /quotes/stats", stats_ok, r.text[:300])
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
r = c.get("/quotes", params={"q": "میلگرد"})
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
if materials:
needle = materials[0]["name"].split()[0]
r = c.get("/quotes", params={"q": needle})
check("GET /quotes?q (searches material names)", r.status_code == 200, r.text[:300])
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
if projects:
r = c.get(f"/projects/{projects[0]['id']}/stats")
check(
"GET /projects/{id}/stats",
r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"},
r.text[:300],
)
print(f"\n===== {PASS} passed, {FAIL} failed =====")
sys.exit(1 if FAIL else 0)
+82 -32
View File
@@ -19,9 +19,20 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
import os
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- units ----------
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
check("POST /units", r.status_code == 201 and r.json()["data"]["si_factor"] == 1.0, r.text[:200])
uid = r.json()["data"]["id"]
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
@@ -42,21 +53,28 @@ r = c.patch(f"/brands/{bid}", json={"name": "برند تست ۲"})
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
r = c.delete(f"/brands/{bid}")
check("DELETE /brands (unused)", r.status_code == 200)
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand = r.json()["data"][0]
_bml = c.get("/brand-materials", params={"page_size": 1}).json()["data"]
if _bml:
brand = {"id": _bml[0]["brand_id"]}
else:
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
r = c.delete(f"/brands/{brand['id']}")
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
# ---------- categories ----------
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
if r.status_code == 409: # rerun safety — fetch the existing test category
cid = c.get("/material-categories", params={"q": "test-cat"}).json()["data"][0]["id"]
check("POST /material-categories (exists from previous run)", True)
else:
check("POST /material-categories", r.status_code == 201, r.text[:200])
cid = r.json()["data"]["id"]
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
r = c.delete(f"/material-categories/{cid}")
check("DELETE /material-categories (empty)", r.status_code == 200)
r = c.get("/material-categories", params={"q": "میلگرد"})
cat = r.json()["data"][0]
_mat0 = c.get("/materials", params={"page_size": 1, "expand": "category"}).json()["data"][0]
cat = _mat0["category"]
r = c.delete(f"/material-categories/{cat['id']}")
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
@@ -75,9 +93,14 @@ r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "number"})
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
# use a DEDICATED test material so the seed material's EAV values stay intact
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
# (dataset-independent: any category; rerun reuses the existing test material)
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
if r.status_code == 409: # rerun
tmat = c.get("/materials", params={"q": "متریال تست EAV"}).json()["data"][0]
check("create test material for EAV tests (exists from previous run)", True)
else:
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
tmat = r.json()["data"]
mat = tmat
@@ -99,6 +122,10 @@ check("DELETE attribute-definition (no values) -> 200", r.status_code == 200, r.
# ---------- category attributes ----------
# use a FRESH definition so the pair is not already in the seed junction
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
if r.status_code == 409: # rerun safety — fetch the existing definition
newdef = next(d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]
if d["attribute_key"] == "test_ca_attr")
else:
newdef = r.json()["data"]
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
@@ -114,10 +141,11 @@ check("cleanup: DELETE unused attribute-definition", r.status_code == 200)
# ---------- material units ----------
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
check("GET material units", r.status_code == 200, r.text[:200])
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 0.001, "is_default": False})
_unit_other = next(u for u in c.get("/units", params={"page_size": 100}).json()["data"] if u["code"] != "kg")
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 0.001, "is_default": False})
check("POST material unit", r.status_code == 201, r.text[:200])
muid = r.json()["data"]["id"]
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 2})
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 2})
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
@@ -125,10 +153,10 @@ r = c.delete(f"/materials/{mat['id']}/units/{muid}")
check("DELETE material unit", r.status_code == 200)
# ---------- brand materials ----------
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
m2 = r.json()["data"][0]
r = c.get("/brands", params={"q": "Bosch"})
b2 = r.json()["data"][0]
# dataset-independent: any material + a freshly created brand (no existing link)
m2 = c.get("/materials", params={"page_size": 1}).json()["data"][0]
r = c.post("/brands", json={"name": "برند لینک تست", "country": "ایران"})
b2 = r.json()["data"] if r.status_code == 201 else c.get("/brands", params={"q": "برند لینک تست"}).json()["data"][0]
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
check("POST brand-material", r.status_code == 201, r.text[:200])
bmid = r.json()["data"]["id"]
@@ -140,18 +168,18 @@ r = c.get(f"/brand-materials/{bmid}", params={"expand": "brand,material"})
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
r = c.delete(f"/brand-materials/{bmid}")
check("DELETE brand-material", r.status_code == 200)
r = c.delete(f"/brands/{b2['id']}")
check("cleanup: DELETE link-test brand", r.status_code == 200, r.text[:200])
# ---------- supplier/project patches ----------
r = c.get("/suppliers", params={"q": "آریا"})
s1 = r.json()["data"][0]
s1 = c.get("/suppliers", params={"page_size": 1}).json()["data"][0]
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
r = c.get("/projects", params={"q": "ونوس"})
p1 = r.json()["data"][0]
p1 = c.get("/projects", params={"page_size": 1}).json()["data"][0]
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
r = c.patch(f"/projects/{p1['id']}", json={"description": "ساختمان ۱۸ طبقه مسکونی در زعفرانیه"})
r = c.patch(f"/projects/{p1['id']}", json={"description": p1.get("description")})
# ---------- quote PATCH metadata ----------
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
@@ -166,16 +194,17 @@ r = c.get(f"/quotes/{qid}/events")
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
# ---------- termsheet delete rules ----------
# termsheet-count tolerance: this suite needs >=2 termsheets on the picked quote
r = c.get(f"/quotes/{qid}/termsheets")
ts = r.json()["data"]
if len(ts) == 1:
while len(ts) < 2:
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
check("POST second termsheet", r.status_code == 201, r.text[:200])
ts = r.json()["data"]
check("POST additional termsheet", r.status_code == 201, r.text[:200])
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
_remaining = c.get(f"/quotes/{qid}/termsheets").json()["data"]
r = c.delete(f"/quotes/{qid}/termsheets/{_remaining[-1]['id']}")
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
# restore a second termsheet for later tests
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
@@ -192,31 +221,52 @@ if len(steps) > 1:
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
# ---------- decision rules (on a pending quote) ----------
r = c.get(f"/quotes/{qid}/line-items")
li = r.json()["data"][0]
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
# ---------- decision rules (need a PENDING decision — rerun-safe pick) ----------
_pending = None
for _qo in c.get("/quotes", params={"status": "offered", "page_size": 20, "expand": "line_items"}).json()["data"]:
for _lix in _qo["line_items"]:
if _lix.get("decision") and _lix["decision"]["status"] == "pending":
_pending = (_qo["id"], _lix)
break
if _pending:
break
if _pending:
_qid2, li = _pending
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "rejected"})
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen"})
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
r = c.patch(f"/quotes/{_qid2}/line-items/{li['id']}/decision/status", json={"status": "pending"})
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
else:
print(" SKIP decision rules — no offered quote with a pending decision")
# ---------- item termsheets ----------
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
its = r.json()["data"]
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
# termsheet-count tolerance marker
if _pending:
_its_quote, li = _pending
r = c.get(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets")
its = r.json().get("data", [])
if its:
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
else:
print(" SKIP PATCH item-termsheet — picked line item has no item pricing rows")
else:
print(" SKIP PATCH item-termsheet — no pending decision available")
# ---------- invoice upload on accepted/arrived (multi-file) ----------
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
arrived = r.json()["data"]
if arrived:
qa = arrived[0]
# rerun safety — remove invoices left by previous runs
for _inv in c.get(f"/quotes/{qa['id']}/invoice").json()["data"]:
c.delete(f"/quotes/{qa['id']}/invoice/{_inv['id']}")
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
+105 -35
View File
@@ -23,16 +23,45 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
import os
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- supplier + project for the test quote ----------
sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران"}).json()["data"]
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
check("POST /suppliers + /projects", True)
# dataset-independent: fetch/create whatever the seed does or doesn't have
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
r_sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران", "category_id": cat["id"]})
if r_sup.status_code == 201:
sup = r_sup.json()["data"]
check("POST /suppliers", True)
else: # rerun — the test supplier already exists
sup = c.get("/suppliers", params={"q": "تأمین‌کننده تست"}).json()["data"][0]
check("POST /suppliers (exists from previous run — 409 tolerated)", r_sup.status_code == 409, r_sup.text[:200])
r_proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"})
if r_proj.status_code == 201:
proj = r_proj.json()["data"]
check("POST /projects", True)
else:
proj = c.get("/projects", params={"q": "پروژه تست"}).json()["data"][0]
check("POST /projects (exists from previous run — 409 tolerated)", r_proj.status_code == 409, r_proj.text[:200])
# ---------- material with EAV + brands ----------
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).json()["data"][0]
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
# ensure the EAV keys this suite exercises exist (seed-independent)
for _key, _dt in (("rebar_grade", "string"), ("rebar_diameter", "number"), ("rebar_ribbed", "boolean")):
if _key not in defs:
r = c.post("/attribute-definitions", json={"attribute_key": _key, "label": _key, "data_type": _dt})
check("POST /attribute-definitions (%s)" % _key, r.status_code == 201, r.text[:200])
defs[_key] = r.json()["data"]
r = c.post(
"/materials",
@@ -48,11 +77,15 @@ r = c.post(
],
},
)
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
if r.status_code == 201:
material = r.json()["data"]
check("POST /materials (EAV+brands)", True)
else: # rerun — fetch the material a previous run created
material = c.get("/materials", params={"q": "میلگرد تست A3 سایز ۱۶"}).json()["data"][0]
check("POST /materials (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
check("new material findable via EAV filter", r.status_code == 200 and any(m["id"] == material["id"] for m in r.json()["data"]))
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
@@ -68,6 +101,11 @@ r = c.get("/category-reference-units", params={"category_id": cat["id"]})
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
if r.status_code == 409: # rerun — reuse the existing conversion
uc = next(x for x in c.get("/unit-conversions", params={"page_size": 100}).json()["data"]
if x["from_unit"] == "کیلوگرم" and x["to_unit"] == "متر" and x["category"]["id"] == cat["id"])
check("POST /unit-conversions (exists from previous run — 409 tolerated)", True)
else:
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
uc = r.json()["data"]
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
@@ -76,13 +114,19 @@ r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
if r.status_code == 201:
sc_id = r.json()["data"]["id"]
check("POST /standard-conversions", True)
else: # rerun — reuse the existing conversion
sc_id = next(x["id"] for x in c.get("/standard-conversions", params={"page_size": 100}).json()["data"]
if x["from_unit"] == "تن" and x["to_unit"] == "کیلوگرم")
check("POST /standard-conversions (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
r = c.get("/standard-conversions")
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
# ---------- nested quote creation ----------
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
_all_materials = c.get("/materials", params={"page_size": 100}).json()["data"]
material2 = next(m for m in _all_materials if m["id"] != material["id"])
r = c.post(
"/quotes",
json={
@@ -125,7 +169,11 @@ check("DELETE /quotes offered -> OK", r.status_code == 200 and r.json()["data"][
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [], "item_termsheets": []})
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
# current contract: termsheets are optional at quote level (item pricing
# is driven by item_termsheets) — a quote without them is valid
check("POST /quotes empty termsheets -> 201 (optional since v2.6)", r.status_code == 201, r.text[:200])
if r.status_code == 201:
c.delete(f"/quotes/{r.json()['data']['id']}")
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
@@ -233,33 +281,49 @@ check("GET receipt of rejected item -> 404", r.status_code == 404)
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
# ---------- backward transition ----------
# ---------- backward transition (v2.6 rule: quotes WITH inventory receipts
# are immutable — they feed the stock ledger) ----------
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
check("PATCH backward accepted->offered", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
check(
"PATCH backward accepted->offered blocked after receipts",
r.status_code == 409 and "رسید" in r.json()["error"]["message"], r.text[:300],
)
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], str(decisions))
r = c.get(f"/quotes/{qid}/termsheets")
check("decisions unchanged after blocked revert", decisions == ["chosen", "chosen", "rejected"], str(decisions))
# transition tests need a quote WITHOUT receipts — q3:
q3 = c.post("/quotes", json={
"supplier_id": sup["id"], "project_id": proj["id"],
"quote_date": "2026-08-02T10:00:00Z", "expiry_date": "2026-08-20",
"line_items": [{"temp_id": "li-x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 2}],
"termsheets": [{"temp_id": "ts-x", "label": "نقدی", "schedule_steps": [{"due_date": "2026-08-10", "amount": 5000}]}],
"item_termsheets": [{"line_item_temp_id": "li-x", "termsheet_temp_id": "ts-x", "unit_price": 2500}],
}).json()["data"]
r = c.patch(f"/quotes/{q3['id']}/accept", json={
"accepted_termsheets": [{"id": q3["termsheets"][0]["id"],
"schedule_steps": [{"step_no": 1, "due_date": "2026-08-10", "amount": 5000}]}],
"item_decisions": [{"line_item_id": q3["line_items"][0]["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 2500}],
})
check("q3 accept", r.status_code == 200, r.text[:300])
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
check("PATCH backward accepted->offered (no receipts) -> OK", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
r = c.get(f"/quotes/{q3['id']}", params={"expand": "line_items"})
check("decisions reset to pending", [li["decision"]["status"] for li in r.json()["data"]["line_items"]] == ["pending"], r.text[:200])
r = c.get(f"/quotes/{q3['id']}/termsheets")
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
receipts_kept = all(li.get("receipt") is not None for li in r.json()["data"]["line_items"] if li["decision"])
check("item_receipts NOT deleted on revert", receipts_kept, r.text[:200])
r = c.patch(f"/quotes/{qid}", json={"status": "accepted"})
r = c.patch(f"/quotes/{q3['id']}", json={"status": "accepted"})
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
# ---------- reject / expire ----------
r = c.patch(f"/quotes/{qid}/reject", json={"reason": "قیمت بالا"})
r = c.patch(f"/quotes/{q3['id']}/reject", json={"reason": "قیمت بالا"})
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.get(f"/quotes/{qid}/events")
r = c.get(f"/quotes/{q3['id']}/events")
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
r = c.patch(f"/quotes/{qid}", json={"status": "offered"})
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
r = c.patch(f"/quotes/{qid}/expire")
r = c.patch(f"/quotes/{q3['id']}/expire")
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
r = c.delete(f"/quotes/{qid}")
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, r.text[:300])
r = c.delete(f"/quotes/{q3['id']}")
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 1, r.text[:300])
# ---------- files ----------
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
@@ -269,8 +333,13 @@ q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
# current contract: attachments are allowed in EVERY status (files.py) —
# the invoice can be attached from the edit page at any time
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
check("POST invoice on offered -> 409", r.status_code == 409, r.text[:200])
check("POST invoice on offered (any-status contract)", r.status_code == 201, r.text[:200])
inv_id = r.json()["data"]["id"]
r = c.delete(f"/quotes/{q2['id']}/invoice/{inv_id}")
check("DELETE test invoice", r.status_code == 200, r.text[:200])
r = c.get(f"/quotes/{q2['id']}/proforma")
lst = r.json()["data"]
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
@@ -282,8 +351,8 @@ check("DELETE proforma by id", r.status_code == 200 and r.json()["data"]["delete
r = c.get(f"/quotes/{q2['id']}/proforma")
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
# cleanup test material + supplier + project
# q2 still references supplier/project/material -> all deletes must be blocked
# ---------- cleanup ----------
# q2 (offered) is deletable; qid is a receipts-protected artifact that stays.
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
@@ -291,14 +360,15 @@ check("DELETE project with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/materials/{material['id']}")
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
r = c.delete(f"/quotes/{q2['id']}")
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
# qid (arrived, receipt-protected) still references everything -> protected
r = c.delete(f"/materials/{material['id']}")
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
check("DELETE material (still referenced by receipt-protected quote) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
check("DELETE supplier (still referenced) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
check("DELETE project (no quotes) -> 200", r.status_code == 200, r.text[:200])
check("DELETE project (still referenced) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/unit-conversions/{uc['id']}")
check("DELETE unit-conversion", r.status_code == 200)
r = c.delete(f"/standard-conversions/{sc_id}")
Binary file not shown.
Binary file not shown.
-44
View File
@@ -1,44 +0,0 @@
# ============================================================
# Restore the committed demo-state dumps (backups/*.dump) into
# the running stack. REPLACES all data in the `procurement` and
# `casdoor` databases — run it on a fresh boot or when you want
# to reset to the exact committed state.
#
# powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1
# ============================================================
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location (Join-Path $here "..")
Write-Host ""
Write-Host "Restoring demo data — this REPLACES the current DB content!" -ForegroundColor Yellow
docker compose ps postgres | Select-String "healthy" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "postgres is not healthy — run 'docker compose up -d' first." -ForegroundColor Red
exit 1
}
Write-Host "Stopping api + casdoor (they hold DB connections)..." -ForegroundColor Cyan
docker compose stop api casdoor
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host "Restoring procurement DB..." -ForegroundColor Cyan
docker cp "backups\procurement.dump" procurement-postgres:/tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
if ($LASTEXITCODE -ne 0) { Write-Host "procurement restore FAILED" -ForegroundColor Red; exit 1 }
Write-Host "Restoring casdoor DB..." -ForegroundColor Cyan
docker cp "backups\casdoor.dump" procurement-postgres:/tmp/casdoor.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
if ($LASTEXITCODE -ne 0) { Write-Host "casdoor restore FAILED" -ForegroundColor Red; exit 1 }
Write-Host "Starting services..." -ForegroundColor Cyan
docker compose start casdoor api
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host ""
Write-Host "Restore complete." -ForegroundColor Green
Write-Host "App : http://localhost" -ForegroundColor Green
Write-Host "Casdoor : http://localhost:8080 (admin / 123)" -ForegroundColor Green
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Restore the committed demo-state dumps (backups/*.dump) into the running
# stack (Linux/macOS/Git Bash). REPLACES all data in the `procurement` and
# `casdoor` databases.
#
# bash backups/restore.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Stopping api + casdoor (they hold DB connections)..."
docker compose stop api casdoor
echo "Restoring procurement DB..."
docker cp backups/procurement.dump procurement-postgres:/tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
echo "Restoring casdoor DB..."
docker cp backups/casdoor.dump procurement-postgres:/tmp/casdoor.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
echo "Starting services..."
docker compose start casdoor api
echo ""
echo "Restore complete."
echo "App : http://localhost"
echo "Casdoor : http://localhost:8080 (admin / 123)"
+235
View File
@@ -0,0 +1,235 @@
services:
api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: procurement-api
depends_on:
minio-init:
condition: service_completed_successfully
required: true
postgres:
condition: service_healthy
required: true
environment:
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST}
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_SECURE: "false"
labels:
traefik.enable: "true"
traefik.http.routers.api-redir.entrypoints: web
traefik.http.routers.api-redir.middlewares: redirect-https@file
traefik.http.routers.api-redir.rule: Host(`${API_HOST}`)
traefik.http.routers.api.entrypoints: websecure
traefik.http.routers.api.rule: Host(`${API_HOST}`)
traefik.http.routers.api.tls: "true"
traefik.http.routers.api.tls.certresolver: letsencrypt
traefik.http.services.api.loadbalancer.server.port: "8000"
networks:
default: null
traefik_network: null
ports:
- "8000"
restart: unless-stopped
casdoor:
container_name: procurement-casdoor
depends_on:
postgres:
condition: service_healthy
required: true
environment:
RUNNING_IN_DOCKER: "true"
dataSourceName: "user=postgres password=postgres host=postgres port=5432 sslmode=disable dbname=casdoor"
dbName: casdoor
driverName: postgres
origin: https://${AUTH_HOST}
image: casbin/casdoor:latest
labels:
traefik.enable: "true"
traefik.http.routers.casdoor-redir.entrypoints: web
traefik.http.routers.casdoor-redir.middlewares: redirect-https@file
traefik.http.routers.casdoor-redir.rule: Host(`${AUTH_HOST}`)
traefik.http.routers.casdoor.entrypoints: websecure
traefik.http.routers.casdoor.rule: Host(`${AUTH_HOST}`)
traefik.http.routers.casdoor.tls: "true"
traefik.http.routers.casdoor.tls.certresolver: letsencrypt
traefik.http.services.casdoor.loadbalancer.server.port: "8000"
networks:
default: null
traefik_network: null
ports:
- "8081:8000"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
DEV_LOGIN_ENABLED: "true"
container_name: procurement-frontend
depends_on:
api:
condition: service_started
required: true
labels:
traefik.enable: "true"
traefik.http.routers.frontend-redir.entrypoints: web
traefik.http.routers.frontend-redir.middlewares: redirect-https@file
traefik.http.routers.frontend-redir.rule: Host(`${APP_HOST}`)
traefik.http.routers.frontend.entrypoints: websecure
traefik.http.routers.frontend.rule: Host(`${APP_HOST}`)
traefik.http.routers.frontend.tls: "true"
traefik.http.routers.frontend.tls.certresolver: letsencrypt
traefik.http.services.frontend.loadbalancer.server.port: "80"
networks:
default: null
traefik_network: null
restart: unless-stopped
minio:
command:
- server
- /data
- --console-address
- :9001
container_name: procurement-minio
environment:
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: minioadmin
image: minio/minio:latest
labels:
traefik.enable: "true"
traefik.http.routers.minio-api-redir.entrypoints: web
traefik.http.routers.minio-api-redir.middlewares: redirect-https@file
traefik.http.routers.minio-api-redir.rule: Host(`${S3_HOST}`)
traefik.http.routers.minio-api.entrypoints: websecure
traefik.http.routers.minio-api.rule: Host(`${S3_HOST}`)
traefik.http.routers.minio-api.service: minio-api-svc
traefik.http.routers.minio-api.tls: "true"
traefik.http.routers.minio-api.tls.certresolver: letsencrypt
traefik.http.routers.minio-console-redir.entrypoints: web
traefik.http.routers.minio-console-redir.middlewares: redirect-https@file
traefik.http.routers.minio-console-redir.rule: Host(`${STORAGE_HOST}`)
traefik.http.routers.minio-console.entrypoints: websecure
traefik.http.routers.minio-console.rule: Host(`${STORAGE_HOST}`)
traefik.http.routers.minio-console.service: minio-console-svc
traefik.http.routers.minio-console.tls: "true"
traefik.http.routers.minio-console.tls.certresolver: letsencrypt
traefik.http.services.minio-api-svc.loadbalancer.server.port: "9000"
traefik.http.services.minio-console-svc.loadbalancer.server.port: "9001"
networks:
default: null
traefik_network: null
ports:
- "9000"
- "9001"
restart: unless-stopped
volumes:
- minio_data:/data
minio-init:
container_name: procurement-minio-init
depends_on:
minio:
condition: service_started
required: true
entrypoint:
- /bin/sh
- -c
- |
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...';
sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
environment:
MINIO_BUCKET: procurement-files
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: minioadmin
image: minio/mc:latest
networks:
default: null
restart: 'no'
postgres:
container_name: procurement-postgres
environment:
POSTGRES_DB: procurement
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
healthcheck:
test:
- CMD-SHELL
- pg_isready -U postgres -d procurement
timeout: 5s
interval: 5s
retries: 10
image: postgres:18
networks:
default: null
ports:
- "5433:5432"
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
- ./sql/02-casdoor-db.sql:/docker-entrypoint-initdb.d/02-casdoor-db.sql:ro
traefik:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO
container_name: procurement-traefik
environment:
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL}
image: traefik:v3.6
networks:
traefik_network: null
ports:
- "80"
- "443"
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs
networks:
default:
name: v15_default
traefik_network:
name: traefik_network
driver: bridge
volumes:
minio_data:
name: v15_minio_data
postgres_data:
name: v15_postgres_data
traefik_certs:
name: v15_traefik_certs
+54 -27
View File
@@ -1,32 +1,72 @@
# ============================================================================
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
# redirect. Usage:
# redirect + security middlewares + required secrets.
#
# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \
# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \
# TRAEFIK_ACME_EMAIL=admin@example.com \
# All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES
# (compose loads it automatically — keep it on the VPS, never in git):
#
# cp .env.example .env # then fill in STRONG values
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# Requirements: DNS records for every host pointing at the server, ports
# 80 + 443 open (ACME http-challenge uses port 80), and the api's
# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host
# (see docs/casdoor-auth.md).
# 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is
# derived here from AUTH_HOST (see docs/casdoor-auth.md).
#
# Any `${VAR:?}` below refuses to boot when the variable is missing/empty —
# that is deliberate: production must not run on defaults.
# ============================================================================
services:
traefik:
environment:
# Certificates persist across restarts (acme.json in the traefik_certs volume).
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production}
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}
postgres:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
minio:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api.middlewares=secure-headers@file
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console.middlewares=secure-headers@file
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
api:
ports: [] # production: only reachable through Traefik
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:?set CASDOOR_CLIENT_SECRET in .env}
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:?set CASDOOR_ADMIN_PASSWORD in .env}
# Issuer must equal the browser-facing Casdoor origin (its discovery doc).
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST:?set AUTH_HOST}
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env}
PROCUREMENT_ENVIRONMENT: prod
PROCUREMENT_DEV_LOGIN_ENABLED: "false"
labels:
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true
- traefik.http.routers.api.tls.certresolver=letsencrypt
# HTTP → HTTPS
- traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file,api-bodylimit@file
- traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api-redir.entrypoints=web
- traefik.http.routers.api-redir.middlewares=redirect-https@file
@@ -41,34 +81,21 @@ services:
- traefik.http.routers.casdoor.entrypoints=websecure
- traefik.http.routers.casdoor.tls=true
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt
- traefik.http.routers.casdoor.middlewares=secure-headers@file
- traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
- traefik.http.routers.casdoor-redir.entrypoints=web
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
frontend:
build:
args:
DEV_LOGIN_ENABLED: "false"
labels:
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend.entrypoints=websecure
- traefik.http.routers.frontend.tls=true
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
- traefik.http.routers.frontend.middlewares=secure-headers@file
- traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend-redir.entrypoints=web
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file
minio:
ports: [] # production: only reachable through Traefik
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
+103 -17
View File
@@ -6,11 +6,17 @@ services:
environment:
POSTGRES_DB: procurement
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "5433:5432"
networks:
- default
- shared # monitoring stack scrapes it (postgres-exporter)
volumes:
- postgres_data:/var/lib/postgresql
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
@@ -23,6 +29,7 @@ services:
retries: 10
restart: unless-stopped
mem_limit: 1g
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
@@ -36,6 +43,7 @@ services:
image: traefik:v3.6
container_name: procurement-traefik
restart: unless-stopped
mem_limit: 128m
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
@@ -69,8 +77,8 @@ services:
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9000:9000" # S3 API
@@ -97,6 +105,7 @@ services:
- default
restart: unless-stopped
mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
@@ -110,8 +119,8 @@ services:
environment:
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files
entrypoint: >
@@ -125,6 +134,40 @@ services:
restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup:
image: postgres:18
container_name: procurement-db-backup
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-postgres}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes:
- ${BACKUP_DIR:-./backups}:/backups
entrypoint: >
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
restart: unless-stopped
mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
@@ -162,6 +205,7 @@ services:
- default
restart: unless-stopped
mem_limit: 768m
api:
build:
@@ -170,38 +214,61 @@ services:
container_name: procurement-api
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
PROCUREMENT_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
PROCUREMENT_OPENPANEL_CLIENT_SECRET: ${OPENPANEL_CLIENT_SECRET:-}
ports:
- "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks:
- traefik_network
- default
- shared # monitoring stack scrapes api:8000/metrics
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.services.api.loadbalancer.server.port=8000
networks:
- traefik_network
- default
depends_on:
postgres:
condition: service_healthy
@@ -209,14 +276,27 @@ services:
condition: service_completed_successfully
restart: unless-stopped
mem_limit: 768m
frontend:
build:
context: ./frontend
args:
# Developer Mode panel on the login page — set false for production.
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels:
- traefik.enable=true
@@ -232,6 +312,7 @@ services:
- api
restart: unless-stopped
mem_limit: 128m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
@@ -239,6 +320,11 @@ networks:
traefik_network:
name: traefik_network
driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared:
name: erp_shared
driver: bridge
volumes:
postgres_data:
+96
View File
@@ -0,0 +1,96 @@
# Operations runbook
Day-2 operations for the ERP stack: deploys, health, backups, monitoring.
Secrets and rotation live in [`SECRET-MANAGEMENT.md`](SECRET-MANAGEMENT.md);
Casdoor/auth details in [`casdoor-auth.md`](casdoor-auth.md).
## Deploy flow (push-to-deploy)
1. Work lands on `production` only via merge from a feature branch.
2. Every push to `production` hits the webhook receiver (host port 9000),
which verifies the Gitea HMAC signature, rebuilds
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
and then polls `/health/ready` (defaults to
`https://$API_HOST/health/ready` from `.env`).
3. A failed build or a failed readiness check is a **failed deploy**:
`journalctl -u erp-deploy-webhook -n 200` on the VPS.
Rollback: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build`
after `git revert` + rebuild — or re-push the previous commit.
## Health endpoints (unauthenticated, for probes)
| Endpoint | Meaning |
|---|---|
| `GET /health` | liveness — process is up |
| `GET /health/ready` | readiness — Postgres + MinIO + Casdoor all reachable (503 + per-check booleans when degraded) |
| `GET /metrics` | Prometheus exposition (scraped by the monitoring stack) |
## Backups
The `db-backup` sidecar dumps BOTH databases (`procurement`, `casdoor`)
with `pg_dump -Fc` every `BACKUP_INTERVAL_SECONDS` (default daily) into
`BACKUP_DIR` (default `./backups` → set to `/srv/erp/backups` on the VPS),
keeping `BACKUP_KEEP_DAYS` (default 14) days.
Restore (⚠️ replaces all data — stop `api` + `casdoor` first):
```bash
cd /srv/erp && docker compose -f docker-compose.yml -f docker-compose.prod.yml stop api casdoor
docker cp backups/procurement-<stamp>.dump procurement-postgres:/tmp/
docker cp backups/casdoor-<stamp>.dump procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement-<stamp>.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor-<stamp>.dump
docker compose -f docker-compose.yml -f docker-compose.prod.yml start casdoor api
```
**Test the restore quarterly** on a scratch volume — an untested backup is
a hope, not a backup. Copy archives off-box periodically (the bind mount is
still on the same disk as the volumes).
## Audit trail
`audit_log` (in the `procurement` DB) records who changed what for
role assignments, user creation, and inventory transactions. Query recent
activity:
```bash
docker exec procurement-postgres psql -U postgres -d procurement \
-c "SELECT occurred_at, actor, action, entity, entity_id FROM audit_log ORDER BY occurred_at DESC LIMIT 50;"
```
`quote_events` continues to hold the quote lifecycle history (its `actor`
is now the verified user's display name, not a raw token).
## Monitoring stack
Start/update (separate compose project — app rebuilds don't touch it):
```bash
cd /srv/erp
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
```
- **Grafana** — `127.0.0.1:3001` on the VPS (SSH tunnel), admin password in
`.env`; the «ERP — Stack overview» dashboard auto-provisions.
- **GlitchTip** — public on `GLITCHTIP_HOST` (browser DSN) + loopback
`:8001` for administration. Create backend/frontend projects there and put
the DSNs in `.env` (`SENTRY_DSN_BACKEND` / `SENTRY_DSN_FRONTEND`).
- **OpenPanel** — see `monitoring/README.md` for the self-hosting setup and
the event list the app emits.
- **Alerts** — Prometheus rules surface in Grafana alerting (API down,
5xx rate, p95 latency, disk, RAM, DB connections). Attach a notification
channel in the Grafana UI.
Logs: every container's stdout ships to Loki via Promtail — in Grafana:
`{compose_project="erp", compose_service="api"}`; JSON lines carry
`request_id`, `http_status`, `duration_ms`.
## Routine checks
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps # all Up/healthy?
docker compose logs --since 10m api | grep -i error
ls -lh /srv/erp/backups | tail # dumps fresh?
curl -fsS https://$API_HOST/health/ready | jq # all checks true?
df -h / # ≥20% free (builds need headroom)
```
+67
View File
@@ -0,0 +1,67 @@
# Secret management & rotation
**Policy (short):** real credentials live only in git-ignored `.env` files —
the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and
local dev `.env` copies. Nothing secret goes into git: not in the README,
not in dumps, not in docs. The repo is (or was) **public** — assume anything
ever committed is compromised and rotate.
`docker-compose.prod.yml` declares every production secret as
`${VAR:?set in .env}` so the stack **refuses to boot** on defaults.
## Where each secret lives
| Secret | Used by | Defined in |
|---|---|---|
| `POSTGRES_PASSWORD` | postgres, api | server `.env` |
| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` |
| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) |
| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) |
| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` |
| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config |
| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) |
## Rotation checklist — run after any leak suspicion
The production branch history contained real credentials (MinIO admin,
Casdoor passwords, server IP, DB dumps). All of the following must be
rotated once, on the VPS:
```bash
cd /srv/erp
# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env
mc alias set local http://127.0.0.1:9000 <old-user> '<old-pass>'
mc admin user add local <new-user> '<new-pass>' # then:
mc admin policy attach local consoleAdmin --user <new-user>
mc admin user remove local <old-user>
# (simplest equivalent: mc admin user svcacct or change root via env +
# `docker compose up -d minio` — MinIO persists root creds from env)
# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env
docker exec -it procurement-postgres psql -U postgres -c \
"ALTER USER postgres PASSWORD '<new-strong-password>';"
docker compose up -d api # api re-reads the DSN from .env
# 3) Casdoor console admin (console → Identity → users → admin → reset
# password) — then update CASDOOR_ADMIN_PASSWORD in .env
# 4) Casdoor application client secret (console → Applications →
# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env
# 5) Seeded human users: reset each password in the Casdoor console.
# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update
# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook)
# 7) Apply everything:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
- **SSH**: disable password login (`PasswordAuthentication no` in
`/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with
sudo over `root` direct login.
- **Repo hygiene**: keep the repo private until the git history is either
purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every
leaked value is rotated. Deleting a file in a later commit does **not**
remove it from history.
- **Verify**: after rotation, `docker compose logs api | grep -i error`,
log into the app, upload + download one attachment, and confirm the
webhook deploy still works with a test push.
+12 -12
View File
@@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself.
|---|---|---|
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) |
| Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed |
| Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
| In-network Casdoor (from the api container) | http://casdoor:8000 | — |
@@ -22,20 +22,20 @@ scenarios, test results, and how to manage the system yourself.
| Username | Password | Casdoor org | App role (our `users` table) |
|---|---|---|---|
| `razaei` | `123456` | inventory | `admin` (super user) |
| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) |
| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) |
| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) |
| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe
to re-run).
Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are
created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run).
---
## 2. Login flow (in-page, no redirect)
```
Browser ──► POST /api/v1/auth/login {username, password, organization?}
Browser ──► POST /api/v1/auth/login {username, password}
│
▼
Backend ──► POST http://casdoor:8000/api/login/oauth/access_token
@@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced.
the OIDC client. Grant types: `authorization_code`, `refresh_token`,
`password`. Token format JWT (so the backend can verify via JWKS).
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs
to it. (The Casdoor console admin `admin/123` lives in the separate
to it. (The Casdoor console admin lives in the separate
`built-in` org.)
- **User — two records per person:**
1. Casdoor user (login credential: name + password).
@@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel
| Test | Action | Result | Evidence |
|---|---|---|---|
| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned |
| 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned |
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
| 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» |
@@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel
| 6 | Organization exists | ✅ `inventory` |
| 7 | Application exists | ✅ `inventory-app` |
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) |
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) |
| 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` |
| 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified |
| 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify |
@@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel
## 9. Managing Casdoor yourself
- **Console:** http://localhost:8080 — log in as **admin / 123**. From there
- **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there
you can manage Organizations, Users, Applications, Providers, Certificates.
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
re-runnable at any time:
+10 -3
View File
@@ -5,6 +5,13 @@
VITE_API_BASE_URL=
# Show the "Developer Mode" panel on the login page.
# true (default) / false — in Docker builds this is controlled by the
# DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
VITE_ENABLE_DEV_LOGIN=true
# true (default in local dev) / false — in Docker builds this is controlled
# by the DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
VITE_ENABLE_DEV_LOGIN=
# ---- Observability (all optional — empty = disabled, no network calls) ----
# OpenPanel: browser origin of the self-hosted dashboard/API + project client id.
VITE_OPENPANEL_URL=
VITE_OPENPANEL_CLIENT_ID=
# GlitchTip/Sentry DSN for frontend exception capture.
VITE_SENTRY_DSN=
+17 -3
View File
@@ -13,11 +13,25 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci
# Developer Mode panel on the login page (default enabled; set to
# "false" to build a production login without the dev panel).
ARG DEV_LOGIN_ENABLED=true
# Developer Mode panel on the login page (dev-only aid). Production builds
# MUST keep this "false" — identity comes from Casdoor tokens, and the
# backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true.
ARG DEV_LOGIN_ENABLED=false
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Observability (Phase 2) — baked into the bundle at build time.
# Empty values disable the feature (analytics.ts is a no-op then).
ARG VITE_OPENPANEL_URL=""
ARG VITE_OPENPANEL_CLIENT_ID=""
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
# so it needs its own arg — defined here or the value never reaches the bundle.
ARG VITE_OPENPANEL_SCRIPT_URL=""
ARG VITE_SENTRY_DSN=""
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Source code + build
COPY . .
RUN npm run build
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head>
<body>
<div id="root"></div>
+20
View File
@@ -29,6 +29,26 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# MIME types: the pdf.js worker ships as .mjs, and a browser REFUSES to
# start a module worker served as application/octet-stream (nginx's
# default for unknown extensions) — which crashes the PDF preview page.
# Declared explicitly rather than relying on the system mime.types.
types {
text/javascript mjs js;
text/css css;
text/html html;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/webp webp;
image/x-icon ico;
font/woff2 woff2;
font/woff woff;
application/json json;
application/pdf pdf;
application/wasm wasm;
}
# SPA fallback — all client-side routes go to index.html
location / {
try_files $uri $uri/ /index.html;
+461 -78
View File
@@ -11,16 +11,19 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10",
"@openpanel/web": "^1.0.1",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
@@ -172,6 +175,273 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@openpanel/sdk": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
"integrity": "sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==",
"license": "MIT"
},
"node_modules/@openpanel/web": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.4.1.tgz",
"integrity": "sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==",
"license": "MIT",
"dependencies": {
"@openpanel/sdk": "1.3.1",
"@rrweb/types": "2.0.0-alpha.20",
"rrweb": "2.0.0-alpha.20"
}
},
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
@@ -308,9 +578,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -328,9 +595,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -348,9 +612,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -368,9 +629,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -388,9 +646,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -408,9 +663,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -428,9 +680,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -448,9 +697,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1207,9 +1453,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1226,9 +1469,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1485,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1264,9 +1501,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1283,9 +1517,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1302,9 +1533,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1368,6 +1596,124 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.20",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz",
"integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==",
"license": "MIT"
},
"node_modules/@rrweb/utils": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.1.6.tgz",
"integrity": "sha512-BIRXcxiytJs5Guxw0tzsnVS7ukcMqtnxixjIGvV49VdJeOEG6NJeMU7o8efbHW1D52a12Y8QsgDjgDgblg3org==",
"license": "MIT"
},
"node_modules/@sentry/browser": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz",
"integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0",
"@sentry/feedback": "10.75.0",
"@sentry/replay": "10.75.0",
"@sentry/replay-canvas": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz",
"integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/conventions": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz",
"integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/feedback": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz",
"integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz",
"integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.75.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/replay": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz",
"integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz",
"integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0",
"@sentry/replay": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1493,9 +1839,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1512,9 +1855,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1531,9 +1871,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1887,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1663,6 +1997,12 @@
"react": "^18 || ^19"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -1719,6 +2059,12 @@
}
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1731,6 +2077,15 @@
"node": ">=10"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -1991,9 +2346,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2014,9 +2366,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2037,9 +2386,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2060,9 +2406,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2134,6 +2477,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -2201,6 +2550,18 @@
}
}
},
"node_modules/pdfjs-dist": {
"version": "4.10.38",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2423,6 +2784,40 @@
"@rolldown/binding-win32-x64-msvc": "1.2.3"
}
},
"node_modules/rrdom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.1.6.tgz",
"integrity": "sha512-EMRrTs1GRsmb8bNyAs107wG7eRwVEOvDUBsnCyvQnVV6elrmrua/AeS0tdcA0gLW4eptr4l6bRsS/2QRICvdqQ==",
"license": "MIT",
"dependencies": {
"rrweb-snapshot": "^2.1.6"
}
},
"node_modules/rrweb": {
"version": "2.0.0-alpha.20",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz",
"integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==",
"license": "MIT",
"dependencies": {
"@rrweb/types": "^2.0.0-alpha.20",
"@rrweb/utils": "^2.0.0-alpha.20",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.0.0-alpha.20",
"rrweb-snapshot": "^2.0.0-alpha.20"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.1.6.tgz",
"integrity": "sha512-X03+x3AdWTbpD4Swb54CCJoEauYTv/O2ah0d3ZVv5rgr6bv0Ty5mxNjEyEEfNC1dQ8VdyjKKJgDG3A00FV0lNg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2788,9 +3183,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2811,9 +3203,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2834,9 +3223,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2857,9 +3243,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+3
View File
@@ -13,16 +13,19 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10",
"@openpanel/web": "^1.0.1",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl",
"lang": "fa",
"start_url": "/",
+288 -5
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./store/auth";
import { MobileTabBar } from "./components/layout/MobileTabBar";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects";
import { MobileTabBar } from "./components/layout/MobileTabBar";
// هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() =>
@@ -37,11 +38,14 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
);
const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
);
const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
);
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
function Loading() {
return (
@@ -54,17 +58,253 @@ function Loading() {
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const { pathname } = useLocation();
if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است.
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
</p>
<button
type="button"
onClick={() => {
useAuthStore.getState().logout();
window.location.href = "/login";
}}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
}
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
function HomeRoute() {
const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />;
}
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin";
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<ErrorBoundary>
@@ -83,10 +323,13 @@ export default function App() {
path="/wizard"
element={
<RequireAuth>
<RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth>
}
/>
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route
path="/suppliers"
element={
@@ -142,7 +385,9 @@ export default function App() {
path="/inventory"
element={
<RequireAuth>
<RequirePmProjects>
<InventoryPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -150,7 +395,9 @@ export default function App() {
path="/inventory/arrivals"
element={
<RequireAuth>
<RequirePmProjects>
<ArrivalsPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -158,7 +405,11 @@ export default function App() {
path="/inventory/change"
element={
<RequireAuth>
<RequirePmProjects>
<RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -166,7 +417,9 @@ export default function App() {
path="/inventory/daily"
element={
<RequireAuth>
<RequirePmProjects>
<DailyControlPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -174,15 +427,25 @@ export default function App() {
path="/inventory/warehouses"
element={
<RequireAuth>
<RequirePmProjects>
<RequireWarehouseManage>
<WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects>
</RequireAuth>
}
/>
<Route
path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={
<RequireAuth>
<RequireOrganization>
<OrganizationPage />
</RequireOrganization>
</RequireAuth>
}
/>
@@ -194,6 +457,26 @@ export default function App() {
</RequireAuth>
}
/>
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
+71 -15
View File
@@ -1,28 +1,84 @@
import { PanelMenu } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// ۱. مدیریت انبار (/inventory)
// ۲. مدیریت تدارکات (/)
// ۳. مدیریت پروژه (/panel/pm)
// ۴. سازمان‌دهی (/organization)
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
}: {
activeModule: "warehouse" | "procurement";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
activeModule: "warehouse" | "procurement" | "organization";
canSwitchToProcurement?: boolean;
}) {
const logout = useLogout();
const items = [];
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = [];
if (hasInventory) {
items.push({
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
});
}
if (hasProcurement && canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement",
});
}
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
}
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
active: activeModule === "organization",
});
}
items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />;
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
}
function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value);
}}
onFocus={() => setOpen(true)}
onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
@@ -1,4 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { captureError } from "../lib/analytics";
interface Props {
children: ReactNode;
@@ -19,6 +20,8 @@ export class ErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info);
// GlitchTip/Sentry (no-op بدون VITE_SENTRY_DSN)
captureError(error);
}
private retry = () => {
+130 -19
View File
@@ -1,6 +1,8 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
import type { Attachment } from "../lib/types";
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
export interface FileListProps {
files: Attachment[];
@@ -9,14 +11,27 @@ export interface FileListProps {
upload: (file: File) => Promise<Attachment>;
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده می‌شود)
onDelete?: (a: Attachment) => void;
// لینک دانلود؛ null یعنی هنوز آپلود نشده
urlOf?: (a: Attachment) => string | null;
// دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد)
onDownload?: (a: Attachment) => void | Promise<void>;
// آدرس blob احراز هویت‌شده برای پیش‌نمایش. مودال پیش‌نمایش با objectURL
// محلی کار می‌کند چون <img>/<iframe> نمی‌توانند توکن بفرستند.
loadPreview?: (a: Attachment) => Promise<string>;
label?: string;
}
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
// مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
export function FileList({
files,
onChange,
upload,
onDelete,
onDownload,
loadPreview,
label = "افزودن فاکتور",
}: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const remove = (i: number) => {
const removed = files[i];
@@ -39,22 +54,108 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
if (added.length) onChange([...files, ...added]);
};
// پیش‌نمایش: یک URL امضاشده‌ی کوتاه‌عمر می‌گیریم (نه blob) تا
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
const handleOpenPreview = async (inv: Attachment) => {
if (!loadPreview) return;
if (!inv.id) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
setLoadingPreview(inv.id);
try {
const url = await loadPreview(inv);
setPreviewItem({ url, name: inv.name });
} catch (err) {
toast.error(err instanceof Error ? err.message : "بارگذاری پیش‌نمایش ناموفق بود");
} finally {
setLoadingPreview(null);
}
};
const closePreview = () => setPreviewItem(null);
return (
<div>
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
{files.map((inv, i) => (
<div key={i} className="inv-row">
<span className="inv-name">📎 {inv.name}</span>
{urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
↓
</a>
<div className="space-y-2">
{!files.length && (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
هنوز فاکتور یا سندی بارگذاری نشده است.
</div>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
{files.length > 0 && (
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const canPreview = !!loadPreview && !!inv.id;
const isLoading = !!inv.id && loadingPreview === inv.id;
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
onClick={() => canPreview && void handleOpenPreview(inv)}
title={canPreview ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImageFile(inv.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdfFile(inv.name) ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{canPreview && (
<button
type="button"
onClick={() => void handleOpenPreview(inv)}
disabled={isLoading}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
)}
{onDownload && (
<button
type="button"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
})}
</div>
)}
<div>
<input
ref={inputRef}
@@ -64,10 +165,20 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
className="hidden"
onChange={handleChange}
/>
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
+ {label}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button>
</div>
{/* مودال پیش‌نمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
{previewItem && (
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
)}
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from "react";
import { PdfCanvas } from "./PdfCanvas";
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
export const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
export const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
/**
* آیا مرورگر می‌تواند PDF را درون‌خطی نشان دهد؟
*
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
* شبیه‌سازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
* منتهی می‌شود، پس آنجا pdf.js استفاده می‌کنیم.
*/
export function canRenderPdfNatively(): boolean {
if (typeof navigator === "undefined") return true;
const ua = navigator.userAgent || "";
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیق‌ترین معیار است
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
return !mobile;
}
export interface FilePreviewModalProps {
// آدرس امضاشده‌ی کوتاه‌عمر — چون <img>/<iframe> نمی‌توانند هدر توکن بفرستند
url: string;
name: string;
onClose: () => void;
}
/**
* مودال پیش‌نمایش فایل — مشترک بین لیست پیوست‌ها (FileList) و کارت پیشنهاد
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم می‌شود (بدون
* وابستگی به پلاگین PDF مرورگر، که در شبیه‌سازی موبایل وجود ندارد)؛ سایر
* فرمت‌ها فقط دکمه‌ی دانلود دارند.
*/
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* هدر پیش‌نمایش */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
<div className="flex items-center gap-2 min-w-0">
{isImageFile(name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
)}
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={url}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="باز کردن در برگه جدید"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
title="بستن"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* بدنه نمایش فایل */}
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
{isImageFile(name) ? (
<img
src={url}
alt={name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(name) ? (
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را می‌دهد؛ در
// شبیه‌سازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
// canvas رسم می‌کند. انتخاب در زمان اجرا با تشخیص قابلیت.
canRenderPdfNatively() ? (
<iframe
src={url}
title={name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<PdfCanvas url={url} className="w-full" />
)
) : (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
<FileText className="h-12 w-12 text-gray-400" />
<div className="text-xs text-gray-600">
امکان نمایش پیش‌نمایش زنده برای این فرمت فایل وجود ندارد.
</div>
</div>
)}
</div>
</div>
</div>
);
}
/**
* هوک مدیریت پیش‌نمایش: بارگذاری blob احراز هویت‌شده + آزادسازی objectURL.
* خروجی برای رندر <FilePreviewModal> آماده است.
*/
/**
* هوک مدیریت پیش‌نمایش: گرفتن URL امضاشده و نگه‌داشتن آن برای مودال.
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
* نیازی به آزادسازی objectURL نیست.
*/
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const open = async (id: string, name: string) => {
if (!id) return;
setLoadingId(id);
try {
const url = await load(id, name);
setItem({ url, name });
} finally {
setLoadingId(null);
}
};
const close = () => setItem(null);
return { item, loadingId, open, close };
}
+240 -39
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
daysInMonth,
gregorianToJalali,
@@ -6,6 +6,7 @@ import {
jalaliStrToIso,
jalaliToGregorian,
MONTHS,
toAsciiDigits,
toFa,
WEEKDAYS,
} from "../lib/jalali";
@@ -14,11 +15,12 @@ import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
value: string | null; // ISO date (با time اختیاری)
onChange: (iso: string | null) => void;
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
onChange: (val: string | null) => void;
placeholder?: string;
className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean;
}
function todayJalali(): [number, number, number] {
@@ -28,6 +30,13 @@ function todayJalali(): [number, number, number] {
function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null;
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const gy = parseInt(m[1], 10);
const gm = parseInt(m[2], 10);
const gd = parseInt(m[3], 10);
return gregorianToJalali(gy, gm, gd);
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
@@ -39,6 +48,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
return new Date(gy, gm - 1, gd, h, m).toISOString();
}
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
function buildDateOnly(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
@@ -60,9 +75,14 @@ export function JalaliDatePicker({
placeholder,
className,
withTime = false,
disabled = false,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const initialTime = timeOf(value);
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
const [text, setText] = useState<string>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -74,17 +94,49 @@ export function JalaliDatePicker({
return `${j} ${faNum(hh + ":" + mm)}`;
});
useEffect(() => {
if (!value) {
setText("");
const now = nowTime();
setHourText(faNum(String(now.h).padStart(2, "0")));
setMinuteText(faNum(String(now.m).padStart(2, "0")));
return;
}
const j = isoToJalaliStr(value);
const t = timeOf(value);
if (!withTime) {
setText(j);
} else {
const hh = String(t.h).padStart(2, "0");
const mm = String(t.m).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
if (curH !== t.h) {
setHourText(faNum(hh));
}
const curM = parseInt(toAsciiDigits(minuteText) || "0", 10);
if (curM !== t.m) {
setMinuteText(faNum(mm));
}
}
const jParts = isoToJalali(value);
if (jParts) setCurJ(jParts);
}, [value, withTime]);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
function selectedLocalDate(): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const selectedIso = selectedLocalDate();
const time = timeOf(value);
const grid = useMemo(() => {
const [jy, jm] = curJ;
@@ -124,35 +176,108 @@ export function JalaliDatePicker({
};
const selectDay = (jy: number, jm: number, jd: number) => {
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
onChange(isoWithTime);
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
const validM = Math.min(59, Math.max(0, currentM));
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}`
);
setOpen(false);
};
const setTimePart = (part: "h" | "m", raw: string) => {
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
if (Number.isNaN(n)) return;
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
const handleHourChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setHourText("");
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(23, Math.max(0, n));
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
setHourText(faNum(textToShow));
const [jy, jm, jd] = curJ;
onChange(buildIso(jy, jm, jd, h, m));
// به‌روزرسانی نمایش
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(23, Math.max(0, n));
setHourText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleMinuteChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setMinuteText("");
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(59, Math.max(0, n));
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
setMinuteText(faNum(textToShow));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
};
const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(59, Math.max(0, n));
setMinuteText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
};
const pickToday = () => {
const now = new Date();
const [jy, jm, jd] = todayJalali();
// بدون withTime فقط تاریخ امروز ثبت می‌شود — ساعت همیشه نیمه‌شب
const isoWithTime = withTime
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
: buildIso(jy, jm, jd, 0, 0);
onChange(isoWithTime);
const nh = now.getHours();
const nm = now.getMinutes();
setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
? buildIso(jy, jm, jd, nh, nm)
: buildDateOnly(jy, jm, jd);
onChange(val);
setText(
withTime
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
);
setOpen(false);
@@ -161,32 +286,98 @@ export function JalaliDatePicker({
const clear = () => {
onChange(null);
setText("");
const now = nowTime();
setHourText(faNum(String(now.h).padStart(2, "0")));
setMinuteText(faNum(String(now.m).padStart(2, "0")));
setOpen(false);
};
const onInputChange = (v: string) => {
setText(v);
if (!v.trim()) {
onChange(null);
return;
}
const iso = jalaliStrToIso(v);
if (iso) {
if (!withTime) {
const nums = String(v)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
} else {
onChange(iso.slice(0, 10));
}
} else {
onChange(iso);
}
const j = isoToJalali(iso);
if (j) setCurJ([j[0], j[1], j[2]]);
}
};
const handleInputBlur = () => {
if (!text.trim()) {
onChange(null);
return;
}
const iso = jalaliStrToIso(text);
if (iso) {
if (!withTime) {
const nums = String(text)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} else {
onChange(iso.slice(0, 10));
setText(isoToJalaliStr(iso));
}
} else {
onChange(iso);
const j = isoToJalaliStr(iso);
const d = new Date(iso);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
}
}
} else if (value) {
const j = isoToJalaliStr(value);
if (!withTime) {
setText(j);
} else {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
}
}
}
};
return (
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={disabled}
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={text}
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
autoComplete="off"
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur}
/>
{open && (
{open && !disabled && (
<div
className={cn(
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
@@ -195,6 +386,7 @@ export function JalaliDatePicker({
withTime ? "w-80 p-3" : "w-64 p-2",
)}
>
{/* 1. Header: Month / Year Navigation */}
<div className="mb-1.5 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
«
@@ -212,6 +404,8 @@ export function JalaliDatePicker({
»
</button>
</div>
{/* 2. Day of Week Header & Days Grid */}
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
@@ -238,33 +432,40 @@ export function JalaliDatePicker({
),
)}
</div>
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
{withTime && (
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
<div className="flex items-center gap-1.5">
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
<input
type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.h).padStart(2, "0"))}
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={minuteText}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("h", e.target.value)}
onFocus={(e) => e.target.select()}
onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur}
/>
</div>
<span className="text-muted">:</span>
<div className="flex items-center gap-1.5">
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input
type="text"
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
value={faNum(String(time.m).padStart(2, "0"))}
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
value={hourText}
inputMode="numeric"
maxLength={2}
onChange={(e) => setTimePart("m", e.target.value)}
onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/>
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
</div>
</div>
)}
{/* 4. Action Buttons Footer */}
<div className="mt-2 flex justify-between border-t border-line pt-2">
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
امروز
+21 -5
View File
@@ -6,29 +6,45 @@ export interface MilestoneRowProps {
milestone: Milestone;
onChange: (m: Milestone) => void;
onRemove: () => void;
disabled?: boolean;
}
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return (
<div className="milestone-row">
<div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-28 shrink-0">
<JalaliDatePicker
value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
placeholder="تاریخ"
className="w-36 shrink-0"
disabled={disabled}
/>
<div className="ms-amount-wrap">
</div>
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0">
<NumberInput
value={milestone.amount}
onChange={(amount) => onChange({ ...milestone, amount })}
placeholder="مبلغ (تومان)"
showWords
disabled={disabled}
/>
</div>
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
{/* دکمه حذف */}
{!disabled && (
<button
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
×
</button>
)}
</div>
);
}
@@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps {
className?: string;
inputClassName?: string;
allowCreate?: boolean; // اجازه‌ی تایپ مقدار جدید (پیش‌فرض true)
disabled?: boolean;
}
// چند-انتخابی خلاق (creatable multi-select) با چیپ‌ها:
@@ -27,6 +28,7 @@ export function MultiSelectCombobox({
className,
inputClassName,
allowCreate = true,
disabled = false,
}: MultiSelectComboboxProps) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
@@ -105,12 +107,16 @@ export function MultiSelectCombobox({
return (
<div ref={ref} className={cn("relative", className)}>
<div
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
onClick={() => inputRef.current?.focus()}
className={cn(
"flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40",
disabled && "cursor-not-allowed opacity-60 bg-canvas/40",
)}
onClick={() => !disabled && inputRef.current?.focus()}
>
{values.map((v) => (
<span key={v} className="spec-chip">
<span className="max-w-[140px] truncate">{v}</span>
{!disabled && (
<button
type="button"
className="spec-chip-x"
@@ -122,29 +128,35 @@ export function MultiSelectCombobox({
>
×
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
disabled={disabled}
className={cn(
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted disabled:cursor-not-allowed",
inputClassName,
)}
value={text}
placeholder={values.length ? "" : placeholder}
autoComplete="off"
onChange={(e) => {
if (disabled) return;
setText(e.target.value);
setHighlight(-1);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
onFocus={() => !disabled && setOpen(true)}
onKeyDown={(e) => {
if (disabled) return;
onKeyDown(e);
}}
/>
</div>
{open && (
{open && !disabled && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">
+26 -17
View File
@@ -7,9 +7,12 @@ export interface NumberInputProps {
onChange: (n: number | null) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
maxDecimals?: number; // حداکثر رقم اعشار
disabled?: boolean;
}
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
@@ -18,9 +21,12 @@ export function NumberInput({
onChange,
placeholder,
className,
inputClassName,
align = "right",
showWords = false,
inputMode = "numeric",
maxDecimals,
disabled = false,
}: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => {
@@ -34,8 +40,6 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => {
const cur = parseNum(text);
if (value != null && value !== cur) {
@@ -58,18 +62,28 @@ export function NumberInput({
const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return (
<div className={cn("min-w-0", className)}>
<div className={cn("min-w-0 w-full", className)}>
<input
type="text"
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={disabled}
className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
value={text}
placeholder={placeholder}
inputMode={inputMode}
onChange={(e) => {
const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) {
@@ -82,16 +96,11 @@ export function NumberInput({
}}
onBlur={() => commit(text)}
/>
{showWords && (
<span className={cn("num-pill", n == null && "empty")}>
{n != null ? (
<>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
</>
) : (
""
)}
</span>
{showWords && n != null && (
<div className="mt-1 block text-right text-[11px] text-muted">
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<span className="text-muted">({toWords(n)})</span>
</div>
)}
</div>
);
+92 -2
View File
@@ -1,19 +1,24 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Menu, X } from "lucide-react";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "../lib/utils";
export interface MenuItem {
label?: string;
to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean;
divider?: boolean;
onClick?: () => void;
// آیتم بازشو (آکاردئونی) — مثلاً فهرست پروژه‌های مدیر پروژه زیر «مدیریت انبار»
children?: MenuItem[];
}
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنل‌ها
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -42,11 +47,96 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
{items.map((it, i) =>
it.divider ? (
<div key={i} className="my-1 border-t border-line" />
) : it.children?.length ? (
<div key={i}>
<div className={cn("flex items-center rounded-lg", it.active && "bg-accent")}>
{it.to ? (
<Link
to={it.to}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"flex flex-1 items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
</Link>
) : (
<button
type="button"
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className="flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
>
{it.label}
</button>
)}
<button
type="button"
aria-label={expanded === i ? "بستن فهرست پروژه‌ها" : "نمایش پروژه‌ها"}
aria-expanded={expanded === i}
onClick={() => setExpanded(expanded === i ? null : i)}
className={cn(
"me-1 inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
it.active ? "text-white hover:bg-white/15" : "text-muted hover:bg-slate-bg",
)}
>
<ChevronDown
className={cn("h-4 w-4 transition-transform", expanded === i && "rotate-180")}
/>
</button>
</div>
{expanded === i && (
<div className="mt-0.5 flex flex-col gap-0.5 border-s border-line ps-2 ms-3">
{it.children.map((c, j) => (
<Link
key={j}
to={c.to ?? "#"}
onClick={() => {
setOpen(false);
c.onClick?.();
}}
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] no-underline",
c.active ? "bg-accent-bg font-bold text-accent" : "text-ink hover:bg-slate-bg",
)}
>
<span className="truncate">{c.label}</span>
{c.active && <span className="shrink-0 text-[10px]">✓ فعلی</span>}
</Link>
))}
</div>
)}
</div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? (
<Link
key={i}
to={it.to}
onClick={() => setOpen(false)}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
/**
* نمایشگر PDF با pdf.js (canvas) به‌جای <iframe>.
*
* چرا: کروم در حالت شبیه‌سازی موبایل (F12) پلاگین PDF درون‌خطی ندارد، پس
* <iframe src="...pdf"> را به‌جای نمایش، دانلود می‌کند — که دانلودمنیجرها
* شکارش می‌کنند و پیش‌نمایش خالی می‌ماند. با رسم روی canvas هیچ ناوبری‌ای
* به آدرس فایل انجام نمی‌شود، پس نه به پلاگین مرورگر وابسته‌ایم و نه چیزی
* برای دانلودمنیجر باقی می‌ماند.
*/
interface PdfCanvasProps {
url: string;
className?: string;
}
export function PdfCanvas({ url, className }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [pageCount, setPageCount] = useState(0);
useEffect(() => {
let cancelled = false;
let doc: { destroy: () => void } | null = null;
(async () => {
setLoading(true);
setError(null);
try {
// بارگذاری تنبل: pdf.js فقط وقتی پیش‌نمایش PDF باز می‌شود دانلود می‌شود
const pdfjs = await import("pdfjs-dist");
// فقط workerSrc ست می‌شود — ست‌کردن هم‌زمان workerPort باعث تعارض
// می‌شد و بارگذاری سند در حلقه گیر می‌کرد («در حال بارگذاری…»).
// فایل worker با پسوند .mjs سرو می‌شود و nginx آن را
// text/javascript اعلام می‌کند (وگرنه مرورگر worker را رد می‌کند).
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
// بایت‌ها را خودمان با fetch می‌گیریم و به pdf.js به‌صورت داده
// می‌دهیم، به‌جای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
// (IDM) درخواست pdf.js به آدرس PDF را می‌رباید و با 204 برمی‌گرداند
// («Unexpected server response (204)»). با داده‌ی درون‌حافظه هیچ
// درخواست قابل‌ربودنی برای دانلودمنیجر باقی نمی‌ماند.
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const task = pdfjs.getDocument({ data: bytes });
// اگر worker/شبکه در دسترس نباشد، به‌جای چرخ «در حال بارگذاری…»
// بی‌پایان، خطای قابل‌فهم نشان بده.
const loaded = await Promise.race([
task.promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
),
]);
doc = loaded as unknown as { destroy: () => void };
if (cancelled) return;
setPageCount(loaded.numPages);
// فعلاً صفحه‌ی اول — برای پیش‌نمایشِ سریع کافی است
const page = await loaded.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
const containerWidth = canvas.parentElement?.clientWidth || 800;
const baseViewport = page.getViewport({ scale: 1 });
const dpr = window.devicePixelRatio || 1;
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
const viewport = page.getViewport({ scale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, viewport }).promise;
if (!cancelled) setLoading(false);
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
}
}
})();
return () => {
cancelled = true;
try {
doc?.destroy();
} catch {
/* نادیده بگیر */
}
};
}, [url]);
return (
<div className={className}>
{loading && !error && (
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
)}
{error ? (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="text-xs text-gray-600">نمایش پیش‌نمایش ممکن نشد: {error}</div>
</div>
) : (
<>
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
{pageCount > 1 && (
<div className="mt-2 text-center text-[11px] text-gray-500">
صفحه ۱ از {pageCount}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,129 @@
import { useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface ProjectOption {
id: string;
name: string;
}
// چند-انتخابیِ جست‌وجوپذیرِ پروژه‌ها (1:N — مدیر پروژه ↔ پروژه‌ها):
// - چیپ‌های پروژه با دکمه‌ی × (جداسازی فوری مدیر از پروژه)
// - جست‌وجوی زنده‌ی فارسی (normalize: ی/ك عربی، نیم‌فاصله و فاصله‌ها)
// - پروژه‌های انتخاب‌شده از فهرست پنهان می‌شوند تا انتخاب تکراری رخ ندهد
export function ProjectMultiSelect({
options,
values,
onChange,
placeholder = "انتخاب پروژه(ها)...",
disabled = false,
}: {
options: ProjectOption[];
values: string[];
onChange: (ids: string[]) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useClickOutside<HTMLDivElement>(() => {
setOpen(false);
setText("");
});
const byId = useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
const selected = values.map((id) => byId.get(id)).filter(Boolean) as ProjectOption[];
const q = normalize(text);
const available = useMemo(
() =>
options.filter((o) => !values.includes(o.id) && (!q || normalize(o.name).includes(q))),
[options, values, q],
);
const add = (id: string) => {
onChange([...values, id]);
setText("");
inputRef.current?.focus();
};
const remove = (id: string) => onChange(values.filter((v) => v !== id));
return (
<div ref={ref} className="relative min-w-52">
<div
className={cn(
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 text-[12px]",
disabled ? "opacity-60" : "cursor-text",
)}
onClick={() => {
if (!disabled) {
setOpen(true);
inputRef.current?.focus();
}
}}
>
{selected.map((p) => (
<span
key={p.id}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-semibold text-accent"
>
<span className="truncate">{p.name}</span>
{!disabled && (
<button
type="button"
aria-label={`جدا کردن ${p.name}`}
onClick={(e) => {
e.stopPropagation();
remove(p.id);
}}
className="cursor-pointer rounded p-0.5 hover:bg-accent/10"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
dir="rtl"
disabled={disabled}
value={text}
onChange={(e) => {
setText(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={selected.length ? "" : placeholder}
className="h-7 min-w-24 flex-1 border-0 bg-transparent px-1 text-[12px] outline-none placeholder:text-muted/70"
/>
</div>
{open && !disabled && (
<div className="absolute z-40 mt-1 max-h-56 w-full min-w-52 overflow-auto rounded-xl border border-line bg-panel p-1 shadow-lg">
{available.length ? (
available.map((o) => (
<button
key={o.id}
type="button"
onClick={() => add(o.id)}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-1.5 text-start text-[12.5px] text-ink hover:bg-slate-bg"
>
{o.name}
</button>
))
) : (
<div className="px-2.5 py-2 text-[11.5px] text-muted">
{options.length === 0
? "پروژه‌ای وجود ندارد"
: q
? "پروژه‌ای یافت نشد"
: "همه‌ی پروژه‌ها متصل شده‌اند"}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,82 @@
import { useEffect, useState, type ReactNode } from "react";
import { apiV26 } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { useLogout } from "../pages/Inventory/panelMenu";
// گیتِ پروژه‌های مدیر پروژه (PM → 1:N):
// - لیست پروژه‌های تخصیص‌یافته را از بک‌اند می‌گیرد و پروژه‌ی فعالِ ذخیره‌شده را
// اعتبارسنجی می‌کند؛ اگر پروژه باطل شده باشد به اولین پروژه برمی‌گردد
// (localStorage هم با همان store به‌روز می‌شود).
// - اگر هیچ پروژه‌ای تخصیص نیافته باشد: صفحه‌ی خالی با پیام + دکمه‌ی خروج
// (بدون حلقه‌ی ریدایرکت و بدون باطل‌کردن نشست).
// - حالت توسعه (بدون توکن) از همین پروژه‌ی انتخابیِ dev استفاده می‌کند.
export function RequirePmProjects({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user);
const isDev = useAuthStore((s) => s.isDev);
const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout();
// پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => {
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok");
return;
}
if (isDev) {
setState(user.project_id ? "ok" : "empty");
return;
}
let alive = true;
setState("loading");
void apiV26.users
.myProjects()
.then((list) => {
if (!alive) return;
setProjects(list); // پروژه‌ی فعال را اعتبارسنجی/جایگزین می‌کند
setState(list.length ? "ok" : "empty");
})
.catch(() => {
// خطای شبکه نباید کاربر را قفل کند — با وضعیت فعلی ادامه می‌دهیم
if (alive) setState("ok");
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted">
در حال بارگذاری پروژه‌ها…
</div>
);
}
if (state === "empty") {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold">هیچ پروژه‌ای به شما تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
در حال حاضر هیچ پروژه‌ای به شما تخصیص داده نشده است. برای دسترسی به پنل انبار، از مدیر
سیستم بخواهید پروژه‌ای را به شما اختصاص دهد.
</p>
<button
type="button"
onClick={logout}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
return <>{children}</>;
}
+102 -18
View File
@@ -1,38 +1,122 @@
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
import { fmtNum } from "../lib/formatters";
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
export interface StatsBarProps {
total: number;
offered: number;
accepted: number;
avgLeadTime: number | null; // روز
arrived: number;
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
}
// نوار ۴ ستونه‌ی آمار
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList },
// پیشنهاد باز = نارنجی، پذیرفته‌شده = آبی، رسیده = سبز
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox },
{ label: "پذیرفته‌شده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 },
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer },
export function StatsBar({
total,
offered,
accepted,
arrived,
activeFilter = "all",
onFilterChange,
}: StatsBarProps) {
const stats: {
key: StatusFilter;
label: string;
shortLabel: string;
value: string;
unit?: string;
color: string;
icon: LucideIcon;
}[] = [
{
key: "all",
label: "کل پیشنهادها",
shortLabel: "کل",
value: fmtNum(total),
color: "text-ink",
icon: ClipboardList,
},
{
key: "offered",
label: "پیشنهاد باز",
shortLabel: "باز",
value: fmtNum(offered),
color: "text-amber",
icon: Inbox,
},
{
key: "accepted",
label: "پذیرفته‌شده",
shortLabel: "پذیرفته",
value: fmtNum(accepted),
color: "text-[#1d4ed8]",
icon: CheckCircle2,
},
{
key: "arrived",
label: "تحویل‌شده به کارگاه",
shortLabel: "تحویل",
value: fmtNum(arrived),
color: "text-green",
icon: PackageCheck,
},
];
const handleClick = (key: StatusFilter) => {
if (!onFilterChange) return;
if (key === "all") {
onFilterChange("all");
} else {
onFilterChange(activeFilter === key ? "all" : key);
}
};
return (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => {
const isClickable = Boolean(onFilterChange);
const isActive = activeFilter === s.key;
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats.map((s) => (
<div
key={s.label}
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40"
onClick={() => handleClick(s.key)}
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
>
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium text-muted">{s.label}</div>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted">
{/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between">
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
<span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span>
{/* آیکون در دسکتاپ */}
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</div>
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
{/* مقدار عددی */}
<div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
>
<span>{s.value}</span>
{s.unit && (
<span className="text-[10px] font-medium text-muted sm:text-xs">
{s.unit}
</span>
)}
</div>
))}
</div>
);
})}
</section>
);
}
+30 -25
View File
@@ -8,19 +8,17 @@ export interface TermsheetItem {
}
export interface TermsheetCardProps {
index: number; // 1-based → «گزینه N»
index: number;
milestones: Milestone[];
onMilestonesChange: (ms: Milestone[]) => void;
// اقلام قابل تیک (مرحله ۲)
items?: TermsheetItem[];
coveredIds?: string[]; // tempIdهایی که تیک دارند
coveredIds?: string[];
onCoverageChange?: (ids: string[]) => void;
onRemove?: () => void;
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیش‌فرض همه تیک بخورند
defaultAll?: boolean;
removable?: boolean;
}
// کارت ترم‌شیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
export function TermsheetCard({
index,
milestones,
@@ -33,7 +31,6 @@ export function TermsheetCard({
removable = true,
}: TermsheetCardProps) {
const covered = new Set(coveredIds);
// پیش‌فرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
const toggle = (tempId: string, checked: boolean) => {
@@ -49,17 +46,25 @@ export function TermsheetCard({
};
return (
<div className="variant-card">
<div className="variant-head">
<span className="variant-num">گزینه {faNum(index)}</span>
<div className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-[13px]">گزینه {faNum(index)}</span>
{removable && onRemove && (
<button type="button" className="variant-x" title="حذف" onClick={onRemove}>
×
<button
type="button"
className="text-gray-400 hover:text-red-500 p-1 rounded transition"
title="حذف گزینه"
onClick={onRemove}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
<div className="mb-1 flex flex-col gap-1.5">
<div className="mb-2 flex flex-col gap-1.5">
{milestones.map((m, i) => (
<MilestoneRow
key={i}
@@ -69,31 +74,31 @@ export function TermsheetCard({
/>
))}
</div>
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
+ مرحله پرداخت
<button
type="button"
className="w-full py-1.5 border border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-600 rounded-lg text-[12px] text-gray-500 transition mb-3"
onClick={addMilestone}
>
+ مرحله پرداخت
</button>
{items.length > 0 && (
<div className="ts-items-box">
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div>
{items.length === 0 ? (
<div className="ts-items-empty">هنوز قلمی در مرحله‌ی ۲ اضافه نشده.</div>
) : (
<div>
<div className="pt-2 border-t border-line/60">
<div className="text-[12px] text-muted mb-1.5">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div>
<div className="flex flex-col gap-1 text-[12px]">
{items.map((it) => (
<label key={it.tempId} className="ts-item-label">
<label key={it.tempId} className="flex items-center gap-2 cursor-pointer text-gray-700">
<input
type="checkbox"
className="ts-item-cb"
data-item-temp-id={it.tempId}
className="rounded border-gray-300"
checked={effectiveCovered.includes(it.tempId)}
onChange={(e) => toggle(it.tempId, e.target.checked)}
/>
{it.label}
<span>{it.label}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
+38 -9
View File
@@ -5,33 +5,62 @@ import { faNum } from "../lib/formatters";
import { cn } from "../lib/utils";
export interface TopicGridProps {
topics: Topic[]; // از store (API) می‌آید
topics: Topic[];
selectedTopic?: string;
onSelect: (topicName: string) => void;
showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک
counts?: Record<string, number>; // topic name → count
showCounts?: boolean;
counts?: Record<string, number>;
}
// گرید تاپیک‌ها (از /material-categories — نه لیست ثابت)
export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
if (!topics.length) {
return <EmptyState emoji="🗂️" text="در حال بارگذاری دسته‌بندی‌ها…" />;
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-3 lg:grid-cols-4">
{topics.map((t) => {
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
const count = counts?.[t.name] ?? 0;
return (
<button
type="button"
key={t.key}
className={cn("topic-card", selected && "selected")}
onClick={() => onSelect(t.name)}
className={cn(
"group relative flex flex-col items-center justify-between rounded-2xl border bg-white p-2.5 sm:p-4 text-center transition-all duration-200 cursor-pointer shadow-xs active:scale-[0.98] overflow-hidden",
selected
? "border-blue-600 ring-2 ring-blue-600/25 shadow-md bg-blue-50/20"
: "border-slate-200 hover:border-blue-300 hover:shadow-md"
)}
>
<img src={t.icon} alt="" />
<div className="t-name">{t.name}</div>
{showCounts && <div className="t-count">{faNum(count)} تأمین‌کننده</div>}
{/* کادر تصویر بسیار بزرگ‌تر شده برای پر کردن تمام فضای مربع */}
<div className="flex h-36 sm:h-48 md:h-52 w-full items-center justify-center overflow-hidden rounded-xl bg-slate-50/50">
<img
src={t.icon}
alt={t.name}
className="h-full w-full object-contain transform scale-115 group-hover:scale-125 transition-transform duration-300 drop-shadow-xs"
loading="lazy"
/>
</div>
{/* بخش عنوان و تعداد */}
<div className="mt-3 w-full pb-1">
<div
className={cn(
"truncate text-[13px] sm:text-[15px] font-bold transition-colors",
selected ? "text-blue-600" : "text-slate-800 group-hover:text-blue-600"
)}
>
{t.name}
</div>
{showCounts && (
<div className="mt-0.5 truncate text-[11px] sm:text-xs text-slate-400 font-medium">
{faNum(count)} تأمین‌کننده
</div>
)}
</div>
</button>
);
})}
+23 -2
View File
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react";
import { cn } from "../../lib/utils";
import { useAuthStore, type AuthUser } from "../../store/auth";
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
export function userSubtitle(user: AuthUser | null | undefined): string {
if (!user) return "";
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const username = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
}
interface HeaderProps {
title: string;
@@ -18,6 +35,10 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم.
@@ -38,7 +59,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<button
type="button"
onClick={goBack}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink max-md:px-2.5 max-md:py-2"
className="hidden md:inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink"
>
<ChevronRight className="h-4 w-4" />
{backLabel}
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<div className="flex min-w-0 items-center gap-2.5">
<div className="min-w-0">
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
{sub && <div className="text-[11px] text-muted">{sub}</div>}
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
</div>
</div>
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,7 +8,6 @@ import {
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
@@ -37,18 +36,18 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
// تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel/95 backdrop-blur-md md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="grid grid-cols-4">
@@ -59,13 +58,13 @@ function TabBar({ tabs }: { tabs: TabDef[] }) {
end={t.end}
className={({ isActive }) =>
cn(
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
isActive ? "text-accent" : "text-muted",
"flex min-h-[66px] flex-col items-center justify-center gap-1.5 whitespace-nowrap px-1 py-1 transition-all active:scale-95",
isActive ? "text-accent font-bold" : "text-muted hover:text-ink font-medium",
)
}
>
<t.icon className="h-5 w-5" strokeWidth={1.8} />
<span>{t.label}</span>
<t.icon className="h-6.5 w-6.5" strokeWidth={2.2} />
<span className="text-[12px] leading-tight tracking-tight">{t.label}</span>
</NavLink>
))}
</div>
@@ -81,14 +80,24 @@ export function MobileTabBar() {
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
const isPM = role === "pm";
const isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard") return null;
const onOfficePage =
pathname === "/" ||
+5 -5
View File
@@ -4,17 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
slate: "bg-slate-bg text-ink hover:bg-line",
line: "border border-line bg-panel text-ink hover:bg-slate-bg",
slate: "bg-slate-bg text-ink border border-transparent hover:brightness-95",
line: "border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg",
ghost: "hover:bg-slate-bg text-ink",
green: "bg-green text-white hover:brightness-95",
danger: "bg-red-bg text-red hover:bg-red hover:text-white",
green: "bg-green text-white shadow-sm hover:brightness-95",
danger: "bg-red-bg text-red shadow-sm hover:bg-red hover:text-white",
},
size: {
default: "h-9 px-4 py-2",
+2 -9
View File
@@ -2,12 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects";
export {
useUnits,
useSaveRefUnit,
useSaveConversion,
useDeleteConversion,
useSaveStandardConversion,
useDeleteStandardConversion,
} from "./useUnits";
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+1 -1
View File
@@ -17,7 +17,7 @@ export function useAddMaterial() {
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (m: Material) => api.materials.create(m),
onMutate: (m) => addMaterial(m),
onSuccess: (created) => addMaterial(created),
onSettled: () => invalidate(),
});
}
+12
View File
@@ -21,3 +21,15 @@ export function useAddProject() {
onSettled: () => invalidate(),
});
}
export function useDeleteProject() {
const deleteProject = useAppStore((s) => s.deleteProject);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.projects.delete(id),
onMutate: (id) => deleteProject(id),
onSettled: () => invalidate(),
});
}
+4 -40
View File
@@ -1,52 +1,16 @@
// ============================================================
// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت
// hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند)
// ============================================================
import { useMutation } from "@tanstack/react-query";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
import type { StandardConversion } from "../lib/types";
import { api } from "../lib/api";
import { useAppStore } from "../store";
import { useInvalidateStore } from "./useStoreData";
export function useUnits(): {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
} {
const categoryUnits = useAppStore((s) => s.categoryUnits);
const unitConversions = useAppStore((s) => s.unitConversions);
export function useUnits(): { standardConversions: StandardConversion[] } {
const standardConversions = useAppStore((s) => s.standardConversions);
return { categoryUnits, unitConversions, standardConversions };
}
export function useSaveRefUnit() {
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
onMutate: (cu) => setCategoryUnit(cu),
onSettled: () => invalidate(),
});
}
export function useSaveConversion() {
const setConversion = useAppStore((s) => s.setConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
onMutate: (c) => setConversion(c),
onSettled: () => invalidate(),
});
}
export function useDeleteConversion() {
const deleteConversion = useAppStore((s) => s.deleteConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.units.deleteConversion(id),
onMutate: (id) => deleteConversion(id),
onSettled: () => invalidate(),
});
return { standardConversions };
}
export function useSaveStandardConversion() {

Some files were not shown because too many files have changed in this diff Show More