46 Commits
Author SHA1 Message Date
fatemeh sane 692343423c feat(dashboard): implement floating waterfall inventory bridge with step-line balance 2026-10-15 15:21:08 +03:30
fatemeh sane eedb821e3b feat(dashboard): update drill-down logic and implement 100% stacked bars 2026-10-14 21:45:20 +03:30
fatemeh sane 3d16c435d0 update dashboard logic 2026-10-13 21:49:22 +03:30
fatemeh sane 34b987398a Fix drill-down logic and structure in dashboard 2026-10-08 15:28:53 +03:30
fatemeh sane 7bb432e715 feat(dashboard): supporting schema, seed, catalog links, funnel/PM labels and API contract (merge resolution) 2026-10-08 11:18:57 +03:30
fatemeh sane b82c039265 feat(dashboard): reconstruct backend router/service, register route, fix suppliers NameError 2026-10-08 11:18:47 +03:30
fatemeh sane d84637769a fix(dashboard): re-apply filter clear/toggle, 3-level drill-down, Persian formatting + wire /dashboard route 2026-10-08 11:18:47 +03:30
fatemeh sane b0776da061 chore: restore MVP dashboard components from backup 2026-10-08 11:16:15 +03:30
MMD d38cf38bd9 fix(pm): project tiles link by project.id (op_project_id is a dropped legacy column)
Prefers id like ProjectsPage does; the backend serializer returns id (UUID)
and schema_sync removes the legacy op_project_id column at startup.
2026-10-07 15:35:56 +03:30
MMD a1220db844 build(pm): mirror-aware pip/npm registries in PM Dockerfiles (same pattern as api/frontend/bot) 2026-10-07 15:28:43 +03:30
MMD 1190ec42eb feat(pm): add pm-api/pm-frontend services to the ERP stack (serves /pm for the PM panel)
PM joins the ERP traefik (PathPrefix /pm, priority 100), shares the ERP database (additive tables at startup), and reads ERP panels for SSO. PIP_INDEX_URL build arg mirrors the api/bot images.
2026-10-07 15:19:11 +03:30
MMD 2747f9d4bb chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
2026-10-07 15:14:20 +03:30
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30
MMD dcc4d693f5 fix(frontend): declare pathname alongside location in RequireAuth (pm merge semantic conflict) 2026-10-06 15:47:32 +03:30
MMD 64bd292335 merge: pm (unified access matrix, limited procurement flow, platform rebrand)
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/pages/Auth/LoginPage.tsx
#	frontend/src/pages/Wizard/WizardPage.tsx
2026-10-06 15:45:23 +03:30
MMD 98c4b28815 merge: prod-v10 (prod-v8 + prod-v9 consolidation, list-based ChangeInventoryPage + return ledger)
# Conflicts:
#	frontend/src/pages/Inventory/ChangeInventoryPage.tsx
2026-10-06 15:41:28 +03:30
MMD aba9ba6486 merge: prod-v7 (bot/ build-context rename) into the all-branches integration 2026-10-06 15:40:05 +03:30
MMD 965054093e fix(frontend): repair merge-damaged syntax left by the pm/RBAC revert cycle
Stray braces in App.tsx / ChangeInventoryPage.tsx, an orphaned admin gate in ArrivalsPage.tsx, and a literal revert commit subject pasted into LoginPage.tsx — all made the committed tree unbuildable.
2026-10-06 15:39:43 +03:30
whqreza 3931baadd5 feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/
  stock-count/project-transfer) become list-based like the withdrawal
  section: pick a warehouse, see its stock rows, material name is only an
  optional filter; manual material/brand/unit entry removed everywhere
  except PURCHASE.
- RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali
  custom range) with server-side filters, immutable-ledger returnable
  remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for
  legacy returns, round4 float-safe), destination warehouse selector.
- PROJECT_TRANSFER: row-based list with one destination project per batch;
  sequential submits keep failed rows inline with errors and drop
  succeeded rows; history dashboard untouched.
- PURCHASE: unit locked to catalog (search-only Combobox), wizard-style
  '+ register new material' option with yellow warning, optional Key-Value
  specs editor (replaces mandatory text chips), continuous-entry UX
  (warehouse/date kept, auto-focus, session log, duplicate warning).
- Mobile-first: responsive table/cards core (StockTable), numeric keyboards
  (inputMode+pattern), sticky bottom submit bar with dynamic counter above
  MobileTabBar incl. safe-area inset, horizontally scrollable touch chips.
- Backend fix (prereq of RETURN): GET /inventory/transactions date filters
  crashed with 500 under asyncpg (string vs timestamptz); parse to aware
  UTC datetimes (DB runs Etc/UTC).
- docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap,
  standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy
  REF backfill, UTC day semantics, pagination gaps).
2026-10-03 21:56:46 +03:30
whqreza 1bf55ba8a6 merge: integrate prod-v8 (inter-project transfers + multi-project allocation)
Conflict resolved in ChangeInventoryPage.tsx (canSubmit): kept both the
WITHDRAWAL branch (prod-v9: delivered-to validation) and the
PROJECT_TRANSFER branch (prod-v8: inter-project transfer validation).
All other files auto-merged; both feature sets fully preserved.
2026-10-03 19:43:24 +03:30
whqreza 5458a2f99f merge: integrate prod-v9 (show all warehouse items on selection + track withdrawal recipient) 2026-10-03 19:40:03 +03:30
whqreza 638076dbaa refactor(bot)!: rename 'ai procurement' -> 'bot/' so the compose build context is committed
The bot service in docker-compose.yml builds from ./bot, but the agent code lived in 'ai procurement/' and ./bot was only a Windows junction on reza.g's machine (and was gitignored) — exactly as the team reported: 'bot/ was never committed — prod-v7's bot is un-buildable from the repo.'

- junction removed; 'ai procurement/' renamed to 'bot/' via git mv (all 41 files keep their history as pure renames)
- 'bot/' dropped from .gitignore (voice/ stays ignored — runtime voice notes; bot/.env stays ignored by bot/.gitignore)
- compose needs no change: context: ./bot and ./bot/app now resolve to committed files
- bot/INTEGRATION-REPORT.md added: full story (problem, root cause, git state, security note on the committed BALE_BOT_TOKEN / AI_API_KEY compose defaults, and usage instructions for the team)

Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
2026-10-02 12:52:25 +03:30
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
MMD 4b84844b54 fix(db): bootstrap the panel-access schema (users.username, user_panels, user_project_members, projects planning columns)
The pm branch's model expects these but nothing created them — login 500s
with UndefinedColumnError users.username on any volume that predates the
RBAC work, including fresh ones (migration.sql never had the column
either). The pm author evidently altered his local DB by hand.

ensure_rbac_schema() now creates all of it idempotently at startup,
alongside the audit/bot_drafts/project_transfers ensures.
2026-10-01 16:45:37 +03:30
MMD ea6a377023 Merge feature/show-material-suppliers into the all-branches integration
Conflict in supplier_materials.py resolved by keeping HEAD's auth
(_procurement_writer) and list route while adopting the branch's UUID
parameter annotations.
2026-10-01 15:44:12 +03:30
MMD 5400588751 Merge pm (panel-based access, supervisor role) onto the restored RBAC base
Reverting 253c922 first made this merge small — only two conflicts:
- MobileTabBar: supervisor null-check combined with v7's /review hiding
- ArrivalsPage: pm's PM+supervisor scoping combined with v8's 3-way fetch
  (transfers) and its transfer-receive helpers, plus pm's canAccess gate
2026-10-01 15:36:57 +03:30
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +03:30
MMD a84b33dde3 Merge prod-v8 (multi-project allocation, inter-project transfers) into the all-branches integration
Conflicts (all keep-both):
- init_db.py: v7 added ensure_bot_drafts_table, v8 added
  ensure_project_transfers_table at the same anchor — both functions and
  both startup calls kept
- main.py: same, both ensure calls kept
- ChangeInventoryPage.canSubmit: v9's WITHDRAWAL branch and v8's
  PROJECT_TRANSFER branch — both validation branches kept
2026-10-01 15:05:09 +03:30
MMD fbd34277c0 Merge remote-tracking branch 'origin/prod-v7' into integration-all 2026-10-01 14:43:02 +03:30
fatemeh sane 8c5f4ab770 feat: show material suppliers 2026-10-01 11:16:46 +03:30
Behrad af092fe4c8 fix(dashboard): refine accept modal item allocation layout and live persian number words 2026-09-30 11:38:50 +03:30
whqreza 07643dbdf5 feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence 2026-09-30 00:49:04 +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
whqreza 0a7db5ebe9 feat(stt): support multi-model STT with parallel execution and consensus NLU extraction 2026-09-29 02:10:03 +03:30
whqreza da68fc683f fix(bot): strip html tags for Bale, fallback to direct DB catalogs, align validation strictly with DB schema 2026-09-29 01:49:42 +03:30
whqreza 17458d8697 feat(bot): overhaul quote review card template with Jalali dates and DB-aligned validations 2026-09-29 01:30:26 +03:30
whqreza d85ede7d11 feat(inventory): display all warehouse items on selection and track withdrawal recipient
- Show all available inventory items immediately when selecting a warehouse in withdrawal form
- Add vertical scrolling container with max height to prevent layout overflow for large item lists
- Add Material column to the inventory rows table for clear item identification
- Add required 'delivered_to' recipient field in withdrawal form with frontend validation
- Track delivered_to in database, models, schemas, and API
- Display recipient badge on withdrawal cards in daily control page
- Group daily control withdrawals by inventory ID and recipient
2026-09-28 22:54:02 +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
whqreza b2aba2fcfe fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer 2026-09-23 23:11:18 +03:30
whqreza e560018273 fix(inventory): envelope transfer responses and add defensive array guards in UI 2026-09-23 22:46:07 +03:30
whqreza 856513b6d7 feat(frontend): integrate inter-project material transfer into ChangeInventoryPage and ArrivalsPage
- Add PROJECT_TRANSFER tab under reductions in ChangeInventoryPage
- Render complete material spec form with destination project selector
- Show matching stock preview in source warehouse
- Add transfer history table with in-transit edit modal and cancellation dialog
- Add incoming transfer cards in ArrivalsPage with 100% full delivery allocation
2026-09-23 22:31:10 +03:30
whqreza b326cf64bf feat(inventory): implement inter-project material transfer backend
- Add ProjectTransfer database model and migration
- Implement project transfer service with immediate stock deduction on creation
- Support 100% full delivery receipt logic across destination warehouses
- Enforce canonical_specs_hash matching for cross-project stock merge
- Add RBAC guards and status lock on received transfers (409 Conflict)
- Register API router for transfers with on-the-way integration
2026-09-23 22:30:31 +03:30
whqreza da883a28c0 feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal
- Add allocations field to LineItem and propagate in api.quotes.update
- Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation
- Disable previously selected projects within the same item to prevent duplicates
- Merge split items client-side in EditModal when transitioning status to offered
2026-09-23 22:02:49 +03:30
whqreza b6a1ce2ae7 feat(procurement): support multi-project line item split allocation and merge on reopen
- Add ProjectAllocationIn and allocations field to ItemDecisionIn schema
- Implement line item split allocation across projects in accept_quote
- Replicate all ItemTermsheet options and LineItemSpec records to split rows
- Ensure QuoteProjects and LineItemProjects relations are updated
- Add validation against duplicate project selection and decimal sum mismatches
- Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered
2026-09-23 22:02:24 +03:30
whqreza 64d80e15f8 feat(seed): include ali.mohaghegh admin user in seed plan 2026-09-23 22:02:16 +03:30
248 changed files with 37803 additions and 2326 deletions
-1
View File
@@ -30,7 +30,6 @@ frontend/.env
scratch/ scratch/
v1.2 - Copy/ v1.2 - Copy/
inv dash/ inv dash/
bot/
voice/ voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ---- # ---- Machine-local compose overrides (LAN phone access, etc.) ----
+109 -93
View File
@@ -1,3 +1,4 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO) # Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this Self-contained ship package: everything needed to run the stack is in this
@@ -12,19 +13,22 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) | | **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
``` ```
Browser ──► Traefik :80 (edge) Browser ──► Traefik :80 (edge)
│ │
├── Host(localhost) ──► frontend (nginx SPA :80) ├── Host(localhost) ──► frontend (nginx SPA :80)
│ │ │ │
│ └── /api/* proxy ──► api :8000 (/api/v1) │ └── /api/* proxy ──► api :8000 (/api/v1)
│ │
├── Host(api.localhost) ──► api :8000 ├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login) ├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console) └── Host(s3/storage.localhost) ──► minio (API / console)
│ │
├── postgres :5432 (procurement + casdoor DBs) ├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files) └── minio :9000 (invoice/proforma files)
``` ```
--- ---
@@ -74,6 +78,7 @@ v1.3/
├── Projects/ (projects) ├── Projects/ (projects)
├── Units/ (unit conversions) ├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port) └── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
@@ -86,12 +91,12 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. no browser redirect to Casdoor.
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record * **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»). stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage * **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control, quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses). «کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only. The «انبارها» and «تغییر موجودی» sections are PM-only.
**Developer Mode** (top-left button on the login page) is a 3-step flow for **Developer Mode** (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) → the admin (super user): ① superuser auth gate (admin credentials only) →
@@ -103,7 +108,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
for production builds). for production builds).
| What | Where | Credentials | | What | Where | Credentials |
|---|---|---| | --- | --- | --- |
| App | http://localhost (via Traefik) | — | | App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them | | 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`) | | 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`) |
@@ -111,11 +116,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details — Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a > The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see > single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data). > [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
--- ---
@@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS): at these public addresses (HTTPS):
| What | Address | | What | Address |
|---|---| | --- | --- |
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ | | **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.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 Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)). [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel) ### MinIO console on the server (SSH tunnel)
@@ -140,21 +145,20 @@ The MinIO console is deliberately **loopback-only** on the server
is an SSH tunnel from your machine: is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you 1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel): use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
```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.
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** 2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's 3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See **never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md). [`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there. ERP stores, browsable and downloadable from there.
@@ -168,22 +172,22 @@ tunnel.
Notes from the server setup: Notes from the server setup:
- The panel is published only on the server's loopback, so the internet * 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. 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 * Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`. why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for * 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 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 with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default. tunnel remains the safer default.
--- ---
## Prerequisites (target machine) ## Prerequisites (target machine)
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS) * **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`) * ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
- Internet access on first build (image + `npm ci` downloads) * Internet access on first build (image + `npm ci` downloads)
--- ---
@@ -192,6 +196,7 @@ Notes from the server setup:
```powershell ```powershell
cd <this-folder> cd <this-folder>
docker compose up -d --build docker compose up -d --build
``` ```
First run downloads base images and builds the frontend (takes a few minutes). First run downloads base images and builds the frontend (takes a few minutes).
@@ -201,6 +206,7 @@ Wait until all services are up:
```powershell ```powershell
docker compose ps docker compose ps
``` ```
All should show `Up` (postgres also `(healthy)`). All should show `Up` (postgres also `(healthy)`).
@@ -210,7 +216,7 @@ All should show `Up` (postgres also `(healthy)`).
## Verify ## Verify
| What | Where | | What | Where |
|---|---| | --- | --- |
| Frontend UI | http://localhost | | Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs | | API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | | API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
@@ -225,23 +231,24 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell ```powershell
backend\tools\smoke_test.py # read paths backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py backend\tools\smoke_test_write.py / smoke_test_misc.py
``` ```
> Every API route now requires a Casdoor JWT — a bare > Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design. > `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md). > Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
--- ---
## MinIO (object storage for invoice / proforma files) ## MinIO (object storage for invoice / proforma files)
| Item | Value | | Item | Value |
|---|---| | --- | --- |
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | | S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 | | Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) | | Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) | | Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` | | Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata. File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell ```powershell
.\mc.cmd ls --recursive .\mc.cmd ls --recursive
``` ```
--- ---
@@ -261,10 +269,12 @@ Port: 5433
Database: procurement Database: procurement
Username: postgres Username: postgres
Password: postgres Password: postgres
``` ```
```powershell ```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement docker exec -it procurement-postgres psql -U postgres -d procurement
``` ```
--- ---
@@ -276,26 +286,28 @@ database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up: 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 1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders: `postgres_data` volume; then run the seeders:
```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"
```
```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. **Restore your own dump** — create dumps on the server (kept server-side, 2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git: e.g. `/srv/erp/backups/`), never in git:
```powershell
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
```powershell ```
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
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
(see `docs/OPERATIONS.md`) automates this. first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations ## Common operations
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data) docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
``` ```
--- ---
## Known behavior ## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on * Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login` every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback (401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless (`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. `PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`, * Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers. the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor + The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»). grant/revoke «مدیر تدارکات»).
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»: * **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z» stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote. badges and a «رسید جزیی» badge on the quote.
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor * **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was (`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`). dumped/restored; see `docs/casdoor-auth.md`).
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only * **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains, serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect): HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars). (see the prod file header for the required env vars).
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume * Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS` (see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup. for the units-dashboard tables and `quote_attachments` at startup.
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction. * Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
- The supplier detail page (`/suppliers/:id`) shows the purchase history with * The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO. invoice/proforma download links from MinIO.
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild. * **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامین‌کننده‌ای برای این متریال ثبت نشده است») if none exist.
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not * If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
reachable; check `docker compose ps` and `docker compose logs api`. * If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`.
```
-77
View File
@@ -1,77 +0,0 @@
"""Bot configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
``.env`` file. Docker/compose passes them in directly (same convention as
the api service's ``PROCUREMENT_*`` prefix).
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PROCUREMENT_BOT_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
BALE_API_BASE: str = "https://tapi.bale.ai"
BOT_TOKEN: str = "" # from Bale's BotFather
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
POLL_ERROR_SLEEP_SECONDS: float = 3.0
# ERP API (in-network service URL inside Docker).
API_BASE_URL: str = "http://localhost:8000/api/v1"
API_TIMEOUT_SECONDS: float = 30.0
# Optional bearer for the day procurement endpoints start enforcing auth.
SERVICE_TOKEN: str = ""
# Frontend URL (for opening Wizard from Bale bot)
FRONTEND_URL: str = "http://localhost:8081"
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
AI_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
LLM_TIMEOUT_SECONDS: float = 60.0
# STT provider — any OpenAI-compatible /audio/transcriptions endpoint.
# Falls back to the LLM provider when unset. Note: some providers
# (e.g. DeepSeek) have no audio API — voice stays disabled gracefully.
STT_BASE_URL: str = ""
STT_API_KEY: str = ""
STT_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa"
@property
def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL
@property
def stt_api_key(self) -> str:
return self.STT_API_KEY or self.AI_API_KEY
# PostgreSQL — same instance/database as the api service.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Chats that may use the bot without Casdoor linking (comma separated).
ADMIN_CHAT_IDS: str = ""
# Drafts older than this are marked expired by the sweeper loop.
DRAFT_TTL_HOURS: int = 24
LOG_LEVEL: str = "INFO"
@property
def admin_chat_ids(self) -> set[int]:
out: set[int] = set()
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
part = part.strip()
if part.isdigit():
out.add(int(part))
return out
settings = Settings()
-151
View File
@@ -1,151 +0,0 @@
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
"""
import difflib
import time
from dataclasses import dataclass, field
from app.api.client import ApiError, ErpClient
from app.nlu import normalize_text
@dataclass
class Candidate:
id: str
name: str
extra: dict = field(default_factory=dict)
@dataclass
class Resolution:
status: str # "one" | "many" | "none"
candidates: list[Candidate]
@property
def resolved(self) -> bool:
return self.status == "one"
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
q = normalize_text(query)
if not q:
return items[:limit]
scored = []
for item in items:
name = normalize_text(item.name)
ratio = difflib.SequenceMatcher(None, q, name).ratio()
if q == name:
ratio = 1.0
elif q in name:
ratio += 0.35
scored.append((ratio, item))
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
class Resolver:
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
self._api = api
self._ttl = ttl_seconds
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
async def _cached(self, key: str, fetch) -> list[Candidate]:
entry = self._cache.get(key)
now = time.monotonic()
if entry and now - entry[0] < self._ttl:
return entry[1]
items = await fetch()
self._cache[key] = (now, items)
return items
def invalidate(self, key: str | None = None) -> None:
if key:
self._cache.pop(key, None)
else:
self._cache.clear()
# ---------------------------------------------------------------- sources
async def units(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_units()
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")}) for u in rows]
return await self._cached("units", fetch)
async def categories(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_categories()
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
return await self._cached("categories", fetch)
async def projects(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_projects()
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
return await self._cached("projects", fetch)
async def suppliers(self, q: str | None = None) -> list[Candidate]:
rows = await self._api.list_suppliers(q=q, page_size=20)
items = [Candidate(id=str(s["id"]), name=s.get("name") or str(s["id"])) for s in rows]
return _rank(q, items) if q else items
async def materials(self, q: str | None = None) -> list[Candidate]:
rows = await self._api.list_materials(q=q, page_size=20)
extra = {str(m["id"]): m for m in rows}
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
for m in rows]
return _rank(q, items) if q else items
# ------------------------------------------------------------ resolutions
async def resolve(self, kind: str, name: str | None) -> Resolution:
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
if not name or not name.strip():
return Resolution("none", [])
if kind == "supplier":
candidates = await self.suppliers(name)
elif kind == "material":
candidates = await self.materials(name)
elif kind == "project":
candidates = _rank(name, await self.projects())
elif kind == "unit":
candidates = _rank(name, await self.units())
elif kind == "category":
candidates = _rank(name, await self.categories())
else:
raise ValueError(f"unknown kind: {kind}")
if not candidates:
return Resolution("none", [])
norm_name = normalize_text(name)
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
if len(exact) == 1:
return Resolution("one", exact)
top = candidates[0]
top_norm = normalize_text(top.name)
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
return Resolution("one", [top])
return Resolution("many", candidates)
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
"""Return (unit_id, explicitly_resolved). Falls back to the material's
canonical unit when the spoken unit isn't found."""
if unit_name:
res = await self.resolve("unit", unit_name)
if res.resolved:
return res.candidates[0].id, True
if material and material.get("canonical_unit_id"):
return str(material["canonical_unit_id"]), False
return None, False
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
try:
return await resolver.resolve(kind, name)
except ApiError:
return Resolution("none", [])
-62
View File
@@ -1,62 +0,0 @@
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
self-hosted whisper servers exposing the same contract).
"""
import asyncio
import logging
import subprocess
import tempfile
from pathlib import Path
import httpx
from app.config import Settings
logger = logging.getLogger("bot.stt")
_CONVERT_TIMEOUT = 60
_STT_TIMEOUT = 120
def _to_mp3(raw: bytes, suffix: str) -> bytes:
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{suffix or '.ogg'}"
dst = Path(tmp) / "out.mp3"
src.write_bytes(raw)
proc = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
capture_output=True,
timeout=_CONVERT_TIMEOUT,
)
if proc.returncode != 0 or not dst.exists():
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
return dst.read_bytes()
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> str:
"""Return the Persian transcript for a voice note (empty string on silence)."""
if not settings.stt_base_url or not settings.stt_api_key:
raise RuntimeError("STT provider is not configured (PROCUREMENT_BOT_STT_BASE_URL / _AI_BASE_URL)")
suffix = Path(filename or "").suffix
if not suffix and mime_type:
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
url = f"{settings.stt_base_url.rstrip('/')}/audio/transcriptions"
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {settings.stt_api_key}"},
files={"file": ("voice.mp3", converted, "audio/mpeg")},
data={"model": settings.STT_MODEL, "language": settings.STT_LANGUAGE},
)
if resp.status_code != 200:
raise RuntimeError(f"STT provider returned HTTP {resp.status_code}: {resp.text[:300]}")
return str((resp.json() or {}).get("text") or "").strip()
-254
View File
@@ -1,254 +0,0 @@
"""Persian message templates (the ERP's user-facing layer is Persian)."""
import html
from datetime import date
STATUS_FA = {
"offered": "🔵 در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده",
"arrived": "✅ رسیده",
"rejected": "🔴 ردشده",
"expired": "⚪️ منقضی",
}
MISSING_MARKS = {True: "✅", False: "❌"}
WARNING_MARK = "⚠️"
def esc(value) -> str:
return html.escape(str(value if value is not None else "—"))
def fa_date(value: str | None) -> str:
if not value:
return "—"
return str(value)[:10]
WELCOME = (
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
"می‌توانی با پیام متنی یا <b>صوتی</b> فعالیتی تعریف کنی — مثلاً:\n"
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
"من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان "
"می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n"
"دستورها:\n"
"/drafts — پیش‌نویس‌های باز\n"
"/cancel — لغو پیش‌نویس/عملیات جاری\n"
"/whoami — اتصال من\n"
"/unlink — قطع اتصال\n"
"/help — راهنما"
)
HELP = (
"<b>چه کارهایی می‌توانم بکنم؟</b>\n\n"
"🧾 <b>ثبت استعلام</b> — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n"
"🏢 <b>افزودن تأمین‌کننده</b> — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
"📦 <b>افزودن کالا</b> — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
"📊 <b>پرس‌وجو</b> — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n"
"✔️ <b>پذیرش استعلام</b> — «استعلام سازان نو را قبول کن»\n"
"🚚 <b>اعلام رسیدن</b> — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
"هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن."
)
LINK_START = (
"برای اتصال حساب Bale به حساب ERP، <b>نام کاربری Casdoor</b> خود را بفرست "
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
)
LINK_ASK_PASSWORD = "ممنون. حالا <b>رمز عبور</b> را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو"
LINK_OK = "✅ اتصال انجام شد: <b>{name}</b> ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین."
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود."
DRAFTS_LIST_HEADER = "📋 پیش‌نویس‌های باز:"
DRAFTS_EMPTY = "پیش‌نویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
NOTHING_TO_COMMIT = "پیش‌نویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
CANCELLED = "لغو شد. ✔️"
UNKNOWN_HINT = (
"متوجه منظورت نشد 🤔\n"
"می‌توانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
)
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
AI_NOT_CONFIGURED = (
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
"با مدیر سیستم تماس بگیر."
)
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
ERR_GENERIC = "خطا: {detail}"
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را می‌خواهی؟"
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
ACCEPT_REPORT = (
"✅ پذیرش انجام شد\n"
"استعلام #{id}\n"
"وضعیت: {status}\n"
"اقلام تصمیم‌گیری‌شده: {count}"
)
ARRIVE_REPORT = (
"🚚 ثبت رسید انجام شد\n"
"استعلام #{id} — وضعیت: {status}\n{lines}"
)
ACCEPT_DONE_TS = "شرایط انتخاب‌شده: {label}"
def missing_block(missing: list[dict]) -> str:
"""✅/❌ list of what's captured vs. still empty (⚠️ = nice-to-have)."""
if not missing:
return "همه فیلدها کامل است ✅"
lines = []
for m in missing:
mark = MISSING_MARKS[m["ok"]] if m["blocking"] else (MISSING_MARKS.get(m["ok"], WARNING_MARK))
if not m["blocking"] and not m["ok"]:
mark = WARNING_MARK
lines.append(f"{mark} {m['label']}")
return "\n".join(lines)
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
lines = ["🧾 پیش‌نویس استعلام:"]
if status_note:
lines.append(status_note)
sup = payload.get("supplier_name") or "—"
if payload.get("supplier_id"):
sup_label = f"{esc(sup)} ✅"
elif payload.get("supplier_name"):
sup_label = f"{esc(sup)} ✨ (تأمین‌کننده جدید)"
else:
sup_label = "—"
lines.append(f"تأمین‌کننده: {sup_label}")
proj_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
if proj_names:
lines.append(f"پروژه(ها): {esc('، '.join(proj_names))}")
lines.append(f"نوع: {'رسمی' if payload.get('is_official', True) else 'برآوردی'}")
if payload.get("expiry_date"):
lines.append(f"اعتبار تا: {fa_date(payload['expiry_date'])}")
items = payload.get("items") or []
if items:
lines.append("\n📦 اقلام:")
for i, it in enumerate(items, start=1):
name = it.get('material_name') or '?'
if it.get('brand_name'):
name += f" ({it['brand_name']})"
parts = [f"{i}. {esc(name)}"]
if it.get('specs'):
parts.append(esc("، ".join(it['specs'])))
if it.get("quantity"):
parts.append(f"مقدار {esc(it['quantity'])}")
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
if unit:
parts.append(str(unit))
if it.get("unit_price"):
parts.append(f"قیمت {esc(it['unit_price'])}")
if not it.get("material_id"):
parts.append("✨ کالای جدید")
lines.append(" — ".join(str(p) for p in parts))
else:
lines.append("\nاقلام: هنوز چیزی ثبت نشده ❌")
sheets = payload.get("termsheets") or []
if sheets:
ts_parts = []
for t in sheets:
lbl = esc(t.get('label'))
if t.get("milestones"):
steps_txt = " + ".join(f"{fa_date(m.get('due_date'))}: {esc(m.get('amount'))}" for m in t["milestones"] if m.get("amount"))
if steps_txt:
lbl += f" [{steps_txt}]"
elif t.get("discount_value"):
lbl += f" (تخفیف {esc(t.get('discount_value'))})"
ts_parts.append(lbl)
lines.append("\n💳 شرایط پرداخت: " + "؛ ".join(ts_parts))
if payload.get("notes"):
lines.append(f"یادداشت: {esc(payload['notes'])}")
lines.append("\n📋 وضعیت فیلدها:")
lines.append(missing_block(missing))
lines.append("\nبا پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.")
return "\n".join(lines)
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏢 <b>پیش‌نویس تأمین‌کننده جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"]
lines.append(f"دسته: {esc(payload.get('category_name'))}")
if payload.get("phone"):
lines.append(f"تلفن: {esc(payload['phone'])}")
if payload.get("location"):
lines.append(f"موقعیت: {esc(payload['location'])}")
if payload.get("contact_person"):
lines.append(f"رابط: {esc(payload['contact_person'])}")
lines.append("\n<b>وضعیت فیلدها:</b>")
lines.append(missing_block(missing))
return "\n".join(lines)
def material_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["📦 <b>پیش‌نویس کالای جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"]
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
if payload.get("topic"):
lines.append(f"تاپیک: {esc(payload['topic'])}")
lines.append("\n<b>وضعیت فیلدها:</b>")
lines.append(missing_block(missing))
return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
lines = [f"✅ <b>ثبت شد</b> — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup:
lines.append(f"تأمین‌کننده: {esc(sup)}")
items = created.get("line_items") or []
if items:
lines.append(f"اقلام: {len(items)}")
sheets = created.get("termsheets") or []
if sheets:
lines.append(f"شرایط پرداخت: {len(sheets)}")
if payload.get("project_id"):
lines.append("پروژه: پیوست شد")
if remaining:
lines.append("\n<b>خالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):</b>")
lines += [f"⚠️ {esc(r)}" for r in remaining]
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
return "\n".join(lines)
def supplier_committed_report(created: dict) -> str:
return (
f"✅ <b>تأمین‌کننده ثبت شد</b> — {esc(created.get('name'))}\n"
f"دسته: {esc((created.get('category') or {}).get('name'))}"
)
def material_committed_report(created: dict) -> str:
return f"✅ <b>کالا ثبت شد</b> — {esc(created.get('name'))}"
def quote_line(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
items = quote.get("line_items") or []
item_names = []
for it in items:
mat = (it.get("material") or {}).get("name")
if mat:
qty = it.get("quantity")
item_names.append(f"{mat} ({qty})" if qty else mat)
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
return (
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
f"{fa_date(quote.get('quote_date'))}{tail}"
)
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
titles = {
"pending": "🔵 استعلام‌های در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده (در انتظار تحویل)",
"arrived": "✅ رسیده",
"expiring": "⏳ به‌زودی منقضی می‌شوند (۷ روز آینده)",
"recent": "🕘 آخرین استعلام‌ها",
}
if not quotes:
return f"{titles.get(kind, 'نتیجه‌ای')}\nموردی نیست."
lines = [titles.get(kind, "استعلام‌ها")]
if extra_note:
lines.append(extra_note)
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
return "\n".join(lines)
+79 -11
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.db.engine import AsyncSessionLocal from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.utils.errors import ( from app.utils.errors import (
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
@@ -106,6 +106,10 @@ class UserContext:
project_id: object | None # uuid.UUID | None — default (first) project of a PM 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) project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str 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: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id: if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") 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)) user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None: if user is None:
user = User( user = User(
casdoor_id=casdoor_id, casdoor_id=casdoor_id,
username=username or None,
first_name=str( first_name=str(
claims.get("firstName") claims.get("firstName")
or claims.get("firstname") or claims.get("firstname")
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
project_ids: list = [] # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
if user.role == "pm": panels: dict = {}
projects = ( 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( await db.execute(
select(Project) select(UserProjectMembership)
.where(Project.pm_user_id == user.id) .where(UserProjectMembership.user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(UserProjectMembership.created_at)
) )
).scalars().all() ).scalars().all()
project_ids = [p.id for p in projects] ]
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 project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_id=project_id, project_id=project_id,
project_ids=project_ids, project_ids=project_ids,
display_name=display_name, display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
) )
async def pm_project_ids(db: AsyncSession, user: User) -> list: async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first).""" """Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
rows = ( pm_rows = (
await db.execute( await db.execute(
select(Project.id) select(Project.id)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
return list(rows) 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: async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
@@ -217,3 +257,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
return _dep 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
+3
View File
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "procurement-files" MINIO_BUCKET: str = "procurement-files"
MINIO_SECURE: bool = False MINIO_SECURE: bool = False
# Security token for standalone wizard review URL (must match bot)
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
# Background auto-expiry of offered quotes whose expiry_date has passed. # Background auto-expiry of offered quotes whose expiry_date has passed.
AUTO_EXPIRE_ENABLED: bool = True AUTO_EXPIRE_ENABLED: bool = True
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600 AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
+164 -4
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""", """,
""" """
DO $$ BEGIN 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 $$ EXCEPTION WHEN duplicate_object THEN NULL; END $$
""", """,
""" """
@@ -247,6 +247,7 @@ _V26_DDL = [
physical_qty NUMERIC(18,6), physical_qty NUMERIC(18,6),
transfer_pair_id UUID, transfer_pair_id UUID,
performed_by TEXT, performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(), transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT, note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -258,6 +259,9 @@ _V26_DDL = [
""" """
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date) CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""", """,
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it. # suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
""" """
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
@@ -380,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"ON CONFLICT (from_unit, to_unit) DO NOTHING", "ON CONFLICT (from_unit, to_unit) DO NOTHING",
c, c,
) )
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column. # v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
# Category→Material filter cascade (see ensure_dashboard_schema below).
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("DROP FUNCTION IF EXISTS 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("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
@@ -410,6 +415,50 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)") logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_rbac_schema(engine: AsyncEngine) -> None:
"""Panel-access schema (pm branch): users.username, projects planning
columns, user_panels and user_project_members. Idempotent; safe on any
volume — the RBAC model expects these and login 500s without them."""
stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()",
"""
CREATE TABLE IF NOT EXISTS user_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
panel TEXT NOT NULL,
level TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_user_panel UNIQUE (user_id, panel)
)
""",
"CREATE INDEX IF NOT EXISTS ix_user_panels_user_id ON user_panels(user_id)",
"""
CREATE TABLE IF NOT EXISTS user_project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_user_project_member UNIQUE (user_id, project_id)
)
""",
"CREATE INDEX IF NOT EXISTS ix_user_project_members_user_id ON user_project_members(user_id)",
"CREATE INDEX IF NOT EXISTS ix_user_project_members_project_id ON user_project_members(project_id)",
)
async with engine.begin() as conn:
for stmt in stmts:
await conn.execute(text(stmt))
logger.info("RBAC schema ensured (username / project columns / user_panels / user_project_members)")
async def ensure_audit_table(engine: AsyncEngine) -> None: async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" """v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """ ddl = """
@@ -466,3 +515,114 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("bot_drafts table ensured") logger.info("bot_drafts table ensured")
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
"""
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("project_transfers table ensured")
# ---------------------------------------------------------------------------
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
# needs a real materials.category_id link. v2.6 had deliberately dropped the
# old supplier-derived column; this is a fresh, topic-backfilled link
# (nullable — a material may stay uncategorized). The rest is B-tree index
# coverage for the aggregation queries in services/dashboard_service.py.
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
# backfill. Every statement is idempotent.
# ---------------------------------------------------------------------------
_DASHBOARD_SCHEMA_DDL = [
# Fresh column (inline FK names the constraint materials_category_id_fkey,
# matching the DO-block guard below for volumes that predate v2.8).
"""
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
""",
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
# groups those under «بدون دسته»).
"""
UPDATE materials SET category_id = mc.id
FROM material_categories mc
WHERE materials.topic = mc.name AND materials.category_id IS NULL
""",
# FK for the (rare) volume that already had the column without it.
"""
DO $$ BEGIN
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
# Drill-down / PM scoping: every dashboard query filters through it.
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
# Daily flow + daily control (date-range aggregation).
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
# Type slices (withdrawal / return / waste) inside a date range.
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
# Funnel per-status counts (existing composite leads with supplier_id).
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
# Project-density workload grouping.
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
# Receipt-level arrival reporting.
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
# «What»-filtered stock scans across warehouses (existing composites lead
# with warehouse_id, useless for a bare material/brand filter).
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
]
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
Statement-per-transaction like ensure_v26: one failure can never block
the rest of the upgrade. Safe to call on every startup.
"""
for ddl in _DASHBOARD_SCHEMA_DDL:
try:
async with engine.begin() as conn:
await conn.execute(text(ddl))
except Exception: # pragma: no cover - pre-existing data conflicts
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
+6
View File
@@ -28,10 +28,13 @@ from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table, ensure_bot_drafts_table,
ensure_dashboard_schema,
ensure_drop_category_tables, ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
ensure_quote_projects, ensure_quote_projects,
ensure_rbac_schema,
ensure_project_transfers_table,
ensure_v26, ensure_v26,
) )
from app.observability import RequestContextMiddleware, setup_logging from app.observability import RequestContextMiddleware, setup_logging
@@ -106,9 +109,12 @@ async def lifespan(_app: FastAPI):
await ensure_material_topic_column(engine) await ensure_material_topic_column(engine)
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_rbac_schema(engine)
await ensure_dashboard_schema(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine) await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine) await ensure_bot_drafts_table(engine)
await ensure_project_transfers_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try: try:
yield yield
+3
View File
@@ -32,6 +32,8 @@ from app.models.procurement import (
TermsheetScheduleStep, TermsheetScheduleStep,
) )
from app.models.project_transfer import ProjectTransfer
__all__ = [ __all__ = [
"Base", "Base",
"Unit", "Unit",
@@ -60,4 +62,5 @@ __all__ = [
"ItemDecision", "ItemDecision",
"ItemReceipt", "ItemReceipt",
"QuoteAttachment", "QuoteAttachment",
"ProjectTransfer",
] ]
+7 -1
View File
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories. # v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
# category taxonomy for the dashboard filter cascade (nullable — backfill
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="SET NULL")
)
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
) )
@@ -147,6 +152,7 @@ class Material(Base):
) )
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
) )
+1
View File
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row. # NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True)) transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder) performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column( transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
+54 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum( user_role_enum = Enum(
"pm", "procurement_manager", "admin", "pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True, name="user_role", native_enum=True,
) )
quote_status_enum = Enum( quote_status_enum = Enum(
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) 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) first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text) phone: Mapped[str | None] = mapped_column(Text)
@@ -65,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz() 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): class Supplier(Base):
__tablename__ = "suppliers" __tablename__ = "suppliers"
@@ -148,12 +190,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text) 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) 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). # v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column( pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL") ForeignKey("users.id", ondelete="SET NULL")
) )
created_at: Mapped[datetime] = _timestamptz() 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): class Quote(Base):
+87
View File
@@ -0,0 +1,87 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Numeric,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
class ProjectTransfer(Base):
"""project_transfers — Inter-project (inter-site) material transfer ledger.
Records the movement of materials from a source project warehouse to a
destination project. Stock is deducted from the source warehouse immediately
upon creation (status = 'in_transit') via out_transaction_id.
When the destination project PM confirms receipt in 'Arrivals', stock is
added to the destination warehouse (status = 'received').
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
Once 'received', the transfer is permanently locked from edit or cancellation.
"""
__tablename__ = "project_transfers"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
to_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
)
transfer_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
transferred_by: Mapped[str | None] = mapped_column(Text)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
received_by: Mapped[str | None] = mapped_column(Text)
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("warehouses.id", ondelete="SET NULL")
)
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
)
+4
View File
@@ -4,6 +4,7 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
dashboard,
drafts, drafts,
files, files,
inventory, inventory,
@@ -15,6 +16,7 @@ from app.routers import (
material_categories, material_categories,
materials, materials,
pm, pm,
project_transfers,
projects, projects,
quote_events, quote_events,
quotes, quotes,
@@ -51,6 +53,8 @@ ROUTERS = [
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router,
dashboard.router,
users.router, users.router,
drafts.router, drafts.router,
] ]
+16 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec 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 from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) 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: 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) @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)) duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None: if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}") @router.patch("/{definition_id}")
async def update_attribute_definition( 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) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@router.delete("/{definition_id}") @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) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد") raise NotFoundError("تعریف ویژگی یافت نشد")
+5 -2
View File
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.services import analytics from app.services import analytics
from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user) 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( await analytics.track(
"login_success", "login_success",
{"username": payload.username, "role": ctx.role}, {"username": payload.username, "role": ctx.role},
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects), "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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem 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 from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: 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) @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: if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id") raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None: 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}") @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) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@router.delete("/{bm_id}") @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) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate 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 from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@router.post("", status_code=201) @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() rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows): if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}") @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) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@router.delete("/{brand_id}") @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) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
+212
View File
@@ -0,0 +1,212 @@
"""MVP dashboard router — read-only aggregations for the warehouse +
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
client (frontend/src/lib/api.ts).
Scope (same resolution pattern as the inventory / pm routers):
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
an explicit ?project_id must be one of them.
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
an optional filter.
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
inventory router).
Endpoints:
- GET /dashboard/filters — «What» cascade options
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
- GET /dashboard/inventory/dead-stock — dormant stock table
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
- GET /dashboard/daily-control — today's tickets + performers
- GET /dashboard/procurement/workload — workload per project (density)
Every response is the standard `{"data": …}` envelope.
"""
import uuid
from dataclasses import dataclass
from datetime import date as date_cls
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
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.services import dashboard_service
from app.utils.errors import (
ForbiddenError,
UnauthorizedError,
ValidationError,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@dataclass
class DashboardScope:
role: str # "pm" | "office"
project_ids: list | None # PM: managed project ids; office: None = all
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
async def dashboard_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"),
db: AsyncSession = Depends(get_db),
) -> DashboardScope:
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
user: UserContext | None = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if user.role == "pm":
allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
if user.role in ("admin", "procurement_manager"):
return DashboardScope(role="office", project_ids=None, project_id=project_id)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
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" or role == "admin":
role = "office"
elif role == "pm":
role = "pm"
else:
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
if role == "pm":
raw = request.headers.get("x-project-id")
if not raw:
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
try:
if project_id is None:
project_id = uuid.UUID(str(raw))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
return DashboardScope(role="office", project_ids=None, project_id=project_id)
def _what_filters(
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
) -> dict:
return {
"material_id": material_id,
"brand_id": brand_id,
"specs_hash": specs_hash,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Options for the global filter bar (Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@router.get("/inventory/drilldown")
async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
return await dashboard_service.inventory_drilldown(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/inventory/dead-stock")
async def dead_stock(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
limit: int = Query(default=200, ge=1, le=1000),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود."""
return await dashboard_service.dead_stock(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
idle_days=idle_days,
limit=limit,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/inventory/flow")
async def inventory_flow(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"),
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
warehouse_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
today = datetime.now(dashboard_service.TEHRAN).date()
date_from = date_from or (today - timedelta(days=29))
date_to = date_to or (today + timedelta(days=1))
if date_to <= date_from:
raise ValidationError("بازه‌ی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
if (date_to - date_from).days > 180:
raise ValidationError("بازه‌ی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
return await dashboard_service.daily_flow(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
warehouse_id=warehouse_id,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/daily-control")
async def daily_control(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیش‌فرض امروز)"),
warehouse_id: uuid.UUID | None = Query(default=None),
):
"""Daily control: today's outbound/return tickets + exact performer names."""
day = date or datetime.now(dashboard_service.TEHRAN).date()
return await dashboard_service.daily_control(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
day=day,
warehouse_id=warehouse_id,
)
@router.get("/procurement/workload")
async def procurement_workload(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
):
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژه‌ها."""
return await dashboard_service.procurement_workload(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
)
+89 -1
View File
@@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.draft import BotDraft from app.models.draft import BotDraft
from app.utils.errors import NotFoundError from app.utils.draft_token import verify_draft_token
from app.utils.errors import NotFoundError, UnauthorizedError
router = APIRouter(prefix="/drafts", tags=["drafts"]) router = APIRouter(prefix="/drafts", tags=["drafts"])
@@ -38,6 +39,93 @@ def _serialize_draft(draft: BotDraft) -> dict:
} }
def _verify_token_or_raise(token: str) -> dict:
token_payload = verify_draft_token(token)
if not token_payload or not token_payload.get("draft_id"):
raise UnauthorizedError("لینک بازبینی نامعتبر یا منقضی شده است")
return token_payload
@router.get("/verify")
async def verify_and_get_draft(token: str, db: AsyncSession = Depends(get_db)):
"""Public endpoint for token-based draft review without ERP login."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
return {"data": _serialize_draft(draft), "token_payload": token_payload}
@router.patch("/by-token")
async def update_draft_by_token(token: str, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
"""Update draft payload using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
if body.payload is not None:
merged = dict(draft.payload or {})
merged.update(body.payload)
draft.payload = merged
if body.status is not None:
draft.status = body.status
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/commit")
async def commit_draft_by_token(token: str, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
"""Commit draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "committed"
payload = dict(draft.payload or {})
if body.quote_id:
payload["committed_quote_id"] = body.quote_id
draft.payload = payload
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/cancel")
async def cancel_draft_by_token(token: str, db: AsyncSession = Depends(get_db)):
"""Cancel draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "cancelled"
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.get("/{draft_id}") @router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)): async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id) stmt = select(BotDraft).where(BotDraft.id == draft_id)
+4 -3
View File
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"]) router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود # حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است. # برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin") _attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = { ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -125,7 +126,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) @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)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) @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)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
+108 -43
View File
@@ -12,6 +12,7 @@ import base64
import uuid import uuid
from contextlib import nullcontext from contextlib import nullcontext
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
@@ -58,6 +59,7 @@ class Scope:
role: str role: str
project_id: uuid.UUID | None project_id: uuid.UUID | None
actor: str | None actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope( async def inventory_scope(
@@ -75,25 +77,41 @@ async def inventory_scope(
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
if user.role is None: panels = user.panels or {}
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") if user.role is None and not panels:
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
if user.role == "procurement_manager": # ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
if user.role not in ("pm", "admin"): # لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") if "inventory" in panels:
if user.role == "pm": if panels["inventory"] == "full":
allowed = list(user.project_ids or []) # کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
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 is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="pm", project_id=project_id, actor=user.display_name) 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: if not allowed:
raise ValidationError( raise ForbiddenError("پروژه تخصیص داده نشده است")
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" if project_id is not None:
) if project_id not in allowed:
return Scope(role="pm", project_id=allowed[0], actor=user.display_name) raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="admin", project_id=project_id, actor=user.display_name) 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 # Dev fallback: header-based identity (fork's placeholder mode) — only
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
@@ -128,9 +146,9 @@ async def inventory_scope(
return Scope(role=role, project_id=project_id, actor=actor) return Scope(role=role, project_id=project_id, actor=actor)
def _require_pm(scope: Scope) -> None: def _require_inventory_modifier(scope: Scope) -> None:
if scope.role != ROLE_PM: if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -197,6 +215,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None, "physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None, "transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by, "performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date), "transaction_date": to_iso(tx.transaction_date),
"note": tx.note, "note": tx.note,
} }
@@ -215,8 +234,16 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Warehouse) stmt = select(Warehouse)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope 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: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name) stmt = stmt.order_by(Warehouse.name)
@@ -248,9 +275,11 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) # PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor"):
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id") 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: if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description) wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
@@ -267,8 +296,9 @@ async def update_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
if scope.project_id != wh.project_id: allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None: if payload.name is not None:
wh.name = payload.name wh.name = payload.name
@@ -292,8 +322,10 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) 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"):
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") 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 rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists. # inventory cascade — block deletion if any stock history exists.
linked = await db.scalar( linked = await db.scalar(
@@ -323,10 +355,22 @@ async def list_inventory(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(InventoryItem) stmt = select(InventoryItem)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
Warehouse.project_id == scope.project_id 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
)
elif project_id is not None: elif project_id is not None:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id Warehouse.project_id == project_id
@@ -388,8 +432,16 @@ async def list_transactions(
stmt = select(InventoryTransaction).join( stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) 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: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
if warehouse_id is not None: if warehouse_id is not None:
@@ -397,9 +449,18 @@ async def list_transactions(
if transaction_type is not None: if transaction_type is not None:
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type) stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
if date_from is not None: if date_from is not None:
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from) # asyncpg تایپ سخت‌گیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
# دیتابیس با TZ=UTC اجرا می‌شود، پس «YYYY-MM-DD» یعنی نیمه‌شب UTC
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
stmt = stmt.where(
InventoryTransaction.transaction_date
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
)
if date_to is not None: if date_to is not None:
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00") stmt = stmt.where(
InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
)
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
@@ -454,8 +515,10 @@ async def inventory_history(
if item is None: if item is None:
raise NotFoundError("موجودی یافت نشد") raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id) 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"):
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") 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( txs = (await db.execute(
select(InventoryTransaction) select(InventoryTransaction)
.where(InventoryTransaction.inventory_id == inventory_id) .where(InventoryTransaction.inventory_id == inventory_id)
@@ -474,18 +537,20 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_require_pm(scope) _require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit # No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db # transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure. # commits on success / rolls back on failure.
with nullcontext(): with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id) 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 [])
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") if scope.role in (ROLE_PM, "supervisor"):
if payload.dest_warehouse_id is not None: if wh.project_id not in allowed:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if dest.project_id != scope.project_id: if payload.dest_warehouse_id is not None:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id not in allowed:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor) txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record( await audit_record(
db, db,
+16 -2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: 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) @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) await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, 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)) 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("") @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) 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)) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None: if decision is None:
+18 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: 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) @router.post("", status_code=201)
async def create_item_termsheet( 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(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) 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}") @router.patch("/{item_termsheet_id}")
async def update_item_termsheet( 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) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}") @router.delete("/{item_termsheet_id}")
async def delete_item_termsheet( 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( it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote 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 from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) 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: 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("") @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).""" """FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
+19 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote 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 from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) 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: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -50,7 +52,12 @@ async def list_line_items(
@router.post("", status_code=201) @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(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}") @router.patch("/{line_item_id}")
async def update_line_item( 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) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
@@ -131,7 +142,12 @@ async def update_line_item(
@router.delete("/{line_item_id}") @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) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
@@ -46,7 +48,11 @@ async def list_categories(
@router.post("", status_code=201) @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)) duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None: if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}") @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) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@router.delete("/{category_id}") @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) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
+36 -7
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
@@ -125,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_material( 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) material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))} return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}") @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) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@router.delete("/{material_id}") @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) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -248,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201) @router.post("/{material_id}/units", status_code=201)
async def create_material_unit( 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(): async with db.begin():
await _material_or_404(db, material_id) await _material_or_404(db, material_id)
@@ -287,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}") @router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit( 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(): async with db.begin():
mu = await db.scalar( mu = await db.scalar(
@@ -319,7 +340,12 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}") @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( mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
) )
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values") @router.put("/{material_id}/attribute-values")
async def put_material_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).""" """FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin(): async with db.begin():
+74 -21
View File
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, Unit from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
ItemReceipt, ItemReceipt,
ItemReceiptAllocation, ItemReceiptAllocation,
LineItem, LineItem,
LineItemSpec,
Project, Project,
Quote, Quote,
Supplier, Supplier,
@@ -71,16 +72,17 @@ class ArrivalScope:
user_id: uuid.UUID | None user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope( async def arrivals_scope(
request: Request, request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"), project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ArrivalScope: ) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to the requested project, which must be one they manage 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. (projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter). Admin / procurement manager → all projects (param = optional filter).
""" """
@@ -89,19 +91,19 @@ async def arrivals_scope(
if ctx is not None: if ctx is not None:
if ctx.role is None: if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm": if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or []) allowed = list(ctx.project_ids or [])
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
if not allowed: if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], 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"): if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to # Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor). # admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would # Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM. # let anyone confirm receipts as PM.
@@ -110,8 +112,10 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower() role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager": if role == "procurement_manager":
role = "admin" role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"): if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it. # The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm": if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id") raw_project = request.headers.get("x-project-id")
@@ -179,20 +183,35 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
async def pm_on_the_way( async def pm_on_the_way(
ctx: ArrivalScope = Depends(arrivals_scope), ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
): ):
stmt = ( stmt = (
select(ItemDecision, LineItem, Quote, Material, Supplier, Project) select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
.join(LineItem, LineItem.id == ItemDecision.line_item_id) .join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id) .join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_id) .join(Supplier, Supplier.id == Quote.supplier_id)
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if ctx.role == "pm" and ctx.project_id is None: if material_id is not None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") stmt = stmt.where(Material.id == material_id)
if ctx.project_id is not None: if brand_id is not None:
# PM: active project; admin/manager: optional project filter stmt = stmt.where(BrandMaterial.brand_id == brand_id)
if ctx.role == "pm":
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) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -201,8 +220,29 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} 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()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
# frozen spec snapshot per line item (key = attribute label)
li_ids = [d.LineItem.id for d in decisions]
specs_by_line: dict = {}
if li_ids:
spec_rows = (
await db.execute(
select(LineItemSpec, AttributeDefinition)
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
.where(LineItemSpec.line_item_id.in_(li_ids))
)
).all()
for spec, ad in spec_rows:
value = spec.value_string
if value is None and spec.value_number is not None:
value = str(spec.value_number)
if value is None and spec.value_boolean is not None:
value = str(spec.value_boolean)
if value is None and spec.value_date is not None:
value = spec.value_date.isoformat()
if value is not None:
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
out = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj, bm, br in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
out.append({ out.append({
"item_decision_id": str(d.id), "item_decision_id": str(d.id),
@@ -214,6 +254,8 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name}, "material": {"id": str(m.id), "name": m.name},
"brand": {"id": str(br.id), "name": br.name} if br else None,
"specs": specs_by_line.get(li.id, {}),
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "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, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
@@ -243,8 +285,12 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id) decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen": if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.project_id is not None and decision.project_id != ctx.project_id: if ctx.role == "pm":
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") 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) li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id) quote = await db.get(Quote, li.quote_id)
@@ -451,10 +497,17 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id) .join(Project, Project.id == ItemDecision.project_id)
) )
if ctx.role == "pm" and ctx.project_id is None: if ctx.role == "pm":
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if ctx.project_id is not None: if not allowed:
# PM: active project; admin/manager: optional project filter 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) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows] receipts = [r.ItemReceipt for r in rows]
+206
View File
@@ -0,0 +1,206 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.routers.pm import ArrivalScope, arrivals_scope
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferOut,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services import project_transfer_service
from app.utils.errors import ForbiddenError
router = APIRouter(tags=["project-transfers"])
def _to_transfer_out(t) -> dict:
return {
"id": t.id,
"from_project": (
{"id": t.from_project.id, "name": t.from_project.name}
if t.from_project
else {"id": t.from_project_id, "name": ""}
),
"from_warehouse": (
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
if t.from_warehouse
else {"id": t.from_warehouse_id, "name": ""}
),
"to_project": (
{"id": t.to_project.id, "name": t.to_project.name}
if t.to_project
else {"id": t.to_project_id, "name": ""}
),
"to_warehouse": (
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
if t.to_warehouse
else None
),
"material": (
{"id": t.material.id, "name": t.material.name}
if t.material
else {"id": t.material_id, "name": ""}
),
"brand": (
{"id": t.brand.id, "name": t.brand.name}
if t.brand
else None
),
"unit": (
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
if t.unit
else {"id": t.unit_id, "code": "", "name_fa": None}
),
"quantity": t.quantity,
"specs": t.specs or {},
"specs_hash": t.specs_hash,
"note": t.note,
"status": t.status,
"transfer_date": t.transfer_date,
"transferred_by": t.transferred_by,
"received_at": t.received_at,
"received_by": t.received_by,
"receipt_allocations": t.receipt_allocations,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers — Create inter-project transfer
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers", status_code=201)
async def create_transfer(
payload: ProjectTransferCreate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.create_project_transfer(
db=db,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
transferred_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers — List transfers with direction and filters
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers")
async def list_transfers(
project_id: uuid.UUID | None = Query(default=None),
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
status: str | None = Query(default=None),
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
target_pid = project_id or ctx.project_id
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=target_pid,
direction=direction,
status=status,
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers/{id} — Single transfer details
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers/{transfer_id}")
async def get_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
if ctx.role == "pm" and ctx.project_id is not None:
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.patch("/inventory/project-transfers/{transfer_id}")
async def update_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.update_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.delete("/inventory/project-transfers/{transfer_id}")
async def delete_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
res = await project_transfer_service.delete_project_transfer(
db=db,
transfer_id=transfer_id,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": res}
# ---------------------------------------------------------------------------
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
# ---------------------------------------------------------------------------
@router.get("/pm/transfers/on-the-way")
async def pm_incoming_transfers(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید")
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=ctx.project_id,
direction="incoming",
status="in_transit",
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers/{transfer_id}/receive")
async def receive_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.receive_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
received_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
+56 -14
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = { d = {
"id": str(project.id), "id": str(project.id),
"name": project.name, "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), "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, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at), "created_at": to_iso(project.created_at),
} }
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id) pm = await db.get(User, project.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id 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 return d
@@ -55,7 +62,14 @@ async def list_projects(
): ):
stmt = select(Project) stmt = select(Project)
if q: 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) stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items) pm_map = await _pm_map(db, items)
@@ -64,24 +78,38 @@ async def list_projects(
d = { d = {
"id": str(p.id), "id": str(p.id),
"name": p.name, "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), "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, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at), "created_at": to_iso(p.created_at),
} }
pm = pm_map.get(p.pm_user_id) pm = pm_map.get(p.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id 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) data.append(d)
return {"data": data, "meta": meta} return {"data": data, "meta": meta}
@router.post("", status_code=201) @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( project = Project(
name=payload.name, name=payload.name.strip(),
description=payload.description, 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, start_date=payload.start_date,
planned_start=payload.start_date,
status="ACTIVE",
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
@@ -103,37 +131,51 @@ async def update_project(
request: Request, request: Request,
ctx: UserContext | None = Depends(_optional_ctx), ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
if payload.name is not None: 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: if payload.description is not None:
project.description = payload.description project.description = payload.description.strip()
if payload.start_date is not None: if payload.start_date is not None:
project.start_date = payload.start_date 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. # PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel). # Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set: if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager"): if role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است") raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None: if payload.pm_user_id is None:
project.pm_user_id = None project.pm_user_id = None
else: else:
pm_user = await db.get(User, payload.pm_user_id) pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None: if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id") raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role != "pm": if pm_user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
project.pm_user_id = 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() await db.flush()
return {"data": await project_to_dict(db, project)} return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}") @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) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
+8 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn 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 from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_procurement_writer()
@router.get("") @router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@router.post("", status_code=201) @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) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد") raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+65 -10
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession 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.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -36,12 +37,15 @@ from app.services.expand_service import (
serialize_quote_with_children, serialize_quote_with_children,
serialize_quotes, 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.pagination import sort_query
from app.utils.security import get_actor from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_procurement_writer()
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -156,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("", status_code=201) @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) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
@@ -167,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
@router.patch("/{quote_id}") @router.patch("/{quote_id}")
async def update_quote( 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) quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())} return {"data": await serialize_quote(db, quote, set())}
@@ -175,7 +200,11 @@ async def update_quote(
@router.patch("/{quote_id}/children") @router.patch("/{quote_id}/children")
async def replace_quote_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).""" """Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -188,7 +217,11 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept") @router.patch("/{quote_id}/accept")
async def accept_quote( 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) quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)} return {"data": await _accept_response(db, quote)}
@@ -224,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive") @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) quote = await quote_service.arrive_quote(db, quote_id, actor)
return { return {
"data": { "data": {
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/arrive-items") @router.patch("/{quote_id}/arrive-items")
async def 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) quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return { return {
@@ -259,14 +301,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject") @router.patch("/{quote_id}/reject")
async def reject_quote( 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) quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire") @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) quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -276,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.delete("/{quote_id}") @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) result = await quote_service.delete_quote(db, quote_id)
return {"data": result} return {"data": result}
+20 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: 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) @router.post("", status_code=201)
async def create_schedule_step( 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) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}") @router.patch("/{step_id}")
async def update_schedule_step( 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) step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no: 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}") @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) step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar( count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where( 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@router.post("", status_code=201) @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( duplicate = await db.scalar(
select(StandardConversion.id).where( select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit, 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}") @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) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@router.delete("/{sc_id}") @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) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
+116 -28
View File
@@ -2,12 +2,15 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog. GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink. DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — price-comparison data source. GET /materials/{id}/suppliers — suppliers linked to a material.
""" """
from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(
category: MaterialCategory | None = None, link: SupplierMaterial,
supplier: Supplier | None = None) -> dict: material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None,
) -> dict:
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
return { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
"material_id": str(link.material_id), "material_id": str(link.material_id),
"is_active": link.is_active, "is_active": link.is_active,
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None, "last_quoted_price": (
float(link.last_quoted_price) if link.last_quoted_price is not None else None
),
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None, "last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at), "created_at": to_iso(link.created_at),
"material": { "material": (
"id": str(material.id), {
"name": material.name, "id": str(material.id),
"category": {"id": str(category.id), "name": category.name} if category else None, "name": material.name,
} if material else None, "category": (
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None, {"id": str(category.id), "name": category.name} if category else None
),
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
} }
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials") @router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials( async def list_supplier_materials(
supplier_id, supplier_id: UUID,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -56,44 +78,110 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at) .order_by(SupplierMaterial.created_at)
) )
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items} mat_ids = [lnk.material_id for lnk in items]
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials: dict = {}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta} if mat_ids:
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
materials = {m.id: m for m in result.scalars()}
return {
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
"meta": meta,
}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) supplier_id: UUID,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material, None)} return {"data": link_to_dict(link, material)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @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: UUID,
material_id: UUID,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@router.get("/materials/{material_id}/suppliers") # ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers( async def list_material_suppliers(
material_id, material_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Return the suppliers linked to a material, straight from PostgreSQL.
The path parameter is typed as UUID so FastAPI validates and coerces it
before the handler runs; a malformed id is rejected with 422, an unknown
material with 404.
Atomic JOIN — a supplier is returned only through its supplier_materials
link, so the response can never contain a supplier that is not actually
linked to this material:
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
Response envelope (frontend MaterialSupplier binds these fields directly):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
]
}
"""
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
stmt = ( stmt = (
select(SupplierMaterial) select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id) .where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last()) .order_by(Supplier.name)
) )
items, meta = await paginate(stmt, page, page_size, db) suppliers = (await db.execute(stmt)).scalars().all()
sup_ids = {l.supplier_id for l in items}
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()} return {
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta} "data": [
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier 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 from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@router.post("", status_code=201) @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) category = await _category_or_404(db, payload.category_id)
supplier = Supplier( supplier = Supplier(
name=payload.name, name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}") @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) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@router.delete("/{supplier_id}") @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) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@router.post("", status_code=201) @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(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}") @router.patch("/{termsheet_id}")
async def update_termsheet( 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) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered": if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status") @router.patch("/{termsheet_id}/status")
async def update_termsheet_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(): async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}") @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) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec 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 from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@router.post("", status_code=201) @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)) duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None: if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}") @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) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@router.delete("/{unit_id}") @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) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
+186 -31
View File
@@ -17,15 +17,21 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends 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 sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record from app.utils.audit import record as audit_record
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import ( from app.utils.errors import (
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه", "pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات", "procurement_manager": "مدیر تدارکات",
"admin": "ادمین", "admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
} }
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: 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); """Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility.""" project_id/project_name stay as the first (oldest) one for compatibility.
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or [] projects = projects or []
first = projects[0] if projects else None first = projects[0] if projects else None
return { out = {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"phone": user.phone, "phone": user.phone,
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
"projects": [{"id": str(p.id), "name": p.name} for p in projects], "projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "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: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management.""" """Admin, procurement manager, or project manager — organization management."""
if ctx.role not in ("admin", "procurement_manager"): if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project the user manages as PM (oldest first; [] for non-PMs).""" """Every project assigned to the user (supervisor or PM) (oldest first).
if user.role != "pm": Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return [] return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = ( rows = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id == user.id) .where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
return {"data": user_to_dict(ctx.user, projects)} return {"data": await user_to_dict(ctx.user, projects, db)}
@router.get("/me/projects") @router.get("/me/projects")
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
active project against this list and falls back gracefully when a active project against this list and falls back gracefully when a
project is revoked.""" project is revoked."""
projects = await _user_projects(db, ctx.user) 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]} return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@@ -148,20 +212,20 @@ async def list_users(
): ):
"""Every user (with role + assigned projects) — Organization panel.""" """Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() 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"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.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)}, "meta": {"total": len(users)},
} }
@@ -187,30 +251,30 @@ async def create_user(
db.add(user) db.add(user)
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def update_user_role( async def update_user_role(
user_id, user_id,
payload: UserRoleUpdate, payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")), ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not """Set/clear a role — organization management (admin, procurement manager, PM)."""
assign or elevate roles. Revoking (or switching away from «pm») also if payload.role == "admin" and ctx.role != "admin":
unassigns the user from their project.""" raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
previous_role = user.role previous_role = user.role
if user.role == "pm" and payload.role != "pm": if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
user.role = payload.role user.role = payload.role
await db.flush() await db.flush()
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}) 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) projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)} return {"data": await user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
continue continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid)) existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None: 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( db.add(User(
casdoor_id=cid, casdoor_id=cid,
username=uname,
first_name=str( first_name=str(
cu.get("firstName") cu.get("firstName")
or cu.get("displayName") or cu.get("displayName")
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None, phone=str(cu.get("phone") or "") or None,
)) ))
created += 1 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() await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() 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"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.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}, "meta": {"total": len(users), "created": created},
} }
@@ -282,17 +357,97 @@ async def assign_user_project(
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
if user.role != "pm": if user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
if payload.project_id is None: if payload.project_id is None:
await _unassign_projects(db, user.id) 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() await db.flush()
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id) project = await db.get(Project, payload.project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.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() await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))} 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
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None unit_id: int | None = None
specs: dict | None = None specs: dict | None = None
note: str | None = None note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None transaction_date: datetime | None = None
@model_validator(mode="after") @model_validator(mode="after")
+26 -2
View File
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel): class ProjectCreate(APIModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
class ProjectUpdate(APIModel): class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1) name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | 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). # PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM. # Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None pm_user_id: UUID | None = None
@@ -135,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1) schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel): class ItemDecisionIn(APIModel):
line_item_id: UUID line_item_id: UUID
status: Literal["chosen", "rejected"] status: Literal["chosen", "rejected"]
@@ -142,6 +152,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0) adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)") project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel): class ItemProjectsIn(APIModel):
@@ -203,13 +214,13 @@ class UserCreate(APIModel):
last_name: str = "" last_name: str = ""
phone: str | None = None phone: str | None = None
casdoor_id: 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): class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment).""" """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): class UserProjectUpdate(APIModel):
@@ -341,3 +352,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0) received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None on_time: bool | None = None
quality_note: str | 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)
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
class WarehouseAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class ProjectTransferCreate(APIModel):
from_warehouse_id: UUID
to_project_id: UUID
material_id: UUID
brand_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
specs: dict = Field(default_factory=dict)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferUpdate(APIModel):
to_project_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferReceive(APIModel):
allocations: list[WarehouseAllocationIn] = Field(
min_length=1,
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
)
received_at: datetime | None = None
note: str | None = None
class IdNameOut(APIModel):
id: UUID
name: str
class UnitBriefOut(APIModel):
id: int
code: str
name_fa: str | None = None
class ProjectTransferOut(APIModel):
id: UUID
from_project: IdNameOut
from_warehouse: IdNameOut
to_project: IdNameOut
to_warehouse: IdNameOut | None = None
material: IdNameOut
brand: IdNameOut | None = None
unit: UnitBriefOut
quantity: Decimal
specs: dict
specs_hash: str
note: str | None = None
status: str
transfer_date: datetime
transferred_by: str | None = None
received_at: datetime | None = None
received_by: str | None = None
receipt_allocations: list[dict] | None = None
created_at: datetime
updated_at: datetime
+732
View File
@@ -0,0 +1,732 @@
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional
«What» filters (material_id / brand_id / specs_hash).
Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the
UI picks one unit at a time. Numeric results leave the service chart-ready
(floats); identifiers and labels are plain strings.
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
exactly — this module is the server side of that contract.
"""
import uuid
from datetime import date as date_cls
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
LineItem,
Project,
Quote,
QuoteProjects,
)
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
# daily widgets means today at this offset.
TEHRAN = timezone(timedelta(hours=3, minutes=30))
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
# separately so the UI can stack it as a distinct red segment inside the
# outbound bar. Transfers are excluded from the buckets (they are internal
# moves) but DO count toward the running balance.
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
"""«What» filters for a statement that already joins inventory→material."""
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None:
stmt = stmt.where(InventoryItem.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
return stmt
def _scope_filters(stmt, *, project_ids, project_id):
"""warehouse.project_id restriction for the resolved dashboard scope."""
if project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
elif project_ids is not None:
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
return stmt
def _qty(value) -> float:
return round(float(value), 4) if value is not None else 0.0
# ---------------------------------------------------------------------------
# «What» filter cascade — Category → Material → Brand → Specs
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Options for the global filter bar (Material → Brand → Specs).
Materials come from the catalog (active only). Brand options per material
are the UNION of the catalog links (brand_materials — every brand defined
for that material) and the distinct brands actually present in stock, so
the cascade is complete even for materials whose current stock rows carry
no brand yet. Spec options come from stock rows (inventory.specs_hash),
since specs are a stock-level dimension.
"""
materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all()
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
inv_rows = (
await db.execute(
select(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Brand.name,
)
.select_from(InventoryItem)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.distinct()
)
).all()
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
catalog_brand_rows = (
await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.select_from(BrandMaterial)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.is_active.is_(True))
)
).all()
brands_by_material: dict[uuid.UUID, dict] = {}
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
for material_id, brand_id, brand_name in catalog_brand_rows:
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
if brand_id is not None:
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
if specs_hash:
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
def material_entry(m: Material) -> dict:
unit = units.get(m.canonical_unit_id)
brands = brands_by_material.get(m.id, {})
spec_map = specs_by_material.get(m.id, {})
return {
"id": str(m.id),
"name": m.name,
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
"brands": [
{"id": str(bid), "name": bname}
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
],
"specs": [
{"hash": h, "specs": sp}
for h, sp in sorted(spec_map.items())
],
}
# فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات)
return {"data": {"materials": [material_entry(m) for m in materials]}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
)
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
q = _qty(qty)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
mat = materials_tree.setdefault(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
if ucode is not None:
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
)
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count
materials = []
for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects_list.append({**proj, "warehouses": warehouses})
projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"materials": materials,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
material_id=None,
brand_id=None,
specs_hash=None,
limit: int = 200,
) -> dict:
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
Last activity = MAX(inventory_transactions.transaction_date) per row —
served by idx_inventory_tx_inventory_date — falling back to
inventory.updated_at for rows that never posted a transaction.
"""
last_tx = (
select(func.max(InventoryTransaction.transaction_date))
.where(InventoryTransaction.inventory_id == InventoryItem.id)
.correlate(InventoryItem)
.scalar_subquery()
)
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
stmt = (
select(
InventoryItem.id,
InventoryItem.quantity,
InventoryItem.specs,
Material.name,
Brand.name,
Warehouse.name,
Project.name,
Unit.code,
Unit.name_fa,
last_activity.label("last_activity"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
.order_by(last_activity.asc())
.limit(limit)
)
stmt = _stock_filters(
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all()
now = datetime.now(TEHRAN)
items = []
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
items.append({
"inventory_id": str(inv_id),
"material": mat_name,
"brand": brand_name,
"specs": specs or {},
"warehouse": wh_name,
"project": proj_name,
"quantity": _qty(quantity),
"unit": {"code": ucode, "name_fa": uname_fa},
"last_activity": last.isoformat() if last else None,
"idle_days": (now - last).days if last else None,
})
return {"data": {"idle_days_threshold": idle_days, "items": items}}
# ---------------------------------------------------------------------------
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
# ---------------------------------------------------------------------------
async def daily_flow(
db: AsyncSession,
*,
project_ids,
project_id,
date_from: datetime,
date_to: datetime,
warehouse_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Per-day buckets from the signed ledger.
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`.
"""
def _with_joins(sel):
return (
sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
what_conditions = []
if material_id is not None:
what_conditions.append(InventoryItem.material_id == material_id)
if brand_id is not None:
what_conditions.append(InventoryItem.brand_id == brand_id)
if specs_hash:
what_conditions.append(InventoryItem.specs_hash == specs_hash)
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
bucket_rows = (
await db.execute(
_with_joins(
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
.where(
InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to,
*scope_conditions,
*what_conditions,
)
.group_by(day_expr, InventoryTransaction.transaction_type)
)
)
).all()
opening = _qty(
await db.scalar(
_with_joins(
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
InventoryTransaction.transaction_date < date_from,
*scope_conditions,
*what_conditions,
)
)
)
)
signed_by_day: dict[date_cls, dict[str, float]] = {}
for day, tx_type, signed in bucket_rows:
day_key = day.date() if isinstance(day, datetime) else day
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
days = []
running = opening
current = date_from.date()
while current < date_to.date():
types = signed_by_day.get(current, {})
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
running = round(running + sum(types.values()), 4)
days.append({
"date": current.isoformat(),
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
"outbound": outbound,
"waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running,
})
current += timedelta(days=1)
return {"data": {"opening_balance": opening, "days": days}}
# ---------------------------------------------------------------------------
# Widget 4 — Daily control (today's site operations)
# ---------------------------------------------------------------------------
async def daily_control(
db: AsyncSession,
*,
project_ids,
project_id,
day: date_cls,
warehouse_id=None,
) -> dict:
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
counts + quantities, the exact performer names, and the operation detail."""
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
day_end = day_start + timedelta(days=1)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
count_rows = (
await db.execute(
select(
InventoryTransaction.transaction_type,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.transaction_type)
)
).all()
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
performers_rows = (
await db.execute(
select(
InventoryTransaction.performed_by,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.performed_by)
.order_by(func.count().desc())
)
).all()
detail_rows = (
await db.execute(
select(
InventoryTransaction,
Warehouse.name,
Material.name,
InventoryItem.specs,
Unit.code,
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
InventoryTransaction.transaction_type.in_(
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
),
*scope_conditions,
)
.order_by(InventoryTransaction.transaction_date.desc())
.limit(200)
)
).all()
zero = {"tickets": 0, "qty": 0.0}
items = []
for tx, wh_name, mat_name, specs, ucode in detail_rows:
items.append({
"id": str(tx.id),
"transaction_type": str(tx.transaction_type),
"quantity": _qty(tx.quantity),
"unit_code": ucode,
"performed_by": tx.performed_by,
"note": tx.note,
"warehouse": wh_name,
"material": mat_name,
"specs": specs or {},
"transaction_date": tx.transaction_date.isoformat(),
})
return {
"data": {
"date": day.isoformat(),
"outbound": by_type.get("WITHDRAWAL", zero),
"returns": by_type.get("RETURN", zero),
"waste": {
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
},
"purchases": by_type.get("PURCHASE", zero),
"performers": [
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
for name, tickets, qty in performers_rows
],
"items": items,
}
}
# ---------------------------------------------------------------------------
# Widget 8 — Procurement workload per project (project density)
# ---------------------------------------------------------------------------
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
"""Per project: offered quotes still open + chosen purchase lines (open
vs fully received). Chosen lines attribute to item_decisions.project_id
(the purchased destination); offered quotes to any linked project."""
received_sq = (
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
.group_by(ItemReceipt.item_decision_id)
.subquery()
)
order_conditions = [ItemDecision.status == "chosen"]
if project_id is not None:
order_conditions.append(ItemDecision.project_id == project_id)
elif project_ids is not None:
order_conditions.append(ItemDecision.project_id.in_(project_ids))
order_rows = (
await db.execute(
select(
Project.id,
Project.name,
func.count().label("chosen_items"),
func.sum(LineItem.quantity).label("ordered_qty"),
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
func.sum(
case(
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
else_=0,
)
).label("open_items"),
)
.select_from(ItemDecision)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id)
.outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
.where(*order_conditions)
.group_by(Project.id, Project.name)
)
).all()
offered_by_project: dict[uuid.UUID | None, set] = {}
offered_primary = (
await db.execute(
select(Quote.id, Quote.project_id).where(
Quote.status == "offered", Quote.project_id.is_not(None)
)
)
).all()
for quote_id, pid in offered_primary:
offered_by_project.setdefault(pid, set()).add(quote_id)
offered_junction = (
await db.execute(
select(QuoteProjects.quote_id, QuoteProjects.project_id)
.join(Quote, Quote.id == QuoteProjects.quote_id)
.where(Quote.status == "offered")
)
).all()
for quote_id, pid in offered_junction:
offered_by_project.setdefault(pid, set()).add(quote_id)
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
names: dict[uuid.UUID, str] = {}
if project_ids_needed:
names = {
p.id: p.name
for p in (
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
).scalars()
}
projects = []
for pid, _pname, chosen, ordered, received, open_items in order_rows:
if pid is None:
offered = 0
else:
offered = len(offered_by_project.pop(pid, set()))
if project_id is not None and pid != project_id:
offered = 0
elif project_ids is not None and pid not in project_ids:
offered = 0
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": offered,
"chosen_items": chosen,
"open_orders": open_items,
"ordered_qty": _qty(ordered),
"received_qty": _qty(received),
})
# offered-only projects that have no chosen lines yet
for pid, quote_ids in offered_by_project.items():
if not quote_ids:
continue
if project_id is not None and pid != project_id:
continue
if project_ids is not None and (pid is None or pid not in project_ids):
continue
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": len(quote_ids),
"chosen_items": 0,
"open_orders": 0,
"ordered_qty": 0.0,
"received_qty": 0.0,
})
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
return {"data": {"projects": projects}}
@@ -290,6 +290,7 @@ async def apply_transaction(
transaction_type=t, transaction_type=t,
quantity=quantity, quantity=quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=payload.transaction_date,
note=payload.note, note=payload.note,
) )
@@ -319,6 +320,7 @@ async def apply_transaction(
transaction_type=t, transaction_type=t,
quantity=-quantity, quantity=-quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=payload.transaction_date,
note=payload.note, note=payload.note,
) )
@@ -0,0 +1,471 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import Project
from app.models.project_transfer import ProjectTransfer
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services.inventory_service import (
_find_inventory,
_get_or_create_inventory,
canonical_specs_hash,
)
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
stmt = (
select(ProjectTransfer)
.where(ProjectTransfer.id == transfer_id)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
transfer = await db.scalar(stmt)
if transfer is None:
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
return transfer
async def create_project_transfer(
db: AsyncSession,
payload: ProjectTransferCreate,
caller_project_id: uuid.UUID | None,
caller_role: str,
transferred_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
# Validate source warehouse
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
if from_wh is None:
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
# RBAC: PM can only send from their own project
if caller_role == "pm" and caller_project_id is not None:
if from_wh.project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
from_proj = await db.get(Project, from_wh.project_id)
to_proj = await db.get(Project, payload.to_project_id)
if to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if from_wh.project_id == payload.to_project_id:
raise ValidationError(
"پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)",
field="to_project_id",
)
# Validate material, brand, unit
mat = await db.get(Material, payload.material_id)
if mat is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
# Requirement 2: Canonical specs hash from inventory_service
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# Check stock in source warehouse with SELECT ... FOR UPDATE
source_item = await _find_inventory(
db,
payload.from_warehouse_id,
payload.material_id,
payload.brand_id,
specs_hash,
for_update=True,
)
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
if source_item is None or source_item.quantity < payload.quantity:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
field="quantity",
)
# Deduct from source warehouse immediately
source_item.quantity -= payload.quantity
transfer_pair_id = uuid.uuid4()
now_dt = payload.transfer_date or datetime.now(timezone.utc)
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
if payload.note:
note_text += f" — {payload.note}"
out_tx = InventoryTransaction(
inventory_id=source_item.id,
warehouse_id=from_wh.id,
transaction_type="TRANSFER_OUT",
quantity=-payload.quantity,
transfer_pair_id=transfer_pair_id,
performed_by=transferred_by,
transaction_date=now_dt,
note=note_text,
)
db.add(out_tx)
await db.flush()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
transfer = ProjectTransfer(
from_project_id=from_wh.project_id,
from_warehouse_id=from_wh.id,
to_project_id=payload.to_project_id,
material_id=payload.material_id,
brand_id=actual_brand_id,
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity,
specs=actual_specs,
specs_hash=actual_specs_hash,
note=payload.note,
status="in_transit",
out_transaction_id=out_tx.id,
transfer_date=now_dt,
transferred_by=transferred_by,
)
db.add(transfer)
await db.flush()
return await get_project_transfer_or_404(db, transfer.id)
async def update_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
)
if transfer.status != "in_transit":
raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند")
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
new_to_proj = await db.get(Project, payload.to_project_id)
if new_to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if payload.to_project_id == transfer.from_project_id:
raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id")
transfer.to_project_id = payload.to_project_id
# Quantity adjustment (reconciles source stock difference)
if payload.quantity is not None and payload.quantity != transfer.quantity:
delta = payload.quantity - transfer.quantity
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if delta > 0:
if source_item is None or source_item.quantity < delta:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
field="quantity",
)
source_item.quantity -= delta
else:
if source_item is not None:
source_item.quantity += abs(delta)
transfer.quantity = payload.quantity
# Update out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.quantity = -payload.quantity
if payload.note is not None:
transfer.note = payload.note
if payload.transfer_date is not None:
transfer.transfer_date = payload.transfer_date
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.transaction_date = payload.transfer_date
return await get_project_transfer_or_404(db, transfer_id)
async def delete_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> dict:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
)
# Reversibility (Requirement 4): Restore stock to source warehouse
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if source_item is not None:
source_item.quantity += transfer.quantity
# Delete out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
await db.delete(out_tx)
await db.delete(transfer)
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
async def receive_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
caller_project_id: uuid.UUID | None,
caller_role: str,
received_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.to_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
# Lock Guard
if transfer.status == "received":
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
if transfer.status != "in_transit":
raise ConflictError("این انتقال در وضعیت در راه نیست")
# Requirement 1: 100% full delivery logic (no partial receipt)
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
if alloc_sum != transfer.quantity:
raise ValidationError(
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
field="allocations",
)
# Validate destination warehouses belong to destination project
wh_ids = [a.warehouse_id for a in payload.allocations]
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
wh_map = {w.id: w for w in wh_rows}
for a in payload.allocations:
wh = wh_map.get(a.warehouse_id)
if wh is None or wh.project_id != transfer.to_project_id:
raise ValidationError(
"انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد",
field="allocations.warehouse_id",
)
from_proj = await db.get(Project, transfer.from_project_id)
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
out_tx = (
await db.get(InventoryTransaction, transfer.out_transaction_id)
if transfer.out_transaction_id
else None
)
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
now_dt = payload.received_at or datetime.now(timezone.utc)
# Add stock to destination warehouse(s) via TRANSFER_IN
for a in payload.allocations:
dest_item = await _get_or_create_inventory(
db,
a.warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.unit_id,
transfer.specs,
transfer.specs_hash,
)
dest_item.quantity += a.quantity
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
if payload.note:
note_text += f" — {payload.note}"
in_tx = InventoryTransaction(
inventory_id=dest_item.id,
warehouse_id=a.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=a.quantity,
transfer_pair_id=pair_id,
performed_by=received_by,
transaction_date=now_dt,
note=note_text,
)
db.add(in_tx)
transfer.status = "received"
transfer.received_at = now_dt
transfer.received_by = received_by
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
transfer.receipt_allocations = [
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
for a in payload.allocations
]
return await get_project_transfer_or_404(db, transfer_id)
async def list_project_transfers(
db: AsyncSession,
project_id: uuid.UUID | None = None,
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
status: str | None = None,
) -> list[ProjectTransfer]:
stmt = (
select(ProjectTransfer)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
if project_id is not None:
if direction == "outgoing":
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
elif direction == "incoming":
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
else:
stmt = stmt.where(
(ProjectTransfer.from_project_id == project_id)
| (ProjectTransfer.to_project_id == project_id)
)
if status:
stmt = stmt.where(ProjectTransfer.status == status)
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
res = await db.execute(stmt)
return list(res.scalars().all())
+213
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID from uuid import UUID
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet, ItemTermsheet,
LineItem, LineItem,
LineItemProjects, LineItemProjects,
LineItemSpec,
Project, Project,
Quote, Quote,
QuoteEvent, QuoteEvent,
@@ -418,6 +420,78 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None: async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered.""" """Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
) )
.execution_options(synchronize_session=False) .execution_options(synchronize_session=False)
) )
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched! # LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None quote.arrived_date = None
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions" "برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
) )
termsheet_id = ts_ids_by_index[index] termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where( select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id, ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None: if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None: if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id") raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id) chosen_ts_ids.add(termsheet_id)
else: # rejected else: # rejected
if decision is None: if decision is None:
@@ -903,6 +1100,17 @@ async def quote_stats(
*conditions, *conditions,
) )
) )
# Procurement funnel (cumulative, not the current-state counts above):
# a quote counts as accepted/arrived if it EVER reached that stage.
funnel_row = (
await db.execute(
select(
func.count().label("offered_total"),
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
).where(*conditions)
)
).one()
return { return {
"total": sum(counts.values()), "total": sum(counts.values()),
"offered": counts["offered"], "offered": counts["offered"],
@@ -911,6 +1119,11 @@ async def quote_stats(
"rejected": counts["rejected"], "rejected": counts["rejected"],
"expired": counts["expired"], "expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None, "average_lead_time_days": int(round(float(avg))) if avg is not None else None,
"funnel": {
"offered_total": funnel_row.offered_total,
"accepted_total": funnel_row.accepted_total,
"arrived_total": funnel_row.arrived_total,
},
} }
+81
View File
@@ -0,0 +1,81 @@
"""HMAC-SHA256 token verification for standalone draft review."""
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from uuid import UUID
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
padding = 4 - (len(s) % 4)
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode("ascii"))
def _get_secret(secret: str | None = None) -> str:
if secret:
return secret
try:
from app.config import settings
return settings.DRAFT_TOKEN_SECRET
except Exception:
return "procurement-draft-secret-change-in-prod-v7"
def create_draft_token(
draft_id: str | UUID,
chat_id: int | None = None,
user_id: str | None = None,
secret: str | None = None,
expires_in: int = 7200,
) -> str:
sec = _get_secret(secret)
now = int(time.time())
payload = {
"draft_id": str(draft_id),
"chat_id": chat_id,
"user_id": str(user_id) if user_id else None,
"iat": now,
"exp": now + expires_in,
}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_draft_token(
token: str,
secret: str | None = None,
) -> dict[str, Any] | None:
"""Verify signature and expiration of a draft token. Return payload or None."""
if not token or "." not in token:
return None
try:
sec = _get_secret(secret)
payload_b64, sig_b64 = token.split(".", 1)
expected_sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload_bytes = _b64url_decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
exp = payload.get("exp")
if exp is not None and int(exp) < int(time.time()):
return None
return payload
except Exception:
return None
+84
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [ USER_PLAN = [
# (casdoor username, first, last, role) # (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"), ("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"), ("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"), ("mohaghegh", "محمد", "محقق", "pm"),
@@ -261,8 +262,11 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
name=name, name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
) )
@@ -440,6 +444,86 @@ async def run() -> None:
)) ))
await db.flush() await db.flush()
# ---- MVP dashboard demo ledger -----------------------------------
# Multi-day history so every widget has real data to show:
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
# - withdrawals/returns/waste over the last ~12 days → flow series
# - tickets dated TODAY (performed_by names) → daily control
# Quantities are hand-checked to keep every row >= 0.
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
mat = material_by_name[MATERIALS[mat_idx][0]]
wh = warehouse_rows[project_idx][wh_idx]
inv = await db.scalar(select(InventoryItem).where(
InventoryItem.warehouse_id == wh.id,
InventoryItem.material_id == mat.id,
InventoryItem.specs_hash == "default",
))
if inv is None:
inv = InventoryItem(
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
specs={}, specs_hash="default",
)
db.add(inv)
await db.flush()
return inv
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
days_ago: int, performed_by: str, note: str | None = None) -> None:
inv.quantity += Decimal(str(qty))
db.add(InventoryTransaction(
inventory_id=inv.id,
warehouse_id=wh.id,
transaction_type=tx_type,
quantity=Decimal(str(qty)),
performed_by=performed_by,
note=note or "تاریخچه دمو داشبورد",
transaction_date=d(days_ago),
))
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
# dead stock: cable + switches bought long ago, untouched since
cable = await ensure_inv(0, 0, 0)
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
keys = await ensure_inv(1, 0, 0)
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
cement = await ensure_inv(4, 2, 0)
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
blocks = await ensure_inv(5, 2, 1)
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
pe16_a = await ensure_inv(6, 1, 0)
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
pe16_b = await ensure_inv(6, 1, 1)
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
pe25 = await ensure_inv(7, 1, 0)
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
await db.flush()
await db.commit() await db.commit()
# summary # summary
+150
View File
@@ -0,0 +1,150 @@
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
> این سند وضعیت نهایی یکپارچه‌سازی بات تلگرام/بله (procurement AI agent) با پروژه‌ی ERP را
> توضیح می‌دهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
> نکات امنیتی و نحوه‌ی استفاده. تاریخ آخرین به‌روزرسانی: برنچ `prod-v7`.
---
## ۱. مشکل چی بود؟
سرویس `bot` در `docker-compose.yml` این‌طور تعریف شده بود:
```yaml
bot:
build:
context: ./bot # ← پوشه‌ای که در گیت وجود نداشت!
volumes:
- ./bot/app:/srv/bot/app
```
اما:
1. **پوشه‌ی `bot/` هیچ‌وقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
2. کد ایجنت در پوشه‌ای به نام `ai procurement/` (با فاصله در نام) زندگی می‌کرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمی‌کرد.
3. روی دستگاه reza.g یک **Windows Junction** (میان‌بر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره می‌کرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب می‌داد.
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست می‌خورد —
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
## ۲. راه‌حل اعمال‌شده (راه‌حل شماره ۲)
1. Junction ویندوزی `bot` حذف شد (فقط میان‌بر؛ فایلی از دست نرفت).
2. پوشه‌ی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
**تاریخچه‌ی گیت هر ۴۱ فایل به‌صورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشه‌ی بات دیگر ignore نشود.
(`voice/` سر جایش ماند — نت‌های صوتی رuntimes هستند و نباید کامیت شوند.)
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار می‌کند:
`context: ./bot` و `./bot/app` دقیقاً به فایل‌های کامیت‌شده اشاره می‌کنند.
5. همه‌چیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایل‌ها دسترسی داشته باشد.
### قواعد ignore فعلی (عمدی)
| مسیر | وضعیت | دلیل |
|---|---|---|
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکن‌ها — هرگز کامیت نمی‌شوند |
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
| `voice/` | ignore (ریشه) | فایل‌های صوتی دریافتی از بله در زمان اجرا |
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیت‌شده** | بکاپ تاریخچه‌ی اصلی ایجنت (~۲۰۰KB) |
## ۳. وضعیت فعلی گیت (تاریخ این سند)
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` هم‌SHA).
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **به‌علاوه‌ی** `bot/`
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
(اندپوینت‌های همگام‌سازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
تا «feat(bot,wizard): smart message filtering…».
- بقیه‌ی برنچ‌های تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایل‌های بات را ندارند** —
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
## ۴. ⚠️ نکته‌ی امنیتی (مهم — اقدام لازم)
در همان `docker-compose.yml`، مقادیر **واقعی** به‌عنوان پیش‌فرض env کامیت و پوش شده‌اند:
```yaml
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
```
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچه‌ی مخزن مشترک است و **هر کسی که به ریپو
دسترسی دارد می‌تواند آن‌ها را ببیند** (`.env` کامیت نشده، ولی این پیش‌فرض‌ها عملاً همان لو رفتن راز است).
اقدامات توصیه‌شده (هنوز انجام نشده — نیاز به تصمیم مالک توکن‌ها):
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
2. مقادیر پیش‌فرض را از compose حذف کنید و به‌جای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
## ۵. نحوه‌ی استفاده
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
```powershell
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
cd ERP
git checkout prod-v7
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
# BALE_BOT_TOKEN=... توکن بات از بله
# AI_API_KEY=... کلید سرویس LLM
# BALE_ADMIN_CHAT_IDS=... چت‌آیدی مدیران
# ۲) کل استک + بات:
docker compose up -d --build
```
سرویس `bot` (کانتینر `procurement-bot`) به‌صورت long-polling به `tapi.bale.ai` وصل می‌شود،
به `api:8000/api/v1` (همان ERP) و پایگاه‌داده‌ی `procurement` وصل است و
draft های گفتگو را در جدول `bot_drafts` می‌نویسد.
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
```powershell
docker compose build bot
docker compose up -d bot
docker compose logs -f bot
```
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
1. کاربر در بله به بات پیام می‌دهد (متن یا وویس — وویس با STT به متن تبدیل می‌شود).
2. بات با NLU موجودی/تأمین‌کننده/کالا را استخراج می‌کند و یک **draft** می‌سازد
(وضعیت `active` در جدول `bot_drafts`) و کارت پیش‌نمایش با فیلدهای خالی می‌فرستد.
3. کاربر با دکمه‌ی ✅ تأیید می‌کند؛ بات draft را به ERP کامیت می‌کند
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
4. ویزارد فرانت‌اند همان draft را از `GET /api/v1/drafts` می‌خواند و پیش‌پر می‌کند —
همگام‌سازی دوطرفه با ذخیره‌ی خودکار (debounced) بین مرورگر و بات.
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی می‌شود (reply-based edit، بدون کارت تکراری).
### ۵.۴ تست سریع سلامت
```powershell
docker compose ps bot # باید Up باشد
docker compose logs bot | Select-Object -Last 20
# داخل کانتینر:
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
```
### ۵.۵ مسیرهای مهم
| مسیر | محتوا |
|---|---|
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
| `bot/docs/SETUP.md` | راه‌اندازی جزئی‌تر ایجنت |
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچه‌ی مستقل قبلی ایجنت |
## ۶. نکات باقی‌مانده برای تیم
- **ادغام با برنچ‌های جدید تیم:** فایل‌های بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
آن‌ها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
@@ -135,3 +135,16 @@ class ErpClient:
return (await self._request( return (await self._request(
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id "PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
)).get("data") or {} )).get("data") or {}
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
url = f"{self._base}/quotes/{quote_id}/{kind}"
files = {"file": (file_name, content, mime_type)}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
if resp.status_code in (200, 201):
return resp.json()
except Exception as e:
logger.warning("Failed to upload quote attachment to ERP: %s", e)
return {}
@@ -67,28 +67,19 @@ class BaleClient:
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None, async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
reply_to: int | None = None) -> dict: reply_to: int | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "text": text[:4000], "parse_mode": "HTML"} payload: dict = {"chat_id": chat_id, "text": text[:4000]}
if reply_markup: if reply_markup:
payload[REPLY_MARKUP] = reply_markup payload[REPLY_MARKUP] = reply_markup
if reply_to: if reply_to:
payload["reply_to_message_id"] = reply_to payload["reply_to_message_id"] = reply_to
try: return await self._call("sendMessage", payload)
return await self._call("sendMessage", payload)
except BaleError:
# parse_mode / markup rejection fallback: retry plain, keep the keyboard.
payload.pop("parse_mode", None)
return await self._call("sendMessage", payload)
async def edit_message_text(self, chat_id: int, message_id: int, text: str, async def edit_message_text(self, chat_id: int, message_id: int, text: str,
reply_markup: dict | None = None) -> dict: reply_markup: dict | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000], "parse_mode": "HTML"} payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000]}
if reply_markup: if reply_markup:
payload[REPLY_MARKUP] = reply_markup payload[REPLY_MARKUP] = reply_markup
try: return await self._call("editMessageText", payload)
return await self._call("editMessageText", payload)
except BaleError:
payload.pop("parse_mode", None)
return await self._call("editMessageText", payload)
async def delete_message(self, chat_id: int, message_id: int) -> bool: async def delete_message(self, chat_id: int, message_id: int) -> bool:
payload: dict = {"chat_id": chat_id, "message_id": message_id} payload: dict = {"chat_id": chat_id, "message_id": message_id}
+148
View File
@@ -0,0 +1,148 @@
"""Bot configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
``.env`` file. Docker/compose passes them in directly (same convention as
the api service's ``PROCUREMENT_*`` prefix).
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PROCUREMENT_BOT_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
BALE_API_BASE: str = "https://tapi.bale.ai"
BOT_TOKEN: str = "" # from Bale's BotFather
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
POLL_ERROR_SLEEP_SECONDS: float = 3.0
# ERP API (in-network service URL inside Docker).
API_BASE_URL: str = "http://localhost:8000/api/v1"
API_TIMEOUT_SECONDS: float = 30.0
# Optional bearer for the day procurement endpoints start enforcing auth.
SERVICE_TOKEN: str = ""
# Frontend URL (for opening Wizard from Bale bot)
FRONTEND_URL: str = "http://localhost:8081"
# Security token for standalone wizard review URL
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
# Persistent storage directory for draft attachments (proformas)
ATTACHMENTS_DIR: str = "data/attachments/proformas"
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
AI_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
LLM_TIMEOUT_SECONDS: float = 60.0
# STT providers — support multiple models/APIs in parallel.
# 1. JSON list: [{"name": "m1", "base_url": "...", "api_key": "...", "model": "..."}, ...]
STT_PROVIDERS_JSON: str = ""
# 2. Individual provider variables (up to 3 distinct STT models)
STT_PROVIDER_1_NAME: str = "model-1"
STT_PROVIDER_1_BASE_URL: str = ""
STT_PROVIDER_1_API_KEY: str = ""
STT_PROVIDER_1_MODEL: str = ""
STT_PROVIDER_1_LANGUAGE: str = "fa"
STT_PROVIDER_2_NAME: str = "model-2"
STT_PROVIDER_2_BASE_URL: str = ""
STT_PROVIDER_2_API_KEY: str = ""
STT_PROVIDER_2_MODEL: str = ""
STT_PROVIDER_2_LANGUAGE: str = "fa"
STT_PROVIDER_3_NAME: str = "model-3"
STT_PROVIDER_3_BASE_URL: str = ""
STT_PROVIDER_3_API_KEY: str = ""
STT_PROVIDER_3_MODEL: str = ""
STT_PROVIDER_3_LANGUAGE: str = "fa"
# Legacy single STT provider fallback:
STT_BASE_URL: str = ""
STT_API_KEY: str = ""
STT_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa"
@property
def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL
@property
def stt_api_key(self) -> str:
return self.STT_API_KEY or self.AI_API_KEY
@property
def stt_providers(self) -> list[dict]:
import json
if self.STT_PROVIDERS_JSON:
try:
data = json.loads(self.STT_PROVIDERS_JSON)
if isinstance(data, list) and len(data) > 0:
return data
except Exception:
pass
# Check explicit STT_PROVIDER_1 / 2 / 3
custom_list: list[dict] = []
for idx in (1, 2, 3):
b_url = getattr(self, f"STT_PROVIDER_{idx}_BASE_URL")
key = getattr(self, f"STT_PROVIDER_{idx}_API_KEY")
model = getattr(self, f"STT_PROVIDER_{idx}_MODEL")
name = getattr(self, f"STT_PROVIDER_{idx}_NAME")
lang = getattr(self, f"STT_PROVIDER_{idx}_LANGUAGE") or "fa"
if b_url and key:
custom_list.append({
"name": name or f"model-{idx}",
"base_url": b_url,
"api_key": key,
"model": model or self.STT_MODEL,
"language": lang,
"timeout": 30.0,
})
if custom_list:
return custom_list
# Fallback to single legacy STT provider if set
if self.stt_base_url and self.stt_api_key:
return [{
"name": "default",
"base_url": self.stt_base_url,
"api_key": self.stt_api_key,
"model": self.STT_MODEL,
"language": self.STT_LANGUAGE,
"timeout": 30.0,
}]
return []
# PostgreSQL — same instance/database as the api service.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Chats that may use the bot without Casdoor linking (comma separated).
ADMIN_CHAT_IDS: str = ""
# Drafts older than this are marked expired by the sweeper loop.
DRAFT_TTL_HOURS: int = 24
LOG_LEVEL: str = "INFO"
@property
def admin_chat_ids(self) -> set[int]:
out: set[int] = set()
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
part = part.strip()
if part.isdigit():
out.add(int(part))
return out
settings = Settings()
@@ -281,33 +281,41 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
def compute_quote_missing(payload: dict) -> list[dict]: def compute_quote_missing(payload: dict) -> list[dict]:
"""What is still empty — the user's explicit ask («یادآوری چیزهای خالی»).""" """What is still empty — strictly aligned with DB schema requirements."""
out: list[dict] = [] out: list[dict] = []
def add(key: str, label: str, ok: bool, blocking: bool) -> None: def add(key: str, label: str, ok: bool, blocking: bool) -> None:
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking}) out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
sup_ok = bool(payload.get("supplier_id")) # In DB: supplier_id is NOT NULL in quotes table.
sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name"))
add("supplier", "تأمین‌کننده", sup_ok, True) add("supplier", "تأمین‌کننده", sup_ok, True)
# In DB: project_id is nullable (optional)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False) add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
# In DB: expiry_date is nullable (optional)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
items = payload.get("items") or [] items = payload.get("items") or []
add("items", "قلم (کالا/مقدار)", bool(items), True) add("items", "قلم (کالا/مقدار)", bool(items), True)
for i, item in enumerate(items, start=1): for i, item in enumerate(items, start=1):
name = item.get("material_name") or f"قلم {i}" name = item.get("material_name") or f"قلم {i}"
if not item.get("material_id"): has_mat = bool(item.get("material_id") or item.get("material_name"))
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True) if not has_mat:
add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True)
if not item.get("quantity"): if not item.get("quantity"):
add(f"item:{i}:qty", f"مقدار «{name}»", False, True) add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
if not (item.get("unit_id") or item.get("unit_name")): if not item.get("unit_id") and not item.get("unit_name"):
add(f"item:{i}:unit", f"واحد «{name}»", False, True) add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True)
if not item.get("unit_price"): if not item.get("unit_price"):
add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False) add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False)
if items and not payload.get("termsheets"): if items and not payload.get("termsheets"):
add("terms", "شرایط پرداخت", False, False) add("terms", "شرایط پرداخت", False, False)
if not payload.get("notes"): if not payload.get("notes"):
add("notes", "یادداشت", False, False) add("notes", "توضیحات", False, False)
return out return out
@@ -14,8 +14,9 @@ from app import drafts as draft_store
from app.config import settings from app.config import settings
from app.nlu import understand from app.nlu import understand
from app.runtime import BotContext, LinkedUser from app.runtime import BotContext, LinkedUser
from app.stt import transcribe from app.storage import save_proforma_file
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY from app.stt import transcribe, transcribe_multi
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
from . import commands, callbacks, quote, supplier, material, queries, decisions from . import commands, callbacks, quote, supplier, material, queries, decisions
@@ -38,31 +39,15 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or "").strip() text = (message.get("text") or message.get("caption") or "").strip()
source = "text" source = "text"
voice_transcripts: list[dict] | None = None
message_id = message.get("message_id")
if text.startswith("/"): if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text) await commands.handle_command(ctx, chat_id, text)
return return
if not text and (message.get("voice") or message.get("audio") or message.get("document")):
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"]
try:
audio = await ctx.bale.download_file(file_id)
text = await transcribe(settings, audio,
mime_type=(message.get("voice") or {}).get("mime_type"),
filename=(message.get("voice") or {}).get("file_name"))
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)))
return
if not text:
await ctx.send(chat_id, VOICE_EMPTY)
return
await ctx._log(chat_id, "in", "voice", text)
# Pending multi-step interactions take the message first. # Pending multi-step interactions take the message first.
flow = ctx.pending.get(chat_id) flow = ctx.pending.get(chat_id)
if flow: if flow:
@@ -75,11 +60,43 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
if user is None: if user is None:
return # gate either started linking or is mid-linking return # gate either started linking or is mid-linking
message_id = message.get("message_id")
reply_to = message.get("reply_to_message") reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None reply_to_msg_id = reply_to.get("message_id") if reply_to else None
target_draft = None def _is_audio(msg: dict) -> bool:
if msg.get("voice") or msg.get("audio"):
return True
doc = msg.get("document")
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
return True
return False
async def _extract_voice() -> list[dict] | None:
nonlocal source
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
audio_media = message.get("voice") or message.get("audio") or message.get("document")
file_id = audio_media["file_id"]
try:
audio_bytes = await ctx.bale.download_file(file_id)
transcripts = await transcribe_multi(
settings,
audio_bytes,
mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"),
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
return None
if not transcripts:
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
return None
return transcripts
# =========================================================================
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
# =========================================================================
if reply_to_msg_id is not None: if reply_to_msg_id is not None:
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None: if target_draft is None:
@@ -90,10 +107,103 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
) )
return return
# Check for proforma attachment (document or photo)
proforma_attached = False
saved_meta = None
doc = message.get("document")
photos = message.get("photo")
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
doc_file_id = doc["file_id"]
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
doc_mime = doc.get("mime_type") or "application/pdf"
elif photos:
largest = photos[-1] if isinstance(photos, list) else photos
doc_file_id = largest["file_id"]
doc_file_name = f"proforma_{message_id}.jpg"
doc_mime = "image/jpeg"
else:
doc_file_id = None
doc_file_name = None
doc_mime = None
if doc_file_id:
await ctx.bale.send_chat_action(chat_id, "upload_document")
try:
content = await ctx.bale.download_file(doc_file_id)
saved_meta = await save_proforma_file(
target_draft.id,
doc_file_name,
content,
mime_type=doc_mime,
storage_dir=settings.ATTACHMENTS_DIR,
)
payload = dict(target_draft.payload or {})
payload["proforma_file"] = saved_meta["file_name"]
payload["proforma"] = saved_meta
target_draft.payload = payload
await draft_store.save_payload(target_draft, mark_bale_synced=True)
proforma_attached = True
except Exception as exc:
logger.exception("Failed to persist proforma")
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیش‌فاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
return
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
if proforma_attached and not text and not voice_transcripts:
await quote.render(ctx, chat_id, target_draft, fresh=False)
await ctx.send(
chat_id,
f"📎 پیش‌فاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
reply_to=message_id,
)
return
if not proforma_attached and not text and not voice_transcripts:
await ctx.send(
chat_id,
"⚠️ برای ویرایش پیش‌نویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیش‌فاکتور را بفرستید.",
reply_to=message_id,
)
return
# =========================================================================
# BRANCH 2: NEW QUOTE MODE — Messages without reply
# =========================================================================
else:
target_draft = None
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
elif text:
source = "text"
else:
await ctx.send(
chat_id,
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
reply_to=message_id,
)
return
draft_summary = _draft_summary(target_draft) if target_draft else None draft_summary = _draft_summary(target_draft) if target_draft else None
result = await understand(settings, text, draft_summary) input_payload = voice_transcripts if voice_transcripts else text
result = await understand(settings, input_payload, draft_summary)
intent, patch = result.get("intent"), result.get("patch") or {} intent, patch = result.get("intent"), result.get("patch") or {}
logger.info("chat %s intent=%s conf=%s (is_edit=%s)", chat_id, intent, result.get("confidence"), target_draft is not None) consensus_text = result.get("consensus_text") or text
if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text)
logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)",
chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None)
if intent in ("register_quote", "update_draft"): if intent in ("register_quote", "update_draft"):
await quote.handle_entities( await quote.handle_entities(
@@ -79,4 +79,4 @@ async def _whoami(ctx, chat_id: int) -> None:
await ctx.send(chat_id, UNLINKED) await ctx.send(chat_id, UNLINKED)
return return
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش") role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
await ctx.send(chat_id, f"اتصال: <b>{esc(link.display_name)}</b> — {role}") await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}")
@@ -103,9 +103,9 @@ def _flow_user(flow):
def _accept_summary(quote: dict, chosen: int) -> str: def _accept_summary(quote: dict, chosen: int) -> str:
lines = [ lines = [
f"✔️ <b>پذیرش استعلام</b> #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}", f"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
"", "",
"<b>اقلام:</b>", "اقلام:",
] ]
for li in quote.get("line_items") or []: for li in quote.get("line_items") or []:
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?" mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
@@ -116,7 +116,7 @@ def _accept_summary(quote: dict, chosen: int) -> str:
parts.append(f"قیمت {esc(price)}") parts.append(f"قیمت {esc(price)}")
lines.append(" — ".join(parts)) lines.append(" — ".join(parts))
lines.append("") lines.append("")
lines.append("<b>شرایط پرداخت:</b>") lines.append("شرایط پرداخت:")
for i, ts in enumerate(quote.get("termsheets") or []): for i, ts in enumerate(quote.get("termsheets") or []):
mark = "👉" if i == chosen else " " mark = "👉" if i == chosen else " "
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else "" discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
@@ -276,7 +276,7 @@ async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str: def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
lines = ["🚚 <b>اعلام رسیدن اقلام</b>", ""] lines = ["🚚 اعلام رسیدن اقلام", ""]
for li in items: for li in items:
mat = (li.get("material") or {}).get("name") or "?" mat = (li.get("material") or {}).get("name") or "?"
ordered = li.get("quantity") or 0 ordered = li.get("quantity") or 0
@@ -284,7 +284,7 @@ def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = Non
unit = ((li.get("unit") or {}).get("name_fa")) or "" unit = ((li.get("unit") or {}).get("name_fa")) or ""
got = "" got = ""
if quantities and li["id"] in quantities: if quantities and li["id"] in quantities:
got = f" → <b>الان: {esc(quantities[li['id']])} {unit}</b>" got = f" → الان: {esc(quantities[li['id']])} {unit}"
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}") lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
return "\n".join(lines) return "\n".join(lines)
@@ -57,8 +57,13 @@ async def resolve_payload(ctx, payload: dict) -> None:
if not payload.get("supplier_id") and payload.get("supplier_name"): if not payload.get("supplier_id") and payload.get("supplier_name"):
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"]) res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
if res.resolved: if res.resolved:
payload["supplier_id"] = res.candidates[0].id c = res.candidates[0]
payload["supplier_name"] = res.candidates[0].name payload["supplier_id"] = c.id
payload["supplier_name"] = c.name
extra = c.extra or {}
for k in ("location", "phone", "contact_person", "category_name"):
if extra.get(k):
payload[f"supplier_{k}"] = extra[k]
options.pop("sup", None) options.pop("sup", None)
elif res.candidates: elif res.candidates:
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates] options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
@@ -193,9 +198,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
reply_to: int | None = None) -> None: reply_to: int | None = None) -> None:
from app.config import settings from app.config import settings
from app.security import create_draft_token
payload = draft.payload payload = draft.payload
missing = compute_quote_missing(payload) missing = compute_quote_missing(payload)
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}" token = create_draft_token(
draft.id,
chat_id=chat_id,
secret=settings.DRAFT_TOKEN_SECRET,
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
)
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
text = quote_review_text(payload, missing, status_note=note) text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}" text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url) keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
@@ -297,9 +310,29 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.pending.pop(chat_id, None) ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate() ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
try:
from pathlib import Path
p_path = Path(proforma_info["saved_path"])
if p_path.exists():
file_bytes = p_path.read_bytes()
await ctx.api.upload_quote_file(
quote_id,
"proforma",
proforma_info.get("file_name") or "proforma.pdf",
file_bytes,
mime_type=proforma_info.get("mime_type") or "application/pdf",
actor_id=user.actor_id,
)
logger.info("Successfully uploaded proforma to quote %s", quote_id)
except Exception as e:
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
if draft.review_message_id: if draft.review_message_id:
try: try:
quote_id = (created or {}).get("id") or ""
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}" msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None) await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id) await draft_store.set_bale_updated_at(draft.id)
+29 -4
View File
@@ -51,12 +51,19 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل: یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
{ {
"consensus_text": "متن تجمیع‌شده و اصلاح‌شده کاربر (فارسی روان و بدون غلط شنیداری)",
"intent": یکی از {intents}, "intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده}, "patch": {شکل partial هر فعالیتی که کاربر داده},
"confidence": 0.0..1.0 "confidence": 0.0..1.0
} }
قوانین: قوانین:
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است:
۱. این نسخه‌ها از چند مدل مختلف برای افزایش دقت به دست آمده‌اند. آن‌ها را با هم مقایسه و مقابله کن.
۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازه‌گیری) تصحیح و حل تعارض کن.
۳. متن نهایی اصلاح و تجمیع‌شده را در فیلد consensus_text قرار بده.
۴. استخراج قصد (intent) و موجودیت‌ها (patch) را بر اساس همین متن دقیق انجام بده.
- اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده.
- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته. - اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft". - اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
- اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft". - اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft".
@@ -113,12 +120,28 @@ def _extract_json(raw: str) -> dict | None:
return None return None
async def understand(settings: Settings, text: str, draft_summary: str | None = None) -> dict: async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
"""Return {"intent": ..., "patch": {...}, "confidence": ...}; never raises.""" """Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"intent": "unknown", "patch": {}, "confidence": 0.0} fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0}
if not settings.AI_BASE_URL or not settings.AI_API_KEY: if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message") logger.warning("AI provider not configured; cannot understand message")
return fallback return fallback
if isinstance(text, list):
first_text = text[0].get("text") if text else ""
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"]
for idx, item in enumerate(text, start=1):
name = item.get("name") or f"مدل {idx}"
t = normalize_digits(item.get("text") or "")
lines.append(f"- نسخه {idx} ({name}): «{t}»")
lines.append("\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
user_content = "\n".join(lines)
else:
user_content = normalize_digits(str(text))
first_text = str(text)
fallback["consensus_text"] = first_text
try: try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client: async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post( resp = await client.post(
@@ -128,7 +151,7 @@ async def understand(settings: Settings, text: str, draft_summary: str | None =
"model": settings.LLM_MODEL, "model": settings.LLM_MODEL,
"messages": [ "messages": [
{"role": "system", "content": _build_system_prompt(draft_summary)}, {"role": "system", "content": _build_system_prompt(draft_summary)},
{"role": "user", "content": normalize_digits(text)}, {"role": "user", "content": user_content},
], ],
"temperature": 0.1, "temperature": 0.1,
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},
@@ -143,6 +166,8 @@ async def understand(settings: Settings, text: str, draft_summary: str | None =
logger.warning("unparseable LLM output: %r", content[:200]) logger.warning("unparseable LLM output: %r", content[:200])
return fallback return fallback
parsed.setdefault("patch", {}) parsed.setdefault("patch", {})
if not parsed.get("consensus_text"):
parsed["consensus_text"] = first_text
return parsed return parsed
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
logger.exception("NLU failure") logger.exception("NLU failure")
+282
View File
@@ -0,0 +1,282 @@
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
"""
import difflib
import time
from dataclasses import dataclass, field
from sqlalchemy import text
from app.api.client import ApiError, ErpClient
from app.db import SessionLocal
from app.nlu import normalize_text
@dataclass
class Candidate:
id: str
name: str
extra: dict = field(default_factory=dict)
@dataclass
class Resolution:
status: str # "one" | "many" | "none"
candidates: list[Candidate]
@property
def resolved(self) -> bool:
return self.status == "one"
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
q = normalize_text(query)
if not q:
return items[:limit]
scored = []
for item in items:
name = normalize_text(item.name)
ratio = difflib.SequenceMatcher(None, q, name).ratio()
if q == name:
ratio = 1.0
elif q in name:
ratio += 0.35
scored.append((ratio, item))
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
async def _db_units() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all()
return [
Candidate(
id=str(u["id"]),
name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")},
)
for u in rows
]
except Exception:
return []
async def _db_categories() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all()
return [
Candidate(
id=str(c["id"]),
name=c.get("name") or c.get("slug") or str(c["id"]),
)
for c in rows
]
except Exception:
return []
async def _db_projects() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all()
return [
Candidate(
id=str(p["id"]),
name=p.get("name") or str(p["id"]),
)
for p in rows
]
except Exception:
return []
async def _db_suppliers(q: str | None = None) -> list[Candidate]:
try:
async with SessionLocal() as s:
if q:
rows = (await s.execute(
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id "
"WHERE s.name ILIKE :q LIMIT 20"),
{"q": f"%{q}%"}
)).mappings().all()
else:
rows = (await s.execute(
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20")
)).mappings().all()
return [
Candidate(
id=str(r["id"]),
name=r.get("name") or str(r["id"]),
extra={
"location": r.get("location"),
"phone": r.get("phone"),
"contact_person": r.get("contact_person"),
"category_name": r.get("category_name"),
},
)
for r in rows
]
except Exception:
return []
async def _db_materials(q: str | None = None) -> list[Candidate]:
try:
async with SessionLocal() as s:
if q:
rows = (await s.execute(
text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"),
{"q": f"%{q}%"}
)).mappings().all()
else:
rows = (await s.execute(
text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20")
)).mappings().all()
return [
Candidate(
id=str(r["id"]),
name=r.get("name") or str(r["id"]),
extra=dict(r),
)
for r in rows
]
except Exception:
return []
class Resolver:
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
self._api = api
self._ttl = ttl_seconds
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
async def _cached(self, key: str, fetch) -> list[Candidate]:
entry = self._cache.get(key)
now = time.monotonic()
if entry and now - entry[0] < self._ttl:
return entry[1]
items = await fetch()
self._cache[key] = (now, items)
return items
def invalidate(self, key: str | None = None) -> None:
if key:
self._cache.pop(key, None)
else:
self._cache.clear()
# ---------------------------------------------------------------- sources
async def units(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._api.list_units()
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")}) for u in rows]
except Exception:
return await _db_units()
return await self._cached("units", fetch)
async def categories(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._api.list_categories()
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
except Exception:
return await _db_categories()
return await self._cached("categories", fetch)
async def projects(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._api.list_projects()
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
except Exception:
return await _db_projects()
return await self._cached("projects", fetch)
async def suppliers(self, q: str | None = None) -> list[Candidate]:
try:
rows = await self._api.list_suppliers(q=q, page_size=20)
items = [
Candidate(
id=str(s["id"]),
name=s.get("name") or str(s["id"]),
extra={
"location": s.get("location"),
"phone": s.get("phone"),
"contact_person": s.get("contact_person"),
"category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None,
},
)
for s in rows
]
except Exception:
items = await _db_suppliers(q=q)
return _rank(q, items) if q else items
async def materials(self, q: str | None = None) -> list[Candidate]:
try:
rows = await self._api.list_materials(q=q, page_size=20)
extra = {str(m["id"]): m for m in rows}
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
for m in rows]
except Exception:
items = await _db_materials(q=q)
return _rank(q, items) if q else items
# ------------------------------------------------------------ resolutions
async def resolve(self, kind: str, name: str | None) -> Resolution:
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
if not name or not name.strip():
return Resolution("none", [])
if kind == "supplier":
candidates = await self.suppliers(name)
elif kind == "material":
candidates = await self.materials(name)
elif kind == "project":
candidates = _rank(name, await self.projects())
elif kind == "unit":
candidates = _rank(name, await self.units())
elif kind == "category":
candidates = _rank(name, await self.categories())
else:
raise ValueError(f"unknown kind: {kind}")
if not candidates:
return Resolution("none", [])
norm_name = normalize_text(name)
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
if len(exact) == 1:
return Resolution("one", exact)
top = candidates[0]
top_norm = normalize_text(top.name)
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
return Resolution("one", [top])
return Resolution("many", candidates)
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
"""Return (unit_id, explicitly_resolved). Falls back to the material's
canonical unit when the spoken unit isn't found."""
if unit_name:
res = await self.resolve("unit", unit_name)
if res.resolved:
return res.candidates[0].id, True
if material and material.get("canonical_unit_id"):
return str(material["canonical_unit_id"]), False
return None, False
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
try:
return await resolver.resolve(kind, name)
except Exception:
return Resolution("none", [])
+72
View File
@@ -0,0 +1,72 @@
"""Security and token generation for standalone draft review."""
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from uuid import UUID
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(s: str) -> bytes:
padding = 4 - (len(s) % 4)
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s.encode("ascii"))
def create_draft_token(
draft_id: str | UUID,
chat_id: int | None = None,
user_id: str | None = None,
secret: str = "procurement-draft-secret-change-in-prod-v7",
expires_in: int = 7200,
) -> str:
"""Generate an HMAC-SHA256 signed token for secure draft access."""
now = int(time.time())
payload = {
"draft_id": str(draft_id),
"chat_id": chat_id,
"user_id": str(user_id) if user_id else None,
"iat": now,
"exp": now + expires_in,
}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_draft_token(
token: str,
secret: str = "procurement-draft-secret-change-in-prod-v7",
) -> dict[str, Any] | None:
"""Verify signature and expiration of a draft token. Return payload or None."""
if not token or "." not in token:
return None
try:
payload_b64, sig_b64 = token.split(".", 1)
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload_bytes = _b64url_decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
# Check expiration
exp = payload.get("exp")
if exp is not None and int(exp) < int(time.time()):
return None
return payload
except Exception:
return None
+53
View File
@@ -0,0 +1,53 @@
"""Persistent storage for draft attachments (proforma files)."""
from datetime import datetime, timezone
import logging
import os
from pathlib import Path
import re
from uuid import UUID, uuid4
logger = logging.getLogger("bot.storage")
def sanitize_filename(filename: str) -> str:
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
raw = (filename or "proforma.pdf").strip()
name = Path(raw).name
# Remove control characters and path traversal
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
clean = clean.strip(". ")
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
async def save_proforma_file(
draft_id: str | UUID,
file_name: str,
content: bytes,
mime_type: str | None = None,
storage_dir: str = "data/attachments/proformas",
) -> dict:
"""Save proforma bytes permanently to disk.
Returns dict metadata for persisting in draft payload.
"""
clean_name = sanitize_filename(file_name)
target_dir = Path(storage_dir) / str(draft_id)
target_dir.mkdir(parents=True, exist_ok=True)
unique_prefix = uuid4().hex[:8]
saved_filename = f"{unique_prefix}_{clean_name}"
target_path = target_dir / saved_filename
# Write bytes to disk
target_path.write_bytes(content)
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
return {
"file_name": clean_name,
"saved_filename": saved_filename,
"saved_path": str(target_path.resolve()),
"relative_path": str(target_path.as_posix()),
"size_bytes": len(content),
"mime_type": mime_type or "application/octet-stream",
"saved_at": datetime.now(timezone.utc).isoformat(),
}
+102
View File
@@ -0,0 +1,102 @@
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
self-hosted whisper servers exposing the same contract).
"""
import asyncio
import logging
import subprocess
import tempfile
from pathlib import Path
import httpx
from app.config import Settings
logger = logging.getLogger("bot.stt")
_CONVERT_TIMEOUT = 60
_STT_TIMEOUT = 120
def _to_mp3(raw: bytes, suffix: str) -> bytes:
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{suffix or '.ogg'}"
dst = Path(tmp) / "out.mp3"
src.write_bytes(raw)
proc = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
capture_output=True,
timeout=_CONVERT_TIMEOUT,
)
if proc.returncode != 0 or not dst.exists():
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
return dst.read_bytes()
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
name = provider.get("name") or "stt"
base_url = (provider.get("base_url") or "").rstrip("/")
api_key = provider.get("api_key") or ""
model = provider.get("model") or "whisper-1"
language = provider.get("language") or "fa"
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
url = f"{base_url}/audio/transcriptions"
try:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {api_key}"},
files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")},
data={"model": model, "language": language},
timeout=timeout,
)
if resp.status_code == 200:
txt = str((resp.json() or {}).get("text") or "").strip()
if txt:
logger.info("STT provider %s succeeded: %r", name, txt[:60])
return {"name": name, "text": txt, "model": model}
else:
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
except Exception as exc:
logger.warning("STT provider %s failed: %s", name, exc)
return None
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> list[dict]:
"""Transcribe audio with all configured STT providers in parallel.
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
Empty list if all failed or silence.
"""
providers = settings.stt_providers
if not providers:
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
suffix = Path(filename or "").suffix
if not suffix and mime_type:
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
tasks = [_call_single_stt(client, p, converted) for p in providers]
results = await asyncio.gather(*tasks, return_exceptions=True)
valid_transcripts: list[dict] = []
for r in results:
if isinstance(r, dict) and r.get("text"):
valid_transcripts.append(r)
return valid_transcripts
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> str:
"""Return the Persian transcript for a voice note (empty string on silence)."""
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
return transcripts[0]["text"] if transcripts else ""
+432
View File
@@ -0,0 +1,432 @@
"""Persian message templates (the ERP's user-facing layer is Persian)."""
import html
from datetime import date
STATUS_FA = {
"offered": "🔵 در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده",
"arrived": "✅ رسیده",
"rejected": "🔴 ردشده",
"expired": "⚪️ منقضی",
}
MISSING_MARKS = {True: "✅", False: "❌"}
WARNING_MARK = "⚠️"
def esc(value) -> str:
return str(value if value is not None and value != "" else "—")
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
gy2 = gy if gm > 2 else gy - 1
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
jy = -1595 + (33 * (days // 12053))
days %= 12053
jy += 4 * (days // 1461)
days %= 1461
if days > 365:
jy += (days - 1) // 365
days = (days - 1) % 365
if days < 186:
jm = 1 + (days // 31)
jd = 1 + (days % 31)
else:
jm = 7 + ((days - 186) // 30)
jd = 1 + ((days - 186) % 30)
return jy, jm, jd
def fa_num(val, use_fa_digits: bool = True) -> str:
"""Format integer/float with thousands separator and Persian digits."""
if val is None or val == "":
return "—"
try:
num = float(val)
if num.is_integer():
s = f"{int(num):,}"
else:
s = f"{num:,.2f}".rstrip("0").rstrip(".")
if use_fa_digits:
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
return s.translate(trans)
return s
except (ValueError, TypeError):
return str(val)
def fa_digits(val) -> str:
"""Convert ASCII digits to Persian digits without thousands separators."""
if not val:
return ""
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
return str(val).translate(trans)
def fa_date(value, use_fa_digits: bool = True) -> str:
if not value:
return "—"
s = str(value)[:10]
try:
parts = [int(p) for p in s.replace("/", "-").split("-")]
if len(parts) == 3 and parts[0] > 1900:
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
if use_fa_digits:
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
return res.translate(trans)
return res
except Exception:
pass
if use_fa_digits:
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
return s.translate(trans)
return s
WELCOME = (
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
"می‌توانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
"من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان "
"می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n"
"دستورها:\n"
"/drafts — پیش‌نویس‌های باز\n"
"/cancel — لغو پیش‌نویس/عملیات جاری\n"
"/whoami — اتصال من\n"
"/unlink — قطع اتصال\n"
"/help — راهنما"
)
HELP = (
"چه کارهایی می‌توانم بکنم؟\n\n"
"🧾 ثبت استعلام — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n"
"🏢 افزودن تأمین‌کننده — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
"📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
"📊 پرس‌وجو — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n"
"✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
"🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
"هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن."
)
LINK_START = (
"برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
)
LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو"
LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین."
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود."
DRAFTS_LIST_HEADER = "📋 پیش‌نویس‌های باز:"
DRAFTS_EMPTY = "پیش‌نویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
NOTHING_TO_COMMIT = "پیش‌نویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
CANCELLED = "لغو شد. ✔️"
UNKNOWN_HINT = (
"متوجه منظورت نشد 🤔\n"
"می‌توانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
)
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
AI_NOT_CONFIGURED = (
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
"با مدیر سیستم تماس بگیر."
)
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
ERR_GENERIC = "خطا: {detail}"
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را می‌خواهی؟"
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
ACCEPT_REPORT = (
"✅ پذیرش انجام شد\n"
"استعلام #{id}\n"
"وضعیت: {status}\n"
"اقلام تصمیم‌گیری‌شده: {count}"
)
ARRIVE_REPORT = (
"🚚 ثبت رسید انجام شد\n"
"استعلام #{id} — وضعیت: {status}\n{lines}"
)
ACCEPT_DONE_TS = "شرایط انتخاب‌شده: {label}"
def missing_block(missing: list[dict]) -> str:
"""Checklist of missing vs. captured fields."""
if not missing:
return "همه فیلدها کامل است ✅"
lines = []
for m in missing:
if m.get("ok"):
mark = "✅"
elif m.get("blocking"):
mark = "❌"
else:
mark = "⚠️"
lines.append(f"{mark} {m.get('label')}")
return "\n".join(lines)
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
lines = ["📋 پیش‌نویس استعلام"]
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
if not has_blocking and not has_warnings:
lines.append("✅ همه‌ی اطلاعات کامل است، آماده ثبت.")
elif has_blocking:
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
else:
lines.append("⚠️ پیش‌نویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
if status_note:
lines.append(status_note)
lines.append("━━━━━━━━━━━━━━\n")
# 🏢 تأمین‌کننده
lines.append("🏢 تأمین‌کننده")
sup_name = payload.get("supplier_name")
is_official = payload.get("is_official", True)
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
if payload.get("supplier_id"):
lines.append(f"{sup_name}{official_badge} ✅")
if payload.get("supplier_location"):
lines.append(f"📍 {payload['supplier_location']}")
contact_parts = []
if payload.get("supplier_phone"):
contact_parts.append(fa_digits(payload["supplier_phone"]))
if payload.get("supplier_contact_person"):
contact_parts.append(str(payload["supplier_contact_person"]))
if contact_parts:
lines.append(f"📞 {' — '.join(contact_parts)}")
elif sup_name:
lines.append(f"{sup_name}{official_badge} ✨ (تأمین‌کننده جدید)")
else:
lines.append("— (مشخص نشده)")
cat_name = payload.get("supplier_category_name")
if cat_name:
lines.append(f"\n🏷️ موضوع: {cat_name}")
# 📦 اقلام
items = payload.get("items") or []
total_amount = 0.0
has_any_price = False
if items:
lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
default_proj = payload.get("project_name")
for i, it in enumerate(items, start=1):
name = it.get("material_name") or f"قلم {i}"
title_parts = [str(name)]
if it.get("brand_name"):
title_parts.append(str(it["brand_name"]))
if it.get("specs"):
title_parts.append("، ".join(str(s) for s in it["specs"]))
if not it.get("material_id"):
title_parts.append("✨ (کالای جدید)")
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
# Project
item_proj = it.get("project_name") or default_proj
if item_proj:
lines.append(f" 🏗️ {item_proj}")
else:
lines.append(" 🏗️ پروژه مشخص نشده")
# Qty, Unit, Price, Total
qty = it.get("quantity")
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
unit_price = it.get("unit_price")
calc_line = " "
if qty is not None:
calc_line += f"{fa_num(qty)} {unit or ''}".strip()
else:
calc_line += "مقدار نامشخص"
if unit_price:
has_any_price = True
calc_line += f" × {fa_num(unit_price)}"
if qty is not None:
try:
line_sum = float(qty) * float(unit_price)
total_amount += line_sum
calc_line += f" = {fa_num(line_sum)} تومان"
except (ValueError, TypeError):
calc_line += " تومان"
else:
calc_line += " تومان"
else:
calc_line += " × — = — تومان"
lines.append(calc_line)
if has_any_price and total_amount > 0:
lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
elif not has_any_price:
lines.append("\n💰 مجموع مبلغ: محاسبه‌نشده (قیمت اقلام وارد نشده)")
else:
lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
# 💳 شرایط پرداخت
sheets = payload.get("termsheets") or []
if sheets:
lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
for s_idx, t in enumerate(sheets, start=1):
lbl = t.get("label") or f"گزینه {s_idx}"
milestones = t.get("milestones") or t.get("schedule_steps") or []
if milestones:
lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
step_sum = 0.0
for m in milestones:
due = fa_date(m.get("due_date"))
amt = m.get("amount")
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
if amt:
try:
step_sum += float(amt)
except (ValueError, TypeError):
pass
note = f" ({m['note']})" if m.get("note") else ""
lines.append(f" • {due} — {amt_str}{note}")
if total_amount > 0 and step_sum > 0:
diff = abs(total_amount - step_sum)
if diff < 1.0:
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
else:
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
if len(items) > 0:
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
else:
disc = ""
if t.get("discount_value"):
disc = f" (تخفیف {fa_num(t['discount_value'])})"
lines.append(f"▫️ {lbl}{disc}")
elif items:
lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیش‌فرض)")
# Metadata
meta_lines = []
if payload.get("quote_date"):
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
if payload.get("expiry_date"):
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
if payload.get("proforma_file"):
meta_lines.append(f"📎 پیش‌فاکتور: {payload['proforma_file']}")
if payload.get("notes"):
meta_lines.append(f"📝 توضیحات: {payload['notes']}")
if meta_lines:
lines.append("")
lines.extend(meta_lines)
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
if has_blocking or has_warnings:
lines.append("\n📋 وضعیت فیلدها:")
lines.append(missing_block(missing))
# Footer
lines.append("\n━━━━━━━━━━━━━━")
if has_blocking:
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
lines.append("با پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.")
elif has_warnings:
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
lines.append("می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.")
else:
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
lines.append("با پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.")
return "\n".join(lines)
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏢 پیش‌نویس تأمین‌کننده جدید", f"نام: {esc(payload.get('name'))}"]
lines.append(f"دسته: {esc(payload.get('category_name'))}")
if payload.get("phone"):
lines.append(f"تلفن: {esc(payload['phone'])}")
if payload.get("location"):
lines.append(f"موقعیت: {esc(payload['location'])}")
if payload.get("contact_person"):
lines.append(f"رابط: {esc(payload['contact_person'])}")
lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing))
return "\n".join(lines)
def material_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["📦 پیش‌نویس کالای جدید", f"نام: {esc(payload.get('name'))}"]
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
if payload.get("topic"):
lines.append(f"تاپیک: {esc(payload['topic'])}")
lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing))
return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup:
lines.append(f"تأمین‌کننده: {esc(sup)}")
items = created.get("line_items") or []
if items:
lines.append(f"اقلام: {len(items)}")
sheets = created.get("termsheets") or []
if sheets:
lines.append(f"شرایط پرداخت: {len(sheets)}")
if payload.get("project_id"):
lines.append("پروژه: پیوست شد")
if remaining:
lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):")
lines += [f"⚠️ {esc(r)}" for r in remaining]
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
return "\n".join(lines)
def supplier_committed_report(created: dict) -> str:
return (
f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n"
f"دسته: {esc((created.get('category') or {}).get('name'))}"
)
def material_committed_report(created: dict) -> str:
return f"✅ کالا ثبت شد — {esc(created.get('name'))}"
def quote_line(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
items = quote.get("line_items") or []
item_names = []
for it in items:
mat = (it.get("material") or {}).get("name")
if mat:
qty = it.get("quantity")
item_names.append(f"{mat} ({qty})" if qty else mat)
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
return (
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
f"{fa_date(quote.get('quote_date'))}{tail}"
)
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
titles = {
"pending": "🔵 استعلام‌های در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده (در انتظار تحویل)",
"arrived": "✅ رسیده",
"expiring": "⏳ به‌زودی منقضی می‌شوند (۷ روز آینده)",
"recent": "🕘 آخرین استعلام‌ها",
}
if not quotes:
return f"{titles.get(kind, 'نتیجه‌ای')}\nموردی نیست."
lines = [titles.get(kind, "استعلام‌ها")]
if extra_note:
lines.append(extra_note)
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
return "\n".join(lines)
+76
View File
@@ -249,6 +249,9 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks: networks:
- traefik_network - traefik_network
- default - default
@@ -347,6 +350,78 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 512m mem_limit: 512m
# ---------------------------------------------------------------------------
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
# created additively at startup) and reads ERP users/user_panels for SSO.
# ---------------------------------------------------------------------------
pm-api:
image: v15-pm-api:latest
build:
context: ./pm/backend
dockerfile: Dockerfile
container_name: pm-api
volumes:
- pm_files:/data/files
- ./pm/backend/app:/srv/pm/app
environment:
# Same database as the ERP — PM tables are additive, created at startup.
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
PM_FILES_DIR: /data/files
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
# ERP integration — in-network service URLs.
PM_ERP_BASE_URL: http://api:8000
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
pm-frontend:
image: v15-pm-frontend:latest
build:
context: ./pm/frontend
dockerfile: Dockerfile
container_name: pm-frontend
depends_on:
- pm-api
# PM platform under /pm on the same domain and the same Traefik router.
labels:
- traefik.enable=true
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
- traefik.http.routers.pm.entrypoints=web
# Higher priority than the ERP frontend Host router — otherwise /pm/api
# would reach the ERP SPA (POST → 405).
- traefik.http.routers.pm.priority=100
- traefik.http.services.pm.loadbalancer.server.port=80
networks:
- traefik_network
- default
restart: unless-stopped
mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
@@ -363,3 +438,4 @@ volumes:
postgres_data: postgres_data:
minio_data: minio_data:
traefik_certs: traefik_certs:
pm_files:
+94
View File
@@ -0,0 +1,94 @@
# گزارش گپ‌های دفتر موجودی (Inventory Ledger Gaps)
بررسی کامل منطق تراکنش‌های انبار (بک‌اند + فرانت‌اند) در جریان بازطراحی صفحه «تغییر موجودی».
**این گزارش فقط فهرست گپ‌هاست برای تصمیم‌گیری معماری — به‌جز مورد ۱، هیچ‌کدام در این نسخه اصلاح نشده‌اند.**
وضعیت‌ها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
---
## ۱. ✅ اصلاح‌شده در همین نسخه (پیش‌نیاز فیچر برگشت)
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
(`backend/app/routers/inventory.py` — list_transactions)
ستون `transaction_date` (timestamptz) با رشته‌ی خام مقایسه می‌شد. درایور asyncpg تایپ سخت‌گیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش می‌کرد. هیچ‌جای UI قدیمی این فیلترها را صدا نمی‌زد، برای همین مخفی مانده بود.
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل می‌شوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکته‌ی مرتبط: مورد ۱۲.
---
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
بک‌اند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) می‌پذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API می‌توان موجودی یک انبار را بی‌نهایت تورم داد.
UI جدید این را کنترل می‌کند (تگ `[REF:<tx-id>]` در note + محاسبه‌ی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمی‌شود.**
**پیشنهاد برای تصمیم‌گیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته می‌شود
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته می‌شود (سرویس آن را افزایش عمومی حساب می‌کند). یعنی از مسیر API می‌توان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساخته‌شده توسط خود سرویس `transfer_pair_id` دارد.
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفت‌سازی داخلی سرویس ثبت شود).
## ۴. 🟡 create انتقال بین‌کارگاهی «ردیف انبار» نمی‌گیرد
`POST /inventory/project-transfers` به‌جای `inventory_row_id` فقط material/brand/unit/specs می‌گیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر می‌کند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
## ۵. 🟡 Audit Trail ناقص است
فقط `POST /inventory/transactions` رکورد audit می‌نویسد (`inventory.py:491-500`). این‌ها audit ندارند:
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بین‌کارگاهی
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
- CRUD انبارها و تغییرات کاربران
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمی‌کند).
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال می‌شوند
- `delivered_to` (تحویل‌گیرنده برداشت) در UI اجباری است؛ API بدون آن 201 می‌دهد.
- ظرفیت‌سنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
در بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیف‌های ۱..k-1 **موفق ثبت‌شده** در جدول باقی می‌مانند → retry کاربر = ثبت تکراری. (در انتقال بین‌کارگاهی و برگشتِ این نسخه، ردیف‌های موفق از لیست حذف می‌شوند — الگوی درست.)
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
## ۸. 🟡 برگشت‌های قدیمی بدون REF قابل انتساب دقیق نیستند
برگشت‌های ثبت‌شده قبل از این نسخه هیچ اشاره‌ای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشت‌های هم‌ردیف (متریال+برند+مشخصات+واحد) جبران می‌کند، ولی این حدس است و در دو حالت خطا دارد:
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمی‌شود.
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحه‌های بعد را جابه‌جا کند.
**پیشنهاد (داده):** اسکریپت یک‌باره‌ی backfill — تطبیق برگشت‌های قدیمی با برداشت‌های همان ردیف (قدیمی‌ترین برداشتِ باقیمانده) و درج REF در note.
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC می‌برد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC می‌افتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich می‌کند). UI فعلی از آن استفاده نمی‌کند ولی برای دیباگ/گزارش‌ها گمراه‌کننده است.
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشی‌های لیست
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی می‌شوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمی‌شود (محدودیت قدیمی، در لیست‌های جدید این صفحه هم از همان دیتا می‌خواند). لیست برگشت و مفقودی‌ها صفحه‌بندی/واکشی کامل دارند.
## ۱۲. 🔵 لیست انتقال‌های بین‌کارگاهی بدون صفحه‌بندی
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همه‌ی ردیف‌ها را بدون pagination برمی‌گردانند؛ با رشد داده بار پاسخ زیاد می‌شود.
---
## چیزهایی که بررسی شد و درست است ✅
- **Immutable Ledger در سطح API:** برای تراکنش‌ها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بین‌کارگاهی است که تراکنش خروجی خودش را بازمی‌گرداند و موجودی را ترمیم می‌کند.
- **سقف FOUND در بک‌اند اعمال می‌شود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامت‌دار صحیح.
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمی‌شود.
- **دریافت انتقال بین‌کارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتب‌شده‌ی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title> <title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+390
View File
@@ -28,6 +28,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -1366,6 +1367,32 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1714,6 +1741,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2003,6 +2042,69 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2033,6 +2135,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2127,6 +2235,133 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2155,6 +2390,24 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2201,6 +2454,25 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "11.1.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2645,6 +2917,36 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-is": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -2752,6 +3054,57 @@
} }
} }
}, },
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -2884,6 +3237,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2970,6 +3329,37 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "دفترچه تأمین — سامانه تدارکات و انبار", "name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "دفترچه تأمین", "short_name": "مدیریت پروژه و تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی", "description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl", "dir": "rtl",
"lang": "fa", "lang": "fa",
"start_url": "/", "start_url": "/",
+295 -9
View File
@@ -38,11 +38,18 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
); );
const OrganizationPage = lazy(() => const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
); );
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
); );
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجت‌های Power-BI-style
const MvpDashboardPage = lazy(() =>
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
);
function Loading() { function Loading() {
return ( return (
@@ -56,6 +63,7 @@ function Loading() {
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const location = useLocation(); const location = useLocation();
const pathname = location.pathname;
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است // در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
@@ -68,14 +76,249 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
const returnUrl = encodeURIComponent(location.pathname + location.search); const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />; return <Navigate to={`/login?returnUrl=${returnUrl}`} 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}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر // صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
function HomeRoute() { function HomeRoute() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />; const panels = user?.panels ?? {};
return <DashboardPage />; 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() { export default function App() {
@@ -83,6 +326,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route path="/review" element={<WizardPage isStandalone />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
path="/" path="/"
@@ -96,10 +340,13 @@ export default function App() {
path="/wizard" path="/wizard"
element={ element={
<RequireAuth> <RequireAuth>
<WizardPage /> <RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth> </RequireAuth>
} }
/> />
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route <Route
path="/suppliers" path="/suppliers"
element={ element={
@@ -143,6 +390,17 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route <Route
path="/units" path="/units"
element={ element={
@@ -176,7 +434,9 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<ChangeInventoryPage /> <RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
@@ -196,18 +456,24 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<WarehousesPage /> <RequireWarehouseManage>
<WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
<Route <Route
path="/inventory/org" path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequireOrganization>
<OrganizationPage /> <OrganizationPage />
</RequirePmProjects> </RequireOrganization>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -219,6 +485,26 @@ export default function App() {
</RequireAuth> </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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>

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