46 Commits
Author SHA1 Message Date
whqreza 1b5fc589fd fix(bot): eliminate duplicate review cards caused by sync race and edit fallback 2026-09-29 01:05:24 +03:30
whqreza a2e7c27e85 feat(bot): support concurrent drafts and reply-based edits with error resilience 2026-09-29 00:48:08 +03:30
whqreza 09245cdc2c revert(stt): remove STT configuration and keep bot in text-only mode 2026-09-29 00:10:57 +03:30
whqreza dc31f837fd feat(docker): add procurement-bot service with Groq STT and compose integration 2026-09-29 00:04:56 +03:30
whqreza 57e0e51c16 merge: incorporate origin/production updates into prod-v7 2026-09-28 23:40:24 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

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

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

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

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

Build args: VITE_OPENPANEL_URL (API base, e.g. https://stats.<host>/api),
VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats.<host>/op1.js).
The client *secret* is deliberately not a frontend arg — it belongs to the API only.
2026-09-21 09:13:27 +00:00
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00
ZCode 2fd72fecf7 feat(dashboard): drop the category filter
The category filter is not used: remove it end to end — the toolbar's category
combobox (desktop + mobile drawer), its option list, the active-filter count and
the filterQuotes() clause it drove, plus the now-unused `category` field in the
Filters type. Supplier and status filters are unchanged.

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

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

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

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

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

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

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

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

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

Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
  served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
  hangs when the package mirror is unreachable (502s)
2026-09-20 20:35:16 +03:30
MMD e637d80b76 Merge latest prod_v2 (FileList preview modal, EditModal tabs) into integration
prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).

- FileList: kept prod_v2's design (icons, preview modal, per-row actions)
  and adapted it — previews now fetch the bytes with a Bearer token and
  use a local objectURL (api.files.blobUrl), with revoke on close/unmount;
  the row download uses the authenticated onDownload.
- EditModal: took prod_v2's tabbed redesign wholesale (it already carried
  our globalSpecs, clean material name and target_project work) and
  converted its FileList to onDownload + loadPreview.
- AcceptModal: converted the remaining urlOf sites and removed duplicated
  file/date blocks the earlier merge had left outside the scroll body.
- api.ts: restored the global handle401 (definition + all four fetch
  sites) alongside the new blobUrl helper.
2026-09-20 14:23:19 +03:30
MMD b6cbcc30a5 Merge prod_v5 (catalog v2.7, warehouse locking, per-project accept) into integration
Conflict in init_db.py resolved keeping the audit_log table ensure
function (new-one) — prod_v5 predates it and its side was empty.
Both sides' SQL lands: audit_log table plus the category-free materials
model.
2026-09-19 18:36:29 +03:30
MMD 08ec341c52 Merge prod_v2 (AcceptModal/MilestoneRow layout) into integration
Conflicts resolved keeping the hardened/current side:
- docker-compose.yml: kept new-one's db-backup service, hardened env
  (ENVIRONMENT/DEV_LOGIN/CORS/issuer var) and build args; prod_v2's
  8083/8085 dev port remaps dropped as they conflict with the hardened
  Casdoor issuer expectation.
- AcceptModal.tsx: kept the per-item target_project model (43ff048) over
  prod_v2's older projUnion candidate-project logic, kept the
  authenticated file upload/download blocks, and adopted prod_v2's
  sticky footer.
2026-09-19 18:36:08 +03:30
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.

Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.

Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.

Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.

Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
2026-09-19 15:11:57 +03:30
whqreza b1edf6eed3 feat(inventory)!: lock warehouses permanently to their parent project
Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.

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

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

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

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

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

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

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

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
MMD ca55192311 docs(monitoring): Docker Desktop local-run notes (host-metrics profile) 2026-09-19 01:03:29 +03:30
MMD 948727d73b fix(monitoring): Linux-only exporters behind host-metrics profile; loki 3.5 key; prometheus joins shared network
- node-exporter + cadvisor need Linux rootfs mounts (fail on Docker
  Desktop) — moved to 'host-metrics' profile; VPS command updated
- loki 3.5: ingestion_burstsize_mb → ingestion_burst_size_mb
- prometheus joins erp_shared so job 'erp-api' resolves (verified: up=1)
2026-09-19 01:02:09 +03:30
MMD e1495afee9 fix(auth): auth deps use their own DB session; smoke suites made dataset-independent
RUNTIME BUG (caught by live smoke tests): the global auth dependency
queried the request-scoped session, auto-beginning its transaction — any
endpoint opening 'async with db.begin()' afterwards crashed with
'transaction already begun' (POST /materials 500). get_current_user /
get_actor now resolve in a short-lived session (expire_on_commit=False
keeps the context usable detached); inventory/pm scopes keep passing the
request session explicitly.

Smoke suites rewritten against CURRENT contracts:
- assertions are behavior-based (shapes, envelopes, filters), no magic
  seed counts; dynamic lookups instead of hard-coded names/ids
- rerun-tolerant: tolerate 409-on-create by reusing prior artifacts
- aligned with current rules: revert blocked after inventory receipts
  (v2.6), attachments allowed in every status, termsheets optional at
  quote level
- SMOKE_USER/SMOKE_PASSWORD env for login (default tester/123456)

Verified on a fresh volume: migration + audit_log bootstrap, Casdoor
seed (bcrypt), demo data, and 29+76+61 smoke checks all green.
2026-09-19 00:49:15 +03:30
MMD e03d94207b fix(compose): duplicate networks block on api service; overridable NPM_REGISTRY build arg 2026-09-19 00:11:24 +03:30
MMD 8eddb74951 chore(frontend): lockfile for @openpanel/web + @sentry/react (verified: tsc -b && vite build pass) 2026-09-18 23:54:11 +03:30
MMD 9364915be0 docs: operations runbook, README verify section, frontend env example
- docs/OPERATIONS.md: deploy flow + rollback, health endpoints, backup/
  restore procedure, audit queries, monitoring stack routine
- README: verification now points at readiness/metrics endpoints and the
  authenticated smoke tests (bare quotes curl is 401 by design)
- frontend/.env.example: observability vars documented
2026-09-18 22:30:16 +03:30
MMD 32323565e6 feat(hardening): login rate limiting, per-service memory limits, pinned images
- slowapi limiter on POST /auth/login (10/min per IP, Persian error msg);
  edge-level coarse limits stay in Traefik
- mem_limit on every main-compose service (~4.4GB worst case, protects the
  8GB shared host during rebuilds)
- monitoring images pinned to verified tags (prom v3.4.1, grafana 11.6.3,
  loki/promtail 3.5.0, node-exporter v1.9.1, postgres-exporter v0.17.1,
  cadvisor v0.49.1); exporter image name fixed to postgres-exporter
2026-09-18 22:27:59 +03:30
MMD e5d6c77c3c feat(data-safety): scheduled DB backups + audit trail + verified actor attribution
- db-backup sidecar: pg_dump -Fc of procurement+casdoor every
  BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS
- audit_log table (migration.sql + runtime ensure_audit_table) with
  model + transactional helper (utils/audit.record) — wired into user
  create/role-change and inventory transaction creation
- get_actor: quote_events.actor now stores the VERIFIED user's display
  name instead of the raw bearer token
2026-09-18 22:23:34 +03:30
MMD e17d1f0883 feat(monitoring): observability stack + app instrumentation
- /health/ready (DB+MinIO+Casdoor checks, webhook post-deploy gate),
  /metrics via prometheus-fastapi-instrumentator, Sentry/GlitchTip capture,
  X-Request-ID middleware + JSON access logging (PROCUREMENT_LOG_FORMAT)
- server-side analytics (OpenPanel Track API): login, quote lifecycle,
  receipts — fire-and-forget, disabled without env config
- monitoring/ compose project: GlitchTip(+pg), Prometheus(+alerts),
  Grafana(provisioned ERP dashboard), Loki+Promtail, node/postgres
  exporters, cAdvisor — mem-limited (~2.2GB), loopback admin UIs,
  separate from app deploys; erp_shared network for scraping
- frontend: @openpanel/web + @sentry/react (build-arg gated, no-op when
  unset), funnel events (login, wizard steps, quote submitted)
- compose: api healthcheck, observability env wiring, .env.example keys
2026-09-18 22:18:02 +03:30
MMD f7b1f7f751 feat(security): JWT required on every API route, gated dev fallback, role checks
- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
2026-09-18 22:02:09 +03:30
MMD 43e317eeba chore(security): remove committed secrets/dumps, .env-based config, hardened deploy webhook
- Delete backups/*.dump and all real credentials from README/docs
  (prod MinIO creds, Casdoor passwords, server IP, admin password)
- All stack secrets now come from git-ignored .env (.env.example added);
  docker-compose.prod.yml refuses to boot without them (${VAR:?})
- seed_casdoor.py: bcrypt password hashing, generated console/client secrets
- Harden infra/deploy-webhook: HMAC-verified Gitea webhook receiver
  + systemd unit + README (replaces unauthenticated deploy endpoint)
- docs/SECRET-MANAGEMENT.md: secret inventory + rotation checklist
2026-09-18 21:55:24 +03:30
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.

- new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids
- replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached
- delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials
- line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material
- storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object)

Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
2026-09-17 18:04:57 +03:30
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows

Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])

Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread

Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.

Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
2026-09-17 16:13:27 +03:30
160 changed files with 10590 additions and 2345 deletions
+71
View File
@@ -0,0 +1,71 @@
# ============================================================
# ERP stack configuration — copy to .env and fill in real values.
# .env is git-ignored; NEVER commit real credentials.
# Local dev: cp .env.example .env (defaults work as-is)
# Production VPS: /srv/erp/.env with STRONG values for every entry —
# docker-compose.prod.yml refuses to boot without them.
# ============================================================
# --- Public hosts (production; local defaults live in docker-compose.yml) ---
APP_HOST=aramesh.bennu-api-bahrestan.ir
API_HOST=api.bennu-api-bahrestan.ir
AUTH_HOST=casdoor.bennu-api-bahrestan.ir
S3_HOST=s3.bennu-api-bahrestan.ir
STORAGE_HOST=storage.bennu-api-bahrestan.ir
# --- Traefik / Let's Encrypt ---
TRAEFIK_ACME_EMAIL=you@example.com
# --- Environment: "prod" enables the fail-fast startup checks in the API ---
ENVIRONMENT=prod
# --- PostgreSQL (app + Casdoor share the instance) ---
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
# --- MinIO object storage root credentials ---
MINIO_ROOT_USER=CHANGE_ME_minio_user
MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password
# --- Casdoor OIDC application (must match the Casdoor application record) ---
CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret
CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password
# --- CORS: exact browser origins allowed to call the API (comma-separated) ---
CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
# --- Developer Mode (X-User-* header identity + login panel) ---
# MUST stay false in production. Only for local debugging without tokens.
DEV_LOGIN_ENABLED=false
# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) ---
GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret
# --- Backups (db-backup sidecar; see docker-compose.yml) ---
BACKUP_DIR=/srv/erp/backups
BACKUP_INTERVAL_SECONDS=86400
BACKUP_KEEP_DAYS=14
# --- Monitoring stack (see monitoring/README.md) ---
# API log format for Loki ingestion ("text" locally, "json" in production)
LOG_FORMAT=json
# GlitchTip (error tracking). GLITCHTIP_HOST must be a public DNS name —
# the browser SDK posts errors there.
GLITCHTIP_SECRET_KEY=CHANGE_ME_random_django_secret
GLITCHTIP_DOMAIN=https://errors.bennu-api-bahrestan.ir
GLITCHTIP_HOST=errors.bennu-api-bahrestan.ir
GLITCHTIP_DB_PASSWORD=CHANGE_ME_glitchtip_db_password
GLITCHTIP_FROM_EMAIL=noreply@bennu-api-bahrestan.ir
GLITCHTIP_EMAIL_URL=consolemail://
# Grafana (dashboards) — UI is loopback-only (:3001) unless you add Traefik labels
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password
GRAFANA_ROOT_URL=http://localhost:3001/
# OpenPanel (product analytics) — client id/secret from the OpenPanel project
OPENPANEL_API_URL=http://openpanel-api:3333
OPENPANEL_CLIENT_ID=
OPENPANEL_CLIENT_SECRET=
# Public origin of the OpenPanel dashboard/API the BROWSER talks to
# (e.g. https://stats.bennu-api-bahrestan.ir — see monitoring/README.md)
OPENPANEL_PUBLIC_URL=
# Sentry/GlitchTip DSNs created in the GlitchTip UI after first boot
SENTRY_DSN_BACKEND=
SENTRY_DSN_FRONTEND=
+14
View File
@@ -11,13 +11,27 @@ __pycache__/
venv/
# ---- Env / secrets ----
# All credentials live in git-ignored .env files (server-side and local).
# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md.
.env
.env.*
!*.env.example
backend/.env
frontend/.env
*.local
# ---- Database dumps (contain user rows + signing material) ----
*.dump
*.sql.gz
# ---- Local session / backups ----
.zcode/
.zcodeignore
scratch/
v1.2 - Copy/
inv dash/
bot/
voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml
+43 -49
View File
@@ -105,8 +105,8 @@ for production builds).
| What | Where | Credentials |
|---|---|---|
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | `admin` / `123` |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details —
@@ -143,7 +143,7 @@ is an SSH tunnel from your machine:
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
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
@@ -151,18 +151,17 @@ is an SSH tunnel from your machine:
2. In your browser, open: **http://localhost:9001**
3. Log in with:
| Username | Password |
|---|---|
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ These credentials are **full admin access to the file storage**. Treat
them as a production secret (this README is inside the repo, so anyone with
repo access can read them — rotate them on the server if they ever leak).
⚠️ Those credentials are **full admin access to the file storage**. They live
only in the server-side `.env`; if one ever leaks, rotate it immediately
(checklist in `docs/SECRET-MANAGEMENT.md`).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel.
@@ -215,15 +214,23 @@ All should show `Up` (postgres also `(healthy)`).
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
| Metrics | http://localhost:8000/metrics |
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
Quick end-to-end check (Traefik → frontend proxy → API → DB):
Automated end-to-end checks (authenticates via `SMOKE_USER` /
`SMOKE_PASSWORD` — the seeder users):
```powershell
curl http://localhost/api/v1/quotes?expand=supplier,project,line_items,termsheets,events,decision,receipt
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
```
> Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
---
## MinIO (object storage for invoice / proforma files)
@@ -262,47 +269,33 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
---
## Restoring the committed seed data
## Recovery / seeding
The databases are **not** in git — Docker volumes hold the data. The repo
ships recovery dumps in `backups/علی محقق/`:
The databases are **not** in git — Docker volumes hold the data, and
database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up:
| File | Contents |
|---|---|
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders:
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
On a running stack:
```powershell
docker compose stop api casdoor
docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
docker compose start casdoor api
```
Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
in usernames, so «ali mohaghegh» is not a valid login name).
Want demo data instead? Two seeders are still available (they create
different users — see the Login table above):
1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Casdoor seeder** (creates org `inventory` + app + users razaei /
mousavi / mohaghegh / tester — run once after a fresh volume):
2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
```powershell
python backend/tools/seed_casdoor.py # from the host, or from the api container
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
To create a fresh dump of your own current state: stop `api` + `casdoor`,
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc`
(+ the `casdoor` DB) into `backups/`, then start them again.
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations
@@ -317,10 +310,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol
## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`,
`/inventory` and `/pm` are protected (401 without a token, 403 without a role).
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still
open — dev placeholder, do not expose to the public internet.
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers.
+28
View File
@@ -0,0 +1,28 @@
# Bale Bot API — token from Bale's BotFather (required)
PROCUREMENT_BOT_BOT_TOKEN=
PROCUREMENT_BOT_BALE_API_BASE=https://tapi.bale.ai
# ERP procurement API (in Docker: http://api:8000/api/v1)
PROCUREMENT_BOT_API_BASE_URL=http://localhost:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/procurement
# LLM provider — any OpenAI-compatible endpoint (JSON-mode chat completions)
PROCUREMENT_BOT_AI_BASE_URL=https://api.deepseek.com
PROCUREMENT_BOT_AI_API_KEY=
PROCUREMENT_BOT_LLM_MODEL=deepseek-flash
# STT provider — must expose POST /audio/transcriptions.
# Falls back to AI_* when unset. DeepSeek has no audio API, so voice needs
# its own whisper-compatible provider (OpenAI, GLM, self-hosted faster-whisper…).
PROCUREMENT_BOT_STT_BASE_URL=
PROCUREMENT_BOT_STT_API_KEY=
PROCUREMENT_BOT_STT_MODEL=whisper-1
PROCUREMENT_BOT_STT_LANGUAGE=fa
# Bale chat ids allowed without Casdoor linking (comma separated)
PROCUREMENT_BOT_ADMIN_CHAT_IDS=
# Optional bearer for the day the ERP starts enforcing auth on procurement routes
PROCUREMENT_BOT_SERVICE_TOKEN=
# Drafts older than this are swept into `expired`
PROCUREMENT_BOT_DRAFT_TTL_HOURS=24
PROCUREMENT_BOT_LOG_LEVEL=INFO
+14
View File
@@ -0,0 +1,14 @@
# ---- Env / secrets ----
.env
*.local
# ---- Python ----
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# ---- OS junk ----
.DS_Store
Thumbs.db
+23
View File
@@ -0,0 +1,23 @@
# Bale procurement bot — a long-polling worker against tapi.bale.ai.
# ffmpeg converts Bale voice notes (OGG/Opus) before they are sent to STT.
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv/bot
# Dependencies first (layer caching). PIP_INDEX_URL overridable like the api image.
ARG PIP_INDEX_URL=https://pypi.org/simple
COPY requirements.txt .
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
# Application
COPY app ./app
COPY tools ./tools
CMD ["python", "-m", "app.main"]
+120
View File
@@ -0,0 +1,120 @@
# procurement_ai_agent — Bale chatbot for the procurement ERP
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
into procurement actions in the
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
draft-first: it captures what it understood, tells you **what it did** and
**what is still empty**, and **commits only after you approve** with a ✅ button.
```
you (Bale, text/voice) ┌─────────────────────────────────┐
│ getUpdates (long poll) │ procurement_ai_agent │
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
└───────────────┬─────────────────┘
│ HTTP + X-Actor-Id
▼
┌─────────────────────────────────┐
│ ERP api (/api/v1) │
│ quotes · suppliers · materials│
└─────────────────────────────────┘
```
## What it can do
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
extracts a typed **intent + entities** (JSON mode, Persian digits
normalized).
2. Names are fuzzy-matched against the ERP. Several candidates → inline
**pick buttons**; no match → listed as missing.
3. The bot replies with a **review card**: everything captured + a
✅/⚠️/❌ list of what is still empty.
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
created quote id and the fields left empty.
### Team access & attribution
Each member links their Bale chat once: `/start` → Casdoor username → password
(validated via the ERP's real `POST /auth/login`, used once, never stored).
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked.
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
## Quick start (Docker)
```bash
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
docker build -t procurement-ai-agent .
docker run -d --name procurement-bot --env-file .env \
--add-host host.docker.internal:host-gateway \
procurement-ai-agent
docker logs -f procurement-bot
```
Or as part of the ERP stack — see [`integration/`](integration/README.md)
(compose service + the one tiny ERP-side patch).
Full setup, env-var reference, flows and troubleshooting:
**[docs/SETUP.md](docs/SETUP.md)**.
## Architecture
```
app/
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
├── texts.py Persian message templates
├── runtime.py context, link store, in-memory pending flows
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
└── handlers/ dispatch · commands · callbacks · links ·
quote · supplier · material · queries · decisions
```
Design notes:
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
no public port, no webhook, resumable offset persisted across restarts.
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
converted into the exact `POST /quotes` request body (temp_id
cross-references) only on approval; the API deliberately tolerates
incomplete quotes, so partial commits are explicit, never accidental.
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
STT. The LLM provider and the STT provider are configured independently
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
## Testing
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
pipeline with fake updates — no token, DB or API key needed:
```bash
pip install -r requirements.txt
python tools/smoke_test.py # → ALL PASSED: 48 passed
```
+2
View File
@@ -0,0 +1,2 @@
"""Bale procurement chatbot — drafts ERP actions from chat text/voice,
reports what it captured + what is missing, and commits only on approval."""
+1
View File
@@ -0,0 +1 @@
"""ERP API client — the only way the bot touches procurement data."""
+137
View File
@@ -0,0 +1,137 @@
"""Async client for the procurement API (/api/v1).
Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so
``quote_events.actor`` records the human who asked, not the bot. A service
bearer token is sent when configured (procurement endpoints are currently
open — the slot is for the day auth lands).
"""
import logging
from typing import Any
import httpx
logger = logging.getLogger("bot.api")
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, field: str | None = None):
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.field = field
class ErpClient:
def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""):
self._base = base_url.rstrip("/")
self._timeout = timeout
self._service_token = service_token
def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict:
headers: dict[str, str] = {}
if self._service_token:
headers["Authorization"] = f"Bearer {self._service_token}"
if actor_id:
headers["X-Actor-Id"] = actor_id
return headers
async def _request(self, method: str, path: str, *, actor_id: str | None = None,
json: Any = None, params: dict | None = None) -> Any:
url = f"{self._base}{path}"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.request(
method, url, json=json, params=params, headers=self._actor_headers(actor_id, None)
)
except httpx.HTTPError as exc:
raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc
if resp.status_code in (200, 201):
return resp.json()
try:
err = resp.json().get("error") or {}
except ValueError:
err = {}
raise ApiError(
resp.status_code,
str(err.get("code") or "INTERNAL_ERROR"),
str(err.get("message") or f"HTTP {resp.status_code}"),
err.get("field"),
)
# ------------------------------------------------------------------ auth
async def login(self, username: str, password: str) -> dict:
body = await self._request("POST", "/auth/login", json={"username": username, "password": password})
return body.get("data") or {}
# ---------------------------------------------------------------- lookup
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
async def list_materials(self, q: str | None = None, page_size: int = 20) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/materials", params=params)).get("data") or []
async def create_material(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or []
async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/units", params=params)).get("data") or []
async def list_projects(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
async def list_categories(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
# ---------------------------------------------------------------- quotes
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
expand: str | None = None, sort: str | None = None,
page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]:
params: dict = {"page": page, "page_size": page_size}
if status:
params["status"] = status
if q:
params["q"] = q
if expand:
params["expand"] = expand
if sort:
params["sort"] = sort
body = await self._request("GET", "/quotes", params=params)
return body.get("data") or [], body.get("meta") or {}
async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict:
params = {"expand": expand} if expand else None
return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {}
async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {}
async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
path = f"/quotes/{quote_id}/accept"
return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {}
async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {}
async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {}
async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict:
return (await self._request(
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
)).get("data") or {}
+1
View File
@@ -0,0 +1 @@
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
+186
View File
@@ -0,0 +1,186 @@
"""Thin async client for the Bale Bot API.
Bale's bot surface is a Telegram Bot API clone: ``POST {base}/bot{token}/{method}``
and file downloads at ``{base}/file/bot{token}/{file_path}``. Long polling via
``getUpdates`` avoids any webhook/TLS setup on the server.
HTML parse_mode is requested first; if Bale rejects it for a given payload we
transparently retry as plain text so messages never get lost.
"""
import asyncio
import logging
import httpx
logger = logging.getLogger("bot.bale")
REPLY_MARKUP = "reply_markup"
class BaleError(Exception):
def __init__(self, description: str, error_code: int | None = None):
super().__init__(description)
self.description = description
self.error_code = error_code
class BaleClient:
def __init__(self, base_url: str, token: str, timeout: float = 60.0):
self._base = base_url.rstrip("/")
self._token = token
self._timeout = timeout
# ------------------------------------------------------------------ core
def _method_url(self, method: str) -> str:
return f"{self._base}/bot{self._token}/{method}"
def _file_url(self, file_path: str) -> str:
return f"{self._base}/file/bot{self._token}/{file_path.lstrip('/')}"
async def _call(self, method: str, payload: dict | None = None, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or self._timeout) as client:
try:
resp = await client.post(self._method_url(method), json=payload or {})
except httpx.HTTPError as exc:
raise BaleError(f"network error calling {method}: {exc}") from exc
try:
body = resp.json()
except ValueError as exc:
raise BaleError(f"non-JSON response for {method} (HTTP {resp.status_code})") from exc
if not body.get("ok"):
raise BaleError(str(body.get("description") or "unknown bale error"), resp.status_code)
return body.get("result")
# ------------------------------------------------------------- receiving
async def get_updates(self, offset: int, timeout_seconds: int) -> list[dict]:
"""Long poll. ``timeout_seconds=0`` returns immediately (drain mode)."""
result = await self._call(
"getUpdates",
{"offset": offset, "timeout": timeout_seconds, "allowed_updates": ["message", "callback_query"]},
timeout=timeout_seconds + 15,
)
return result or []
# -------------------------------------------------------------- sending
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
reply_to: int | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "text": text[:4000], "parse_mode": "HTML"}
if reply_markup:
payload[REPLY_MARKUP] = reply_markup
if reply_to:
payload["reply_to_message_id"] = reply_to
try:
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,
reply_markup: dict | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000], "parse_mode": "HTML"}
if reply_markup:
payload[REPLY_MARKUP] = reply_markup
try:
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:
payload: dict = {"chat_id": chat_id, "message_id": message_id}
try:
return bool(await self._call("deleteMessage", payload))
except BaleError as exc:
logger.warning("deleteMessage failed for chat %s msg %s: %s", chat_id, message_id, exc)
return False
except Exception as exc:
logger.warning("unexpected error in deleteMessage for chat %s msg %s: %s", chat_id, message_id, exc)
return False
async def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None:
payload: dict = {"callback_query_id": callback_query_id}
if text:
payload["text"] = text[:200]
try:
await self._call("answerCallbackQuery", payload)
except BaleError as exc:
logger.warning("answerCallbackQuery failed: %s", exc)
async def send_chat_action(self, chat_id: int, action: str = "typing") -> None:
try:
await self._call("sendChatAction", {"chat_id": chat_id, "action": action})
except BaleError as exc:
logger.debug("sendChatAction failed: %s", exc)
# ---------------------------------------------------------------- files
async def get_file(self, file_id: str) -> dict:
return await self._call("getFile", {"file_id": file_id})
async def download_file(self, file_id: str) -> bytes:
"""Resolve a file_id to bytes (getFile → file_path → download URL)."""
info = await self.get_file(file_id)
file_path = info.get("file_path")
if not file_path:
raise BaleError("getFile returned no file_path")
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(self._file_url(file_path))
resp.raise_for_status()
return resp.content
async def poll_loop(client: BaleClient, on_update, offset_key: str = "bale_offset") -> None:
"""Long-poll supervisor: resumable offset, one update at a time, never dies.
``on_update`` must be an async callable receiving the raw update dict.
"""
from app.db import SessionLocal
from app.models import BotMeta
async def load_offset() -> int:
async with SessionLocal() as session:
row = (await session.execute(
__import__("sqlalchemy").select(BotMeta.value).where(BotMeta.key == offset_key)
)).scalar()
return int(row) if row else 0
async def save_offset(value: int) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
async with SessionLocal() as session:
stmt = pg_insert(BotMeta).values(key=offset_key, value=str(value))
stmt = stmt.on_conflict_do_update(index_elements=[BotMeta.key], set_={"value": str(value)})
await session.execute(stmt)
await session.commit()
offset = await load_offset()
if offset == 0:
# Skip any backlog accumulated while the bot was down: peek once
# without waiting and jump straight to the newest update.
backlog = await client.get_updates(0, 0)
if backlog:
offset = max(u["update_id"] for u in backlog) + 1
logger.info("polling started (offset=%s)", offset)
while True:
try:
updates = await client.get_updates(offset, 25)
for update in updates:
offset = max(offset, update["update_id"] + 1)
await save_offset(offset)
try:
await on_update(update)
except Exception:
logger.exception("handler crashed on update %s", update.get("update_id"))
except asyncio.CancelledError:
raise
except BaleError as exc:
logger.warning("poll error: %s", exc)
await asyncio.sleep(3)
except Exception:
logger.exception("unexpected poll error")
await asyncio.sleep(3)
+77
View File
@@ -0,0 +1,77 @@
"""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()
+7
View File
@@ -0,0 +1,7 @@
"""Async engine/session for the bot's own tables (same postgres as the api)."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+81
View File
@@ -0,0 +1,81 @@
"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py
convention — the ORM never creates tables; this script does, IF NOT EXISTS).
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Base
logger = logging.getLogger("bot.db_init")
DDL = [
"""
CREATE TABLE IF NOT EXISTS bot_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT UNIQUE NOT NULL,
user_id UUID,
casdoor_username TEXT,
display_name TEXT,
role VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)",
"""
CREATE TABLE IF NOT EXISTS bot_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
missing JSONB,
status VARCHAR(16) NOT NULL DEFAULT 'active',
source VARCHAR(16),
source_message_id BIGINT,
review_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)",
"""
CREATE TABLE IF NOT EXISTS bot_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
direction VARCHAR(4) NOT NULL,
kind VARCHAR(16) NOT NULL DEFAULT 'text',
body TEXT,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
"""
CREATE TABLE IF NOT EXISTS bot_meta (
key VARCHAR(64) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
]
async def ensure_bot_tables(session: AsyncSession) -> None:
for stmt in DDL:
await session.execute(text(stmt))
await session.commit()
logger.info("bot tables ready")
async def ensure_schema() -> None:
from app.db import SessionLocal
async with SessionLocal() as session:
await ensure_bot_tables(session)
+474
View File
@@ -0,0 +1,474 @@
"""Draft store + payload/missing-field logic.
A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When
the user approves, ``build_quote_payload`` converts it into the exact
``POST /quotes`` request body (temp_ids cross-referencing line items,
termsheets and prices).
Resolution state lives inside the payload:
payload["supplier_id"], payload["project_id"],
item["material_id"], item["unit_id"], item["unit_explicit"],
payload["options"][field] = candidate list shown as pick-buttons.
"""
import difflib
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import cast, func, select, String, update
from app.db import SessionLocal
from app.models import BotDraft
from app.nlu import normalize_text
KIND_QUOTE = "quote"
KIND_SUPPLIER = "supplier"
KIND_MATERIAL = "material"
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
async def create(chat_id: int, kind: str, payload: dict, source: str | None = None,
source_message_id: int | None = None) -> BotDraft:
async with SessionLocal() as session:
draft = BotDraft(
chat_id=chat_id,
kind=kind,
payload=payload,
source=source,
source_message_id=source_message_id,
)
session.add(draft)
await session.commit()
await session.refresh(draft)
return draft
async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None:
async with SessionLocal() as session:
stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
if kind:
stmt = stmt.where(BotDraft.kind == kind)
stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None:
"""Find the active draft associated with a given message_id (review card or user source message)."""
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(
BotDraft.chat_id == chat_id,
BotDraft.status == "active",
(BotDraft.review_message_id == message_id) | (BotDraft.source_message_id == message_id),
)
.order_by(BotDraft.updated_at.desc())
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None:
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
async with SessionLocal() as session:
stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%"))
if chat_id is not None:
stmt = stmt.where(BotDraft.chat_id == chat_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def save_payload(draft: BotDraft, missing: dict | None = None,
review_message_id: int | None = None,
source_message_id: int | None = None,
mark_bale_synced: bool = False) -> None:
async with SessionLocal() as session:
effective_review_msg_id = review_message_id if review_message_id is not None else draft.review_message_id
values: dict = {
"payload": draft.payload,
"missing": missing,
"review_message_id": effective_review_msg_id,
}
if source_message_id is not None:
values["source_message_id"] = source_message_id
draft.source_message_id = source_message_id
if mark_bale_synced:
values["last_bale_update_at"] = func.now()
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft.id)
.values(**values)
)
await session.commit()
draft.review_message_id = effective_review_msg_id
async def set_status(draft_id: str, status: str) -> None:
async with SessionLocal() as session:
await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status))
await session.commit()
async def set_bale_updated_at(draft_id) -> None:
async with SessionLocal() as session:
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft_id)
.values(last_bale_update_at=func.now())
)
await session.commit()
async def get_pending_bale_updates() -> list[BotDraft]:
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(
BotDraft.review_message_id.is_not(None),
(BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at),
)
.order_by(BotDraft.updated_at.asc())
.limit(10)
)
return list((await session.execute(stmt)).scalars().all())
async def expire_stale(ttl_hours: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours)
async with SessionLocal() as session:
result = await session.execute(
update(BotDraft)
.where(BotDraft.status == "active", BotDraft.updated_at < cutoff)
.values(status="expired")
)
await session.commit()
return result.rowcount or 0
# ---------------------------------------------------------------------------
# Quote payload
# ---------------------------------------------------------------------------
def new_quote_payload() -> dict:
return {
"supplier_name": None, "supplier_id": None,
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
"is_official": True, "notes": None, "expiry_date": None,
"items": [], "termsheets": [],
"options": {}, "proforma_file": None,
}
def new_item(material_name: str | None = None) -> dict:
return {
"material_name": material_name, "material_id": None,
"quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False,
"unit_price": None, "brand_name": None, "specs": [], "notes": None,
}
def _similarity(a: str | None, b: str | None) -> float:
na, nb = normalize_text(a or ""), normalize_text(b or "")
if not na or not nb:
return 0.0
ratio = difflib.SequenceMatcher(None, na, nb).ratio()
if na in nb or nb in na:
ratio += 0.3
return ratio
def merge_quote_patch(payload: dict, patch: dict) -> bool:
"""Merge an NLU patch into the payload. Returns True if anything changed."""
changed = False
def set_field(key: str, value) -> None:
nonlocal changed
if value is None:
return
if payload.get(key) != value:
payload[key] = value
changed = True
if patch.get("supplier_name"):
payload["supplier_name"] = str(patch["supplier_name"]).strip()
payload["supplier_id"] = None # needs re-resolution
changed = True
if patch.get("project_names"):
payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p]
if payload["project_names"] and not payload.get("project_name"):
payload["project_name"] = payload["project_names"][0]
payload["project_id"] = None
changed = True
elif patch.get("project_name"):
payload["project_name"] = str(patch["project_name"]).strip()
payload["project_id"] = None
if not payload.get("project_names"):
payload["project_names"] = [payload["project_name"]]
changed = True
set_field("is_official", patch.get("is_official"))
if patch.get("notes"):
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
changed = True
if patch.get("expiry_date"):
set_field("expiry_date", patch["expiry_date"])
for raw in patch.get("items") or []:
name = (raw.get("material_name") or "").strip() or None
target = None
if name:
best, best_ratio = None, 0.0
for item in payload["items"]:
# If both items have distinct specs, they are different line items
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
continue
# If both items have distinct brands, they are different line items
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
continue
ratio = _similarity(name, item.get("material_name"))
if ratio > best_ratio:
best, best_ratio = item, ratio
if best is not None and best_ratio >= 0.55:
target = best
if target is None:
target = new_item(name)
payload["items"].append(target)
changed = True
for key in ("quantity", "unit_price"):
if raw.get(key) is not None:
try:
value = float(raw[key])
except (TypeError, ValueError):
continue
if target.get(key) != value:
target[key] = value
changed = True
if raw.get("specs"):
target["specs"] = [str(s).strip() for s in raw["specs"] if s]
spec_str = "، ".join(target["specs"])
if spec_str and not target.get("notes"):
target["notes"] = spec_str
changed = True
for key in ("unit_name", "brand_name", "notes"):
if raw.get(key):
val = str(raw[key]).strip()
if target.get(key) != val:
target[key] = val
changed = True
for raw in patch.get("payment_terms") or []:
label = (raw.get("label") or "شرایط پرداخت").strip()
milestones = []
for m in raw.get("milestones") or []:
if m.get("due_date") or m.get("amount"):
milestones.append({
"due_date": m.get("due_date"),
"amount": float(m.get("amount") or 0),
"note": m.get("note"),
})
entry = {
"label": label,
"discount_type": raw.get("discount_type") or "none",
"discount_value": float(raw.get("discount_value") or 0),
"milestones": milestones,
"description": (raw.get("description") or "").strip() or None,
}
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
payload["termsheets"].append(entry)
changed = True
return changed
def compute_quote_missing(payload: dict) -> list[dict]:
"""What is still empty — the user's explicit ask («یادآوری چیزهای خالی»)."""
out: list[dict] = []
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
sup_ok = bool(payload.get("supplier_id"))
add("supplier", "تأمین‌کننده", sup_ok, True)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
items = payload.get("items") or []
add("items", "قلم (کالا/مقدار)", bool(items), True)
for i, item in enumerate(items, start=1):
name = item.get("material_name") or f"قلم {i}"
if not item.get("material_id"):
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True)
if not item.get("quantity"):
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
if not (item.get("unit_id") or item.get("unit_name")):
add(f"item:{i}:unit", f"واحد «{name}»", False, True)
if not item.get("unit_price"):
add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False)
if items and not payload.get("termsheets"):
add("terms", "شرایط پرداخت", False, False)
if not payload.get("notes"):
add("notes", "یادداشت", False, False)
return out
def quote_ready(payload: dict) -> tuple[bool, list[str]]:
"""Blocking check before commit; returns (ok, blocking_labels)."""
missing = compute_quote_missing(payload)
return all(not (m["blocking"] and not m["ok"]) for m in missing), [
m["label"] for m in missing if m["blocking"] and not m["ok"]
]
def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]:
"""Convert the payload into a QuoteCreate body. Returns (body, problems)."""
problems: list[str] = []
if not payload.get("supplier_id"):
return None, ["تأمین‌کننده مشخص نیست"]
items = payload.get("items") or []
line_items: list[dict] = []
for i, item in enumerate(items, start=1):
if not item.get("material_id"):
if only_resolved_items:
continue
problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد")
continue
if not item.get("quantity"):
problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
continue
unit_id = item.get("unit_id")
if not unit_id:
problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
continue
notes_parts = []
if item.get("brand_name"):
notes_parts.append(f"برند: {item['brand_name']}")
if item.get("specs"):
notes_parts.append("، ".join(item["specs"]))
if item.get("notes"):
if item["notes"] not in notes_parts:
notes_parts.append(item["notes"])
notes_str = " | ".join(notes_parts) if notes_parts else None
line_items.append({
"temp_id": f"t{i}",
"material_id": item["material_id"],
"unit_id": int(unit_id),
"quantity": item["quantity"],
"notes": notes_str,
})
termsheets: list[dict] = []
item_termsheets: list[dict] = []
priced = [it for it in items if it.get("unit_price") and it.get("material_id")]
if priced:
raw_sheets = payload.get("termsheets") or []
if not raw_sheets:
raw_sheets = [{"label": "پیشنهاد قیمت"}]
for j, ts in enumerate(raw_sheets, start=1):
steps = []
for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1):
steps.append({
"step_no": s_idx,
"due_date": m.get("due_date"),
"amount": float(m.get("amount") or 0),
"note": m.get("note"),
})
termsheets.append({
"temp_id": f"p{j}",
"label": ts.get("label") or f"شرایط {j}",
"discount_type": ts.get("discount_type") or "none",
"discount_value": float(ts.get("discount_value") or 0),
"schedule_steps": steps,
})
first = termsheets[0]["temp_id"]
for i, item in enumerate(items, start=1):
if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"):
item_termsheets.append({
"line_item_temp_id": f"t{i}",
"termsheet_temp_id": first,
"unit_price": item["unit_price"],
})
body: dict = {
"supplier_id": payload["supplier_id"],
"is_official": bool(payload.get("is_official", True)),
"line_items": line_items,
"termsheets": termsheets,
"item_termsheets": item_termsheets,
}
if payload.get("project_ids"):
body["project_ids"] = payload["project_ids"]
elif payload.get("project_id"):
body["project_ids"] = [payload["project_id"]]
if payload.get("expiry_date"):
body["expiry_date"] = payload["expiry_date"]
if payload.get("notes"):
body["notes"] = payload["notes"]
if not line_items and problems:
return None, problems
return body, problems
# ---------------------------------------------------------------------------
# Supplier / material payloads
# ---------------------------------------------------------------------------
def new_supplier_payload() -> dict:
return {"name": None, "category_id": None, "category_name": None,
"phone": None, "location": None, "contact_person": None, "options": {}}
def merge_supplier_patch(payload: dict, patch: dict) -> bool:
src = patch.get("supplier") or patch
changed = False
for key in ("name", "phone", "location", "contact_person"):
if src.get(key) and payload.get(key) != str(src[key]).strip():
payload[key] = str(src[key]).strip()
changed = True
if src.get("category_name"):
payload["category_name"] = str(src["category_name"]).strip()
payload["category_id"] = None
changed = True
return changed
def supplier_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام تأمین‌کننده", "ok": bool(payload.get("name")), "blocking": True},
{"key": "category", "label": "دسته‌بندی", "ok": bool(payload.get("category_id")), "blocking": True},
{"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False},
{"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False},
]
def new_material_payload() -> dict:
return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}}
def merge_material_patch(payload: dict, patch: dict) -> bool:
src = patch.get("material") or patch
changed = False
if src.get("name") and payload.get("name") != str(src["name"]).strip():
payload["name"] = str(src["name"]).strip()
changed = True
if src.get("unit_name"):
payload["unit_name"] = str(src["unit_name"]).strip()
payload["canonical_unit_id"] = None
changed = True
if src.get("topic") and payload.get("topic") != str(src["topic"]).strip():
payload["topic"] = str(src["topic"]).strip()
changed = True
return changed
def material_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True},
{"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True},
{"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False},
]
def short_id() -> str:
return uuid.uuid4().hex[:8]
+215
View File
@@ -0,0 +1,215 @@
"""Update dispatch — the heart of the bot.
Pipeline per update:
callback_query → callbacks router (link-gated)
message text starting "/" → command router
voice/audio → download → STT → treated as text (source=voice)
text → pending-flow interception (linking / accept-arrive confirmations)
→ auth gate (link or admin)
→ NLU → intent router
"""
import logging
from app import drafts as draft_store
from app.config import settings
from app.nlu import understand
from app.runtime import BotContext, LinkedUser
from app.stt import transcribe
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY
from . import commands, callbacks, quote, supplier, material, queries, decisions
logger = logging.getLogger("bot.handlers")
async def handle_update(ctx: BotContext, update: dict) -> None:
if update.get("callback_query"):
await callbacks.handle_callback(ctx, update["callback_query"])
return
message = update.get("message") or update.get("edited_message")
if not message:
return
chat_id = message["chat"]["id"]
try:
await handle_message(ctx, chat_id, message)
except Exception:
logger.exception("message handling failed for chat %s", chat_id)
await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.")
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or "").strip()
source = "text"
if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text)
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.
flow = ctx.pending.get(chat_id)
if flow:
handled = await _route_to_flow(ctx, chat_id, flow, text)
if handled:
return
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
user = await _gate(ctx, chat_id, message)
if user is None:
return # gate either started linking or is mid-linking
message_id = message.get("message_id")
reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
target_draft = None
if reply_to_msg_id is not None:
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None:
await ctx.send(
chat_id,
"⚠️ پیش‌نویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.",
reply_to=message_id,
)
return
draft_summary = _draft_summary(target_draft) if target_draft else None
result = await understand(settings, text, draft_summary)
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)
if intent in ("register_quote", "update_draft"):
await quote.handle_entities(
ctx, chat_id, user, patch, source,
incoming_msg_id=message_id,
target_draft=target_draft,
)
elif intent == "commit_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, active)
elif intent == "discard_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
if active.review_message_id:
try:
await ctx.delete(chat_id, active.review_message_id)
except Exception:
pass
ctx.pending.pop(chat_id, None)
await ctx.send(chat_id, CANCELLED, reply_to=message_id)
elif intent == "show_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE:
await quote.render(ctx, chat_id, active, fresh=False)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.render(ctx, chat_id, active)
elif intent == "add_supplier":
await supplier.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "add_material":
await material.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote":
await decisions.start_accept(ctx, chat_id, user, patch)
elif intent == "arrive_items":
await decisions.start_arrive(ctx, chat_id, user, patch)
else:
await ctx.send(chat_id, UNKNOWN_HINT)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
"""Link or admin gate. Returns None when the message was consumed by linking."""
from . import links
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
return None
if flow and flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
return None
await ctx.send(chat_id, LINK_START)
ctx.pending.set(chat_id, "link_username", {})
return None
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
from . import links, decisions
if flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, text)
return True
if flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
return True
if flow.kind == "quote_pick":
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
return True
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
return False
def _draft_summary(draft) -> str | None:
if draft is None:
return None
import json
payload = draft.payload or {}
if draft.kind == draft_store.KIND_QUOTE:
items = ", ".join(
f"{it.get('material_name') or '?'}"
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
for it in payload.get("items") or []
)
summary = (
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
)
elif draft.kind == draft_store.KIND_SUPPLIER:
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
elif draft.kind == draft_store.KIND_MATERIAL:
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
else:
summary = json.dumps(payload, ensure_ascii=False)[:500]
return summary
+107
View File
@@ -0,0 +1,107 @@
"""Inline-button routing (callback_query).
Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx}
/ ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows.
"""
import logging
from app import drafts as draft_store
from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc
from . import quote, supplier, material, decisions
logger = logging.getLogger("bot.callbacks")
async def handle_callback(ctx, cq: dict) -> None:
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
data = cq.get("data") or ""
if chat_id is None:
return
try:
await _dispatch(ctx, chat_id, data, cq)
except Exception:
logger.exception("callback failed: %s", data)
await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.")
finally:
await ctx.bale.answer_callback_query(cq.get("id"), None)
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
await ctx.send(chat_id, UNLINKED)
return None
async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
user = await _gate(ctx, chat_id)
if user is None:
return
parts = data.split(":")
# ---- pending-flow buttons (accept / arrive / quote pick) ---------------
if parts[0] == "qc" and len(parts) == 2:
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "quote_pick":
await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1]))
return
if parts[0] == "ac":
await decisions.handle_accept_callback(ctx, chat_id, user, parts)
return
if parts[0] == "ar":
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
return
# ---- pick a candidate for a draft field -------------------------------
if parts[0] == "pk" and len(parts) == 4:
draft = await draft_store.get_by_prefix(parts[1], chat_id)
if draft is None:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3]))
return
# ---- draft commit / cancel --------------------------------------------
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None or draft.kind != kind:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
if arg == "no":
await draft_store.set_status(draft.id, "cancelled")
ctx.pending.pop(chat_id, None)
if draft.review_message_id:
try:
await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیش‌نویس لغو شد.", reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
await ctx.send(chat_id, CANCELLED)
return
if kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
elif kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, draft)
else:
await material.commit(ctx, chat_id, user, draft)
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
options = (draft.payload.get("options") or {}).get(field) or []
if idx < 0 or idx >= len(options):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
choice = options[idx]
if draft.kind == draft_store.KIND_QUOTE:
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_SUPPLIER:
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_MATERIAL:
await material.apply_pick(ctx, chat_id, user, draft, field, choice)
+82
View File
@@ -0,0 +1,82 @@
"""/commands — /start /help /drafts /cancel /whoami /unlink."""
import logging
from app import drafts as draft_store
from app.runtime import ROLE_FA
from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc
logger = logging.getLogger("bot.commands")
KIND_FA = {"quote": "استعلام", "supplier": "تأمین‌کننده", "material": "کالا"}
STATUS_FA = {"active": "باز", "committed": "ثبت‌شده", "cancelled": "لغوشده", "expired": "منقضی"}
async def handle_command(ctx, chat_id: int, text: str) -> None:
command = text.split()[0].split("@")[0].lower()
if command == "/start":
await _start(ctx, chat_id)
elif command in ("/help", "/راهنما"):
await ctx.send(chat_id, HELP)
elif command == "/drafts":
await _drafts(ctx, chat_id)
elif command in ("/cancel", "/لغو"):
await _cancel(ctx, chat_id)
elif command == "/whoami":
await _whoami(ctx, chat_id)
elif command == "/unlink":
await ctx.links.delete(chat_id)
await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.")
else:
await ctx.send(chat_id, HELP)
async def _start(ctx, chat_id: int) -> None:
await ctx.send(chat_id, WELCOME)
link = await ctx.links.get(chat_id)
if not link or not link.user_id:
if chat_id not in ctx.admin_chat_ids:
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
ctx.pending.set(chat_id, "link_username", {})
async def _drafts(ctx, chat_id: int) -> None:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import BotDraft
async with SessionLocal() as session:
rows = (await session.execute(
select(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
.order_by(BotDraft.updated_at.desc())
)).scalars().all()
if not rows:
await ctx.send(chat_id, DRAFTS_EMPTY)
return
lines = [DRAFTS_LIST_HEADER]
for d in rows:
p = d.payload or {}
title = p.get("supplier_name") or p.get("name") or "بدون عنوان"
lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}")
await ctx.send(chat_id, "\n".join(lines))
async def _cancel(ctx, chat_id: int) -> None:
ctx.pending.pop(chat_id)
active = await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
await ctx.send(chat_id, CANCELLED)
async def _whoami(ctx, chat_id: int) -> None:
link = await ctx.links.get(chat_id)
if chat_id in ctx.admin_chat_ids:
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
return
if not link or not link.user_id:
await ctx.send(chat_id, UNLINKED)
return
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
await ctx.send(chat_id, f"اتصال: <b>{esc(link.display_name)}</b> — {role}")
+389
View File
@@ -0,0 +1,389 @@
"""Accept / arrive flows — always with an explicit confirmation step."""
import logging
import re
from app.api.client import ApiError
from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard
from app.nlu import normalize_digits
from app.texts import (
ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC,
QUOTE_NOT_FOUND, STATUS_FA, esc,
)
logger = logging.getLogger("bot.decisions")
EXPAND = "supplier,line_items,termsheets"
CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"}
CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"}
def _norm(text: str) -> str:
return (text or "").strip().lower().replace("\u200c", " ")
# ---------------------------------------------------------------------------
# shared: pick a quote by supplier text
# ---------------------------------------------------------------------------
async def start_accept(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered")
async def start_arrive(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted")
async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None:
target = (patch.get("target") or {})
q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id")
try:
quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if not quotes:
await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?")))
return
if len(quotes) == 1:
if mode == "accept":
await begin_accept(ctx, chat_id, user, quotes[0])
else:
await begin_arrive(ctx, chat_id, user, quotes[0])
return
pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes]
flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]})
flow.data["user_id"] = user.actor_id
message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را می‌خواهی؟", reply_markup=quote_pick_keyboard(pairs))
flow.data["message_id"] = (message or {}).get("message_id")
def _quote_label(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or "—"
return f"#{str(quote.get('id'))[:8]} {sup}"
async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None:
ids = flow.data.get("ids") or []
ctx.pending.pop(chat_id)
if idx < 0 or idx >= len(ids):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
try:
quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if flow.data.get("mode") == "arrive":
await begin_arrive(ctx, chat_id, user, quote)
else:
await begin_accept(ctx, chat_id, user, quote)
async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None:
if _norm(text) in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
numbers = re.findall(r"\d+", normalize_digits(text))
if numbers:
await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1)
else:
await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمه‌ها استفاده کن.")
def _flow_user(flow):
from app.runtime import LinkedUser
return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None,
role=None, is_admin=False)
# ---------------------------------------------------------------------------
# accept
# ---------------------------------------------------------------------------
def _accept_summary(quote: dict, chosen: int) -> str:
lines = [
f"✔️ <b>پذیرش استعلام</b> #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
"",
"<b>اقلام:</b>",
]
for li in quote.get("line_items") or []:
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
unit = ((li.get("unit") or {}).get("name_fa")) or ""
price = li.get("unit_price")
parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"]
if price:
parts.append(f"قیمت {esc(price)}")
lines.append(" — ".join(parts))
lines.append("")
lines.append("<b>شرایط پرداخت:</b>")
for i, ts in enumerate(quote.get("termsheets") or []):
mark = "👉" if i == chosen else " "
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله")
lines.append("")
lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.")
return "\n".join(lines)
async def begin_accept(ctx, chat_id, user, quote: dict) -> None:
termsheets = quote.get("termsheets") or []
if not termsheets:
await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.")
return
if not quote.get("line_items"):
await ctx.send(chat_id, "این استعلام قلم ندارد.")
return
flow = ctx.pending.set(chat_id, "accept", {
"quote_id": str(quote["id"]),
"quote_snapshot": {
"id": quote.get("id"),
"supplier": quote.get("supplier"),
"line_items": quote.get("line_items") or [],
},
"termsheets": termsheets,
"chosen": 0,
"user_id": user.actor_id,
})
text = _accept_summary(quote, 0)
message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets]))
flow.data["message_id"] = (message or {}).get("message_id")
async def handle_accept_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or flow.kind != "accept":
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "ts" and len(parts) == 3:
idx = int(parts[2])
if 0 <= idx < len(flow.data["termsheets"]):
flow.data["chosen"] = idx
await ctx.edit(chat_id, flow.data.get("message_id"),
_accept_summary(flow.data["quote_snapshot"], idx),
reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]]))
return
if op == "ok":
await _commit_accept(ctx, chat_id, user, flow)
async def _commit_accept(ctx, chat_id, user, flow) -> None:
data = flow.data
quote_id = data["quote_id"]
termsheets = data["termsheets"]
ts_index = data.get("chosen", 0)
if not 0 <= ts_index < len(termsheets):
await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.")
return
ts = termsheets[ts_index]
steps = ts.get("schedule_steps") or []
if not steps:
await ctx.send(chat_id, "ترم‌شیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.")
return
try:
quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
decisions_payload = []
problems = []
for li in quote.get("line_items") or []:
decision = li.get("decision") or {}
if decision.get("status") == "rejected":
continue
price = li.get("unit_price")
if not price:
problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده")
continue
li_projects = li.get("projects") or []
project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id")
decisions_payload.append({
"line_item_id": li["id"],
"status": "chosen",
"accepted_termsheet_index": 0,
"adjusted_unit_price": price,
"project_id": project_id,
})
if problems:
await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems))
return
if not decisions_payload:
await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شده‌اند؟).")
return
payload = {
"accepted_termsheets": [{
"id": ts["id"],
"label": ts.get("label"),
"discount_type": ts.get("discount_type") or "none",
"discount_value": ts.get("discount_value") or 0,
"schedule_steps": steps,
}],
"item_decisions": decisions_payload,
}
try:
result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
chosen_label = ts.get("label") or ""
await ctx.send(chat_id, ACCEPT_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
count=len(decisions_payload),
) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label)))
async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
if flow.kind == "accept":
norm = _norm(text)
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_accept(ctx, chat_id, _flow_user(flow), flow)
return True
await ctx.send(chat_id, "برای پذیرش از دکمه‌ها استفاده کن (یا /cancel).")
return True
if flow.kind == "arrive_qty":
await _handle_arrive_quantities(ctx, chat_id, flow, text)
return True
if flow.kind == "arrive_confirm":
norm = _norm(text)
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_arrive(ctx, chat_id, _flow_user(flow), flow)
return True
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
await ctx.send(chat_id, "با دکمه‌ها تأیید یا لغو کن (یا /cancel).")
return True
return False
# ---------------------------------------------------------------------------
# arrive (partial deliveries)
# ---------------------------------------------------------------------------
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
lines = ["🚚 <b>اعلام رسیدن اقلام</b>", ""]
for li in items:
mat = (li.get("material") or {}).get("name") or "?"
ordered = li.get("quantity") or 0
received = ((li.get("receipt") or {}).get("received_qty")) or 0
unit = ((li.get("unit") or {}).get("name_fa")) or ""
got = ""
if quantities and li["id"] in quantities:
got = f" → <b>الان: {esc(quantities[li['id']])} {unit}</b>"
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
return "\n".join(lines)
async def begin_arrive(ctx, chat_id, user, quote: dict) -> None:
items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"]
if not items:
await ctx.send(chat_id, "قلم پذیرفته‌شده‌ای برای تحویل نیست.")
return
flow = ctx.pending.set(chat_id, "arrive_qty", {
"quote_id": str(quote["id"]),
"items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?",
"ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "",
"received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items],
"user_id": user.actor_id,
})
text = (
f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n"
+ _arrive_summary(quote.get("line_items") or [])
+ "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن."
)
message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard())
flow.data["message_id"] = (message or {}).get("message_id")
async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None:
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))]
items = flow.data["items"]
if not numbers:
await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمه‌ها استفاده کن.")
return
if len(numbers) == 1 and len(items) > 1:
await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).")
return
if len(numbers) != len(items):
await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمی‌خواند؛ دوباره بفرست.")
return
quantities = {item["id"]: qty for item, qty in zip(items, numbers)}
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
def _items_with_details(items: list[dict]) -> list[dict]:
return [{
"id": it["id"],
"material": {"name": it["name"]},
"quantity": it["ordered"],
"unit": {"name_fa": it["unit"]},
"receipt": {"received_qty": it["received"]},
} for it in items]
async def handle_arrive_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or not flow.kind.startswith("arrive"):
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "full":
quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]}
if any(v <= 0 for v in quantities.values()):
await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.")
return
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
return
if op == "ok":
if flow.kind != "arrive_confirm" or not flow.data.get("quantities"):
await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.")
return
await _commit_arrive(ctx, chat_id, user, flow)
async def _commit_arrive(ctx, chat_id, user, flow) -> None:
quantities: dict = flow.data["quantities"]
payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]}
try:
result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
lines = []
for item in result.get("items") or []:
lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد")
await ctx.send(chat_id, ARRIVE_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
lines="\n".join(lines) or "—",
))
+46
View File
@@ -0,0 +1,46 @@
"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint)."""
import logging
from app.api.client import ApiError
from app.runtime import ROLE_FA
from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc
logger = logging.getLogger("bot.links")
async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None:
username = (text or "").strip()
if not username or username.startswith("/"):
ctx.pending.pop(chat_id)
return
flow.kind = "link_password"
flow.data["username"] = username
await ctx.send(chat_id, LINK_ASK_PASSWORD)
async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None:
username = flow.data.get("username") or ""
ctx.pending.pop(chat_id)
if not password or password.startswith("/"):
return
try:
data = await ctx.api.login(username, password)
except ApiError as exc:
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message)))
return
except Exception as exc: # network etc.
logger.exception("login failed")
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200])))
return
user = data.get("user") or {}
display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username
await ctx.links.upsert(
chat_id=chat_id,
user_id=user.get("id"),
username=username,
display_name=display,
role=user.get("role"),
)
role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش")
await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa))
+98
View File
@@ -0,0 +1,98 @@
"""Add-material flow: name + canonical unit (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
logger = logging.getLogger("bot.material")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None:
payload = draft.payload
merge_material_patch(payload, patch)
else:
payload = new_material_payload()
merge_material_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name
options.pop("unit", None)
elif res.candidates:
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("unit", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "unit":
draft.payload["canonical_unit_id"] = choice["id"]
draft.payload["unit_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("unit", None)
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("unit") or []
if options:
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = material_missing(payload)
text = material_review_text(payload, missing)
keyboard = _keyboard(payload, draft.id)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard)
draft.review_message_id = (message or {}).get("message_id")
await draft_store.save_payload(draft, missing={"fields": missing},
review_message_id=draft.review_message_id)
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
async def commit(ctx, chat_id, user, draft) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")),
("unit", "واحد اصلی", missing.get("unit")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
if payload.get("topic"):
body["topic"] = payload["topic"]
try:
created = await ctx.api.create_material(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("materials")
await ctx.send(chat_id, material_committed_report(created))
+44
View File
@@ -0,0 +1,44 @@
"""Status queries: «استعلام‌های در انتظار؟»، «کدام‌ها منقضی می‌شوند؟» …"""
import logging
from datetime import date, timedelta
from app.api.client import ApiError
from app.texts import ERR_GENERIC, status_report, esc
logger = logging.getLogger("bot.queries")
EXPAND = "supplier,line_items"
async def handle_query(ctx, chat_id, user, patch: dict) -> None:
query = patch.get("query") or {}
kind = query.get("kind") or "pending"
supplier_name = query.get("supplier_name")
q = supplier_name or query.get("project_name")
try:
if kind == "expiring":
quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND,
q=q, sort="-quote_date", page_size=100)
deadline = date.today() + timedelta(days=query.get("days") or 7)
soon = []
for quote in quotes:
expiry = (quote.get("expiry_date") or "")[:10]
if expiry and expiry <= deadline.isoformat():
soon.append(quote)
soon.sort(key=lambda x: x.get("expiry_date") or "9999")
await ctx.send(chat_id, status_report("expiring", soon[:10]))
return
if kind in ("accepted", "arrived", "pending", "offered"):
status = "offered" if kind == "pending" else kind
quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q,
sort="-quote_date", page_size=10)
await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes))
return
# recent / anything else
quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5)
await ctx.send(chat_id, status_report("recent", quotes))
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
+316
View File
@@ -0,0 +1,316 @@
"""Register-quote flow: entities → draft → resolve → review (+missing) → commit."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
new_quote_payload, quote_ready,
)
from app.keyboards import quote_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
logger = logging.getLogger("bot.quote")
FIELD_FA = {"sup": "تأمین‌کننده", "prj": "پروژه"}
async def handle_entities(
ctx, chat_id, user, patch: dict, source: str | None,
incoming_msg_id: int | None = None,
target_draft = None,
) -> None:
prev_review_msg_id = None
if target_draft is not None:
draft = target_draft
prev_review_msg_id = draft.review_message_id
draft.review_message_id = None
payload = draft.payload
merge_quote_patch(payload, patch)
draft.source_message_id = incoming_msg_id
else:
payload = new_quote_payload()
merge_quote_patch(payload, patch)
draft = await draft_store.create(
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
)
await resolve_payload(ctx, payload)
if prev_review_msg_id:
try:
await ctx.delete(chat_id, prev_review_msg_id)
except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
# ---------------------------------------------------------------------------
# resolution
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("supplier_id") and payload.get("supplier_name"):
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
if res.resolved:
payload["supplier_id"] = res.candidates[0].id
payload["supplier_name"] = res.candidates[0].name
options.pop("sup", None)
elif res.candidates:
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("sup", None)
if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
if res.resolved:
payload["project_id"] = res.candidates[0].id
payload["project_name"] = res.candidates[0].name
if payload["project_id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(payload["project_id"])
options.pop("prj", None)
elif res.candidates:
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("prj", None)
for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
p_id = res.candidates[0].id
if p_id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(p_id)
if not payload.get("project_id"):
payload["project_id"] = p_id
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"):
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
options.pop(f"mat{i}", None)
elif res.candidates:
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop(f"mat{i}", None)
if not item.get("unit_id"):
await _resolve_unit(ctx, item)
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
item["material_id"] = material_id
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
async def _resolve_unit(ctx, item: dict) -> None:
if item.get("unit_id"):
return
if item.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
if res.resolved:
item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True
item["unit_name"] = res.candidates[0].name
return
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
if canonical:
item["unit_id"] = str(canonical)
item["unit_explicit"] = False
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
payload = draft.payload
options = payload.setdefault("options", {})
options.pop(field, None)
if field == "sup":
payload["supplier_id"] = choice["id"]
payload["supplier_name"] = choice["name"]
elif field == "prj":
payload["project_id"] = choice["id"]
payload["project_name"] = choice["name"]
elif field.startswith("mat"):
index = int(field[3:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["material_name"] = choice["name"]
_set_material(items[index], choice["id"], choice["name"])
await _resolve_unit(ctx, items[index])
elif field.startswith("unit"):
index = int(field[4:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["unit_id"] = choice["id"]
items[index]["unit_explicit"] = True
items[index]["unit_name"] = choice["name"]
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=False)
# ---------------------------------------------------------------------------
# rendering
# ---------------------------------------------------------------------------
def _pick_rows(payload: dict) -> list[list[str]]:
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
rows: list[list[str]] = []
options = payload.get("options") or {}
if options.get("sup"):
rows.append(["sup"] + [o["name"] for o in options["sup"]])
if options.get("prj"):
rows.append(["prj"] + [o["name"] for o in options["prj"]])
for i, item in enumerate(payload.get("items") or [], start=1):
if options.get(f"mat{i}"):
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
if options.get(f"unit{i}"):
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
return rows
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
from app.keyboards import pick_buttons
prefix = str(draft_id)[:8]
pick_rows: list[list[dict]] = []
for row in _pick_rows(payload):
field, names = row[0], row[1:]
pick_rows += pick_buttons(prefix, field, names[:6])
ready_items = sum(
1 for it in payload.get("items") or []
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
)
has_partial = 0 < ready_items < len(payload.get("items") or [])
return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url)
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
reply_to: int | None = None) -> None:
from app.config import settings
payload = draft.payload
missing = compute_quote_missing(payload)
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}"
text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard, reply_to=reply_to)
new_review_id = (message or {}).get("message_id")
if new_review_id:
draft.review_message_id = new_review_id
await draft_store.save_payload(
draft,
missing={"fields": missing},
review_message_id=new_review_id,
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
await draft_store.save_payload(
draft,
missing={"fields": missing},
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
if not draft.review_message_id:
return
success = await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
if success:
await draft_store.save_payload(draft, missing={"fields": missing}, mark_bale_synced=True)
else:
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
await draft_store.set_bale_updated_at(draft.id)
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
# 1. Sequential pre-creation for new supplier if user wants to commit
if not payload.get("supplier_id") and payload.get("supplier_name"):
cat_id = payload.get("supplier_category_id")
if not cat_id:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
if cat_id:
try:
sup_body = {
"name": payload["supplier_name"],
"category_id": cat_id,
}
if payload.get("supplier_phone"):
sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"]
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s", e)
# 2. Sequential pre-creation for new materials
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
try:
created_mat = await ctx.api.create_material({
"name": item["material_name"],
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
ctx.resolver.invalidate("materials")
except Exception as e:
logger.warning("Could not auto-create material: %s", e)
ready, blockers = quote_ready(payload)
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
if body is None:
lines = ["ثبت ممکن نیست؛ این‌ها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
await ctx.send(chat_id, "\n".join(lines))
return
if not ready and not only_resolved:
lines = ["برای ثبت کامل، اول این‌ها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
await ctx.send(chat_id, "\n".join(lines))
return
try:
created = await ctx.api.create_quote(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
except Exception:
logger.exception("create_quote failed")
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
return
await draft_store.set_status(draft.id, "committed")
ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate()
if draft.review_message_id:
try:
quote_id = (created or {}).get("id") or ""
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
remaining = [
m["label"] for m in compute_quote_missing(payload)
if not m["blocking"] and not m["ok"]
]
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
if dropped > 0:
remaining.append(f"{dropped} قلم ناشناس حذف شد")
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
+103
View File
@@ -0,0 +1,103 @@
"""Add-supplier flow: name + one of the 8 fixed Persian categories (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
logger = logging.getLogger("bot.supplier")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None:
payload = draft.payload
merge_supplier_patch(payload, patch)
else:
payload = new_supplier_payload()
merge_supplier_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
options.pop("cat", None)
elif res.candidates:
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("cat", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "cat":
draft.payload["category_id"] = choice["id"]
draft.payload["category_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("cat", None)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("cat") or []
if options:
rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = supplier_missing(payload)
text = supplier_review_text(payload, missing)
keyboard = _keyboard(payload, draft.id)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard)
draft.review_message_id = (message or {}).get("message_id")
await draft_store.save_payload(draft, missing={"fields": missing},
review_message_id=draft.review_message_id)
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
async def commit(ctx, chat_id, user, draft) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")),
("category", "دسته‌بندی", missing.get("category")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {
"name": payload["name"],
"category_id": payload["category_id"],
"phone": payload.get("phone"),
"location": payload.get("location"),
"contact_person": payload.get("contact_person"),
}
body = {k: v for k, v in body.items() if v}
try:
created = await ctx.api.create_supplier(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("suppliers")
await ctx.send(chat_id, supplier_committed_report(created))
+74
View File
@@ -0,0 +1,74 @@
"""Inline keyboard builders.
Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a
short op-code plus the 8-char draft prefix; the full state lives in bot_drafts
(or the in-memory pending-flow map) and is looked up on tap.
"""
def _kb(rows: list[list[dict]]) -> dict:
return {"inline_keyboard": rows}
def _btn(text: str, data: str) -> dict:
return {"text": text, "callback_data": data}
# callback scheme (≤64 bytes):
# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only
# s:ok:{d8} / s:no:{d8} supplier
# m:ok:{d8} / m:no:{d8} material
# pk:{d8}:{field}:{idx} pick candidate for a draft field
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
if has_partial_option:
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
return rows
def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]:
return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)]
def quote_review_keyboard(
prefix: str,
pick_rows: list[list[dict]],
has_partial_option: bool = False,
wizard_url: str | None = None,
) -> dict:
rows = list(pick_rows)
if wizard_url:
rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}])
rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option)
return _kb(rows)
def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict:
rows = list(pick_rows)
rows += draft_actions(prefix, kind)
return _kb(rows)
def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict:
"""Pick among candidate quotes: pairs of (label, quote_id)."""
return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)])
def accept_keyboard(termsheet_labels: list[str]) -> dict:
rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)]
rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")])
return _kb(rows)
def arrive_keyboard() -> dict:
return _kb([
[_btn("📦 تحویل کامل همه اقلام", "ar:full")],
[_btn("✅ ثبت مقادیر اعلام‌شده", "ar:ok"), _btn("❌ انصراف", "ar:no")],
])
def cancel_keyboard() -> dict:
return _kb([[_btn("❌ انصراف", "ac:no")]])
+60
View File
@@ -0,0 +1,60 @@
"""Bot entrypoint: ensure tables → sweeper task → Bale long-poll loop.
Mirrors the api's pattern of a plain asyncio background loop (no Celery/Redis).
"""
import asyncio
import logging
from app.bale.client import BaleClient, poll_loop
from app.config import settings
from app.db_init import ensure_schema
from app.drafts import expire_stale
from app.handlers import handle_update
from app.runtime import BotContext
from app.api.client import ErpClient
from app.sync import sync_drafts_loop
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
)
logger = logging.getLogger("bot.main")
async def sweeper_loop() -> None:
"""Expire stale drafts + sweep dead pending flows every 10 minutes."""
while True:
try:
expired = await expire_stale(settings.DRAFT_TTL_HOURS)
if expired:
logger.info("expired %s stale drafts", expired)
except Exception:
logger.exception("sweeper failed")
await asyncio.sleep(600)
async def main() -> None:
if not settings.BOT_TOKEN:
raise SystemExit("PROCUREMENT_BOT_BOT_TOKEN is not set — get one from Bale's BotFather")
logger.info("starting bale procurement bot (api=%s, ai=%s)",
settings.API_BASE_URL, "configured" if settings.AI_BASE_URL else "NOT configured")
await ensure_schema()
bale = BaleClient(settings.BALE_API_BASE, settings.BOT_TOKEN)
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
ctx = BotContext(bale, api)
sweeper = asyncio.create_task(sweeper_loop())
sync_task = asyncio.create_task(sync_drafts_loop(ctx))
try:
await poll_loop(bale, lambda update: handle_update(ctx, update))
finally:
sweeper.cancel()
sync_task.cancel()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+85
View File
@@ -0,0 +1,85 @@
"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py).
These are separate from the ERP schema on purpose: the bot is an autonomous
container and owns its own state (links, drafts, message audit log).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def _uuid_pk() -> Mapped[str]:
return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
def _timestamptz(**kwargs) -> Mapped[datetime]:
return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs)
class BotLink(Base):
"""Bale chat ↔ ERP user mapping (established via Casdoor login)."""
__tablename__ = "bot_links"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id
casdoor_username: Mapped[str | None] = mapped_column(Text)
display_name: Mapped[str | None] = mapped_column(Text)
role: Mapped[str | None] = mapped_column(String(32))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotDraft(Base):
"""A prepared-but-not-committed ERP action (quote / supplier / material)."""
__tablename__ = "bot_drafts"
__table_args__ = (
Index("ix_bot_drafts_chat_status", "chat_id", "status"),
Index("ix_bot_drafts_review_msg", "review_message_id"),
Index("ix_bot_drafts_source_msg", "source_message_id"),
)
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
missing: Mapped[dict | None] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed
source: Mapped[str | None] = mapped_column(String(16)) # text|voice
source_message_id: Mapped[int | None] = mapped_column(BigInteger)
review_message_id: Mapped[int | None] = mapped_column(BigInteger)
last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotMessage(Base):
"""Audit log of every inbound/outbound message (who asked for what)."""
__tablename__ = "bot_messages"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error
body: Mapped[str | None] = mapped_column(Text)
payload: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
class BotMeta(Base):
"""Tiny KV store (e.g. last processed Bale update_id across restarts)."""
__tablename__ = "bot_meta"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text)
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
+149
View File
@@ -0,0 +1,149 @@
"""Natural-language understanding: Persian free text → typed intent + entities.
One chat/completions call per message with JSON-mode output. The prompt embeds
the current draft summary so the model can decide between "start a new
activity" and "refine the open draft" (update_draft with a partial patch).
"""
import json
import logging
import re
import httpx
from app.config import Settings
logger = logging.getLogger("bot.nlu")
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
def normalize_digits(text: str) -> str:
return (text or "").translate(PERSIAN_DIGITS)
ZWNJ = "\u200c"
def normalize_text(text: str) -> str:
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
t = normalize_digits(text or "")
t = t.replace(ZWNJ, " ").replace("ي", "ی").replace("ك", "ک")
t = re.sub(r"\s+", " ", t).strip().lower()
return t
INTENTS = [
"register_quote", # ثبت استعلام/پیشنهاد جدید از یک تأمین‌کننده
"update_draft", # تکمیل/اصلاح پیش‌نویس باز (patch روی آن)
"commit_draft", # «ثبت کن» / «تأیید» — commit the open draft
"discard_draft", # «بی‌خیال» / «لغو»
"show_draft", # «وضعیت پیش‌نویس؟»
"add_supplier",
"add_material",
"status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ...
"accept_quote", # پذیرش یک استعلام
"arrive_items", # اعلام رسیدن اقلام
"help",
"unknown",
]
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
{
"intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده},
"confidence": 0.0..1.0
}
قوانین:
- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
- اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft".
- اگر سؤال درباره وضعیت است → intent="status_query".
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام.
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر.
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
شکل patch برای intent="register_quote"/"update_draft":
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
شکل patch برای intent="add_supplier":
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
شکل patch برای intent="add_material":
{"material": {"name": str, "unit_name": str, "topic": str}}
شکل patch برای intent="status_query":
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
شکل patch برای intent="accept_quote"/"arrive_items":
{"target": {"supplier_name": str, "project_name": str, "quote_id": str},
"arrivals": [{"material_name": str, "quantity": number, "quality_note": str}]}
{draft_block}
"""
def _build_system_prompt(draft_summary: str | None) -> str:
intents = ", ".join(INTENTS)
prompt = SYSTEM_PROMPT.replace("{intents}", intents).replace("{draft_block}", "")
if draft_summary:
prompt += (
"\n\nپیش‌نویسِ باز فعلی کاربر (اگر مرتبط است به آن تکیه کن و فیلدهای تکراری را بازنویسی نکن):\n"
+ draft_summary
)
return prompt
def _extract_json(raw: str) -> dict | None:
raw = (raw or "").strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw).rstrip("`").strip()
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end <= start:
return None
try:
return json.loads(raw[start : end + 1])
except json.JSONDecodeError:
return None
async def understand(settings: Settings, text: str, draft_summary: str | None = None) -> dict:
"""Return {"intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"intent": "unknown", "patch": {}, "confidence": 0.0}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message")
return fallback
try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
json={
"model": settings.LLM_MODEL,
"messages": [
{"role": "system", "content": _build_system_prompt(draft_summary)},
{"role": "user", "content": normalize_digits(text)},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
)
if resp.status_code != 200:
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
return fallback
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
parsed = _extract_json(content)
if not parsed or parsed.get("intent") not in INTENTS:
logger.warning("unparseable LLM output: %r", content[:200])
return fallback
parsed.setdefault("patch", {})
return parsed
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
logger.exception("NLU failure")
return fallback
+151
View File
@@ -0,0 +1,151 @@
"""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", [])
+170
View File
@@ -0,0 +1,170 @@
"""Shared runtime: BotContext wiring + per-chat pending interaction flows.
Pending flows (Casdoor linking, quote picking, accept/arrive confirmations)
are short-lived and single-instance, so they live in memory; anything that
must survive restarts (drafts, links) lives in postgres.
"""
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy import select, update
from app.api.client import ErpClient
from app.bale.client import BaleClient
from app.config import settings
from app.db import SessionLocal
from app.models import BotLink, BotMessage
from app.resolve import Resolver
logger = logging.getLogger("bot.runtime")
# ---------------------------------------------------------------------------
# Links (postgres)
# ---------------------------------------------------------------------------
class LinkStore:
async def get(self, chat_id: int) -> BotLink | None:
async with SessionLocal() as session:
return (await session.execute(
select(BotLink).where(BotLink.chat_id == chat_id)
)).scalar_one_or_none()
async def upsert(self, chat_id: int, user_id: str | None, username: str | None,
display_name: str | None, role: str | None) -> BotLink:
async with SessionLocal() as session:
link = (await session.execute(select(BotLink).where(BotLink.chat_id == chat_id))).scalar_one_or_none()
if link is None:
link = BotLink(chat_id=chat_id)
session.add(link)
link.user_id = user_id
link.casdoor_username = username
link.display_name = display_name
link.role = role
await session.commit()
await session.refresh(link)
return link
async def delete(self, chat_id: int) -> bool:
async with SessionLocal() as session:
result = await session.execute(
update(BotLink).where(BotLink.chat_id == chat_id).values(user_id=None, role=None,
casdoor_username=None)
)
await session.commit()
return (result.rowcount or 0) > 0
ROLE_FA = {"pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین"}
# ---------------------------------------------------------------------------
# Pending flows (memory) — one per chat
# ---------------------------------------------------------------------------
@dataclass
class PendingFlow:
kind: str # link_username | link_password | quote_pick | accept | arrive_qty | arrive_confirm
data: dict = field(default_factory=dict)
created: float = field(default_factory=time.monotonic)
@property
def age_seconds(self) -> float:
return time.monotonic() - self.created
class PendingFlows:
TTL_SECONDS = 1800
def __init__(self):
self._flows: dict[int, PendingFlow] = {}
def set(self, chat_id: int, kind: str, data: dict | None = None) -> PendingFlow:
flow = PendingFlow(kind=kind, data=data or {})
self._flows[chat_id] = flow
return flow
def get(self, chat_id: int) -> PendingFlow | None:
flow = self._flows.get(chat_id)
if flow and flow.age_seconds > self.TTL_SECONDS:
self._flows.pop(chat_id, None)
return None
return flow
def pop(self, chat_id: int, default=None):
return self._flows.pop(chat_id, default)
def sweep(self) -> None:
for chat_id, flow in list(self._flows.items()):
if flow.age_seconds > self.TTL_SECONDS:
self._flows.pop(chat_id, None)
# ---------------------------------------------------------------------------
# Context
# ---------------------------------------------------------------------------
class BotContext:
def __init__(self, bale: BaleClient, api: ErpClient):
self.bale = bale
self.api = api
self.resolver = Resolver(api)
self.links = LinkStore()
self.pending = PendingFlows()
@property
def admin_chat_ids(self) -> set[int]:
return settings.admin_chat_ids
async def send(self, chat_id: int, text: str, reply_markup: dict | None = None,
reply_to: int | None = None) -> dict:
"""Send + audit-log every outbound message."""
try:
message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup, reply_to=reply_to)
except Exception:
logger.exception("failed to send to chat %s", chat_id)
message = {}
await self._log(chat_id, "out", "text", text)
return message
async def edit(self, chat_id: int, message_id: int | None, text: str,
reply_markup: dict | None = None) -> bool:
if not message_id:
return False
try:
await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup)
return True
except Exception as exc:
logger.warning("failed to edit message %s in chat %s: %s", message_id, chat_id, exc)
return False
async def delete(self, chat_id: int, message_id: int | None) -> bool:
if not message_id:
return False
try:
return await self.bale.delete_message(chat_id, message_id)
except Exception:
logger.exception("failed to delete message %s in chat %s", message_id, chat_id)
return False
async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None:
try:
async with SessionLocal() as session:
session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body))
await session.commit()
except Exception:
logger.exception("failed to log bot message")
@dataclass
class LinkedUser:
chat_id: int
user_id: str | None
display_name: str | None
role: str | None
is_admin: bool
@property
def actor_id(self) -> str | None:
return self.user_id
+62
View File
@@ -0,0 +1,62 @@
"""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()
+59
View File
@@ -0,0 +1,59 @@
"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API.
If a draft was updated in the Wizard:
- If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits.
- If status == 'committed': edits Bale message to show it was committed and removes buttons.
- If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons.
"""
import asyncio
import logging
from app import drafts as draft_store
from app.handlers import quote
logger = logging.getLogger("bot.sync")
async def sync_drafts_loop(ctx) -> None:
while True:
try:
pending = await draft_store.get_pending_bale_updates()
for draft in pending:
try:
if not draft.review_message_id:
await draft_store.set_bale_updated_at(draft.id)
continue
if draft.status == "committed":
payload = draft.payload or {}
quote_id = payload.get("committed_quote_id") or ""
msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد."
if quote_id:
msg += f"\nشناسه پیشنهاد: {quote_id}"
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id)
elif draft.status == "cancelled":
msg = "❌ این پیش‌نویس لغو شد."
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
await quote.render(ctx, draft.chat_id, draft, fresh=False)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
else:
await draft_store.set_bale_updated_at(draft.id)
except Exception as exc:
logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc)
await draft_store.set_bale_updated_at(draft.id)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error in sync_drafts_loop")
await asyncio.sleep(1.5)
+254
View File
@@ -0,0 +1,254 @@
"""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)
+172
View File
@@ -0,0 +1,172 @@
# Setup & Operations Guide
A Bale messenger bot that turns Persian **chat messages and voice notes** into
procurement actions in the ERP. It works **draft-first**: it captures what it
understood, tells you exactly **what it did** and **what is still empty**, and
**commits only after you approve** with a ✅ button.
Bale's Bot API is a Telegram-compatible clone hosted at `tapi.bale.ai`, so the
bot uses the familiar `getUpdates` long-polling — **no public port, no webhook,
no TLS setup**.
---
## What it can do (v1)
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` (nested create) |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop (the core idea)
1. You send a message (or voice note). The bot converts voice to text
(STT) and extracts an **intent + entities** (LLM).
2. Names are fuzzy-matched against the ERP (supplier / material / unit /
project). Multiple candidates → inline **pick buttons**; no match → it is
listed as missing.
3. The bot replies with a **review card**: everything it captured, plus a
✅/⚠️/❌ **list of what is still empty** («یادآوری چیزهای خالی»).
4. You keep chatting to refine («قیمت میلگرد ۴۷ شد», «پروژه برج آسمان») — the
open draft is updated and re-rendered.
5. **Nothing is written to the ERP until you tap ✅ ثبت نهایی.** Partial
commits are possible («ثبت فقط اقلام مشخص») because the API deliberately
tolerates incomplete quotes. After commit you get the created quote id and
the list of fields that were left empty.
### Team access & attribution
- Every user links their Bale chat to their ERP account once: `/start` →
Casdoor **username** → **password** (validated through the real
`POST /auth/login`; the password is used once and never stored).
- Mutating API calls carry `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked, not the bot. (The bot's container talks to
the api in-network: `http://api:8000/api/v1`.)
- `BALE_ADMIN_CHAT_IDS` may list chat ids that bypass linking.
### Commands
`/start` · `/help` · `/drafts` (open drafts) · `/cancel` (abort draft/flow) ·
`/whoami` · `/unlink`
---
## Setup
1. **Create the bot** in Bale's BotFather and copy the token.
2. **Configure an AI provider** — any OpenAI-compatible endpoint works for
both STT (`POST {base}/audio/transcriptions`, Persian) and the LLM
(`POST {base}/chat/completions`, JSON mode). Examples: OpenAI `whisper-1`
+ `gpt-4o-mini`, or any proxy/provider you already use.
3. **Compose** (dev):
```powershell
# env passed at compose level (or a .env next to docker-compose.yml)
$env:BALE_BOT_TOKEN = "<token>"
$env:AI_BASE_URL = "https://api.openai.com/v1"
$env:AI_API_KEY = "<key>"
docker compose up -d --build bot
docker compose logs -f bot
```
For Coolify (`docker-compose.coolify.yml`) set the same variables as
service-level env vars; the bot container needs no domain/labels.
### Environment variables (prefix `PROCUREMENT_BOT_`)
| Variable | Default | Purpose |
|---|---|---|
| `BOT_TOKEN` | — (required) | token from Bale's BotFather |
| `BALE_API_BASE` | `https://tapi.bale.ai` | Bot API base (overridable for tests) |
| `API_BASE_URL` | `http://localhost:8000/api/v1` | ERP api (in Docker: `http://api:8000/api/v1`) |
| `DATABASE_URL` | same postgres as the api | bot's own tables (`bot_*`) |
| `AI_BASE_URL` / `AI_API_KEY` | — | OpenAI-compatible STT+LLM provider |
| `STT_MODEL` / `LLM_MODEL` | `whisper-1` / `gpt-4o-mini` | model names at the provider |
| `ADMIN_CHAT_IDS` | — | comma-separated Bale chat ids allowed without linking |
| `SERVICE_TOKEN` | — | optional bearer for the day procurement endpoints enforce auth |
| `DRAFT_TTL_HOURS` | `24` | drafts older than this are marked expired |
In compose these are fed from `BALE_BOT_TOKEN`, `BALE_API_BASE`, `AI_BASE_URL`,
`AI_API_KEY`, `AI_STT_MODEL`, `AI_LLM_MODEL`, `BALE_ADMIN_CHAT_IDS`,
`BALE_SERVICE_TOKEN`.
---
## How it's built
```
bot/
├── Dockerfile python:3.13-slim + ffmpeg (voice conversion)
├── app/
│ ├── main.py ensure tables → sweeper task → poll loop
│ ├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
│ ├── bale/client.py getUpdates/sendMessage/edit/callbacks/getFile+download
│ ├── api/client.py ERP /api/v1 client (X-Actor-Id attribution)
│ ├── stt.py ffmpeg (OGG/Opus → MP3) → /audio/transcriptions
│ ├── nlu.py Persian intent+entity extraction (JSON mode)
│ ├── resolve.py fuzzy name→id matching (difflib + ilike search)
│ ├── drafts.py draft store + QuoteCreate payload builder + missing-field logic
│ ├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
│ ├── texts.py Persian message templates
│ ├── runtime.py context, link store, in-memory pending flows
│ ├── models.py / db_init.py bot_links / bot_drafts / bot_messages / bot_meta (idempotent DDL)
│ └── handlers/ dispatch, commands, callbacks, links,
│ quote, supplier, material, queries, decisions
└── tools/smoke_test.py offline end-to-end harness (fake Bale/ERP/AI, in-memory store)
```
Design notes:
- **Draft state lives in postgres** (`bot_drafts.payload` JSONB in the shape of
the eventual API request), so drafts survive bot restarts; short-lived
interactions (linking, quote picking, accept/arrive confirmations) are
in-memory with a 30-minute TTL.
- **Callback grammar** fits Bale's 64-byte `callback_data`:
`q:ok:{draft8}` (commit), `pk:{draft8}:{field}:{idx}` (pick candidate),
`ac:ts:{i}` (choose payment terms), `ar:full` / `ar:ok` (delivery)…
- **Persian handling**: Persian/Arabic digits are normalized before parsing;
fuzzy matching folds ZWNJ and Arabic ya/ka; all user-facing text is Persian.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3
before hitting the STT endpoint (accepted by essentially all providers).
- The **accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what you send, so
the bot must send the current ones).
### Attribution tweak in the api (backward compatible)
`backend/app/utils/security.py::get_actor` now prefers an `X-Actor-Id` header
over the raw bearer token when attributing `quote_events.actor`. Requests
without that header behave exactly as before.
---
## Testing
Offline harness (no Bale token, no DB, no AI key — stubs everything and drives
the real handler pipeline with fake updates):
```powershell
docker compose run --rm bot python tools/smoke_test.py
# or, with the requirements installed locally:
cd bot && python tools/smoke_test.py
```
Covers: linking, voice→draft, ambiguous-material pick buttons, unit fallback,
draft refinement, commit payload shape (temp_id cross-references), supplier /
material commits, status digest, accept payload (steps echoed, per-item price),
arrival quantities («۵۰۰ و ۲۰۰»), cancel and unknown-intent handling.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| Bot silent | wrong `BALE_BOT_TOKEN`; check `docker compose logs bot` |
| «پردازش هوشمند تنظیم نشده است» | `AI_BASE_URL` / `AI_API_KEY` missing |
| Voice → «نتوانستم پیام صوتی را تبدیل کنم» | provider rejected the model name (`STT_MODEL`) or key |
| «ERP API unreachable» | api container not up / wrong `API_BASE_URL` |
| Draft disappears | TTL expiry (`DRAFT_TTL_HOURS`) or someone pressed ❌ / `/cancel` |
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
# ERP integration pack
The agent is a **standalone container** — it talks to the ERP exclusively over
HTTP and keeps its own state in its own `bot_*` tables. Integrating it into the
[ERP stack](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) needs exactly
two things:
## 1. Compose service
Add [`erp-compose-service.yml`](erp-compose-service.yml) to the ERP's
`docker-compose.yml` (and the Coolify variant). The bot long-polls Bale, so it
needs **no ports, no webhook, no TLS** — it joins the compose `default` network
and reaches `http://api:8000/api/v1` in-network.
Credentials/env are passed at compose level from a `.env` next to the compose
file: `BALE_BOT_TOKEN`, `AI_BASE_URL`, `AI_API_KEY`, `AI_LLM_MODEL`
(see [`../.env.example`](../.env.example) for the full list).
> If your stack is started with multiple `-f` files (e.g. a prod_v2 checkout +
> port-remap override), keep using the same chain — the `bot` service merges in
> like any other service and the override's host-port remaps don't affect it.
## 2. Per-user attribution in the API
Apply [`x-actor-id.patch`](x-actor-id.patch) — a ~6-line, fully
backward-compatible change to `backend/app/utils/security.py`:
- before: `get_actor` returns the raw `Authorization: Bearer …` string and
`quote_events.actor` stores the bot's token;
- after: an `X-Actor-Id: <users.id>` header (sent by the bot for every mutating
call, carrying the **linked ERP user's id**) takes precedence, so the audit
trail records the human who asked, not the bot.
Requests without the header behave exactly as before.
## What the agent does NOT touch
- No ERP code is imported by the bot (HTTP-only, own postgres tables
`bot_links` / `bot_drafts` / `bot_messages` / `bot_meta`, created idempotently
at startup with `CREATE TABLE IF NOT EXISTS`).
- No Alembic migration needed — the bot owns its schema.
- The ERP's data model stays untouched; committed drafts flow through the
ordinary public endpoints (`POST /quotes`, `PATCH /quotes/{id}/accept`,
`PATCH /quotes/{id}/arrive-items`, `POST /suppliers`, `POST /materials`).
@@ -0,0 +1,39 @@
# ==========================================================================
# Add this service to the ERP stack's docker-compose.yml (and mirror it in
# docker-compose.coolify.yml with `container_name: procurement-bot`).
#
# It expects THIS repository checked out (or vendored) next to the ERP repo
# as `./bot` — either `git clone … bot` inside the ERP checkout, or change
# `context:` to an absolute/relative path to this repo.
#
# In-container URLs are unaffected by host port remaps (postgres/api are
# reached over the compose network, not host ports).
# ==========================================================================
services:
bot:
build:
context: ./bot # ← path to this repository
dockerfile: Dockerfile
container_name: procurement-bot
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-}
PROCUREMENT_BOT_STT_MODEL: ${AI_STT_MODEL:-whisper-1}
PROCUREMENT_BOT_STT_BASE_URL: ${AI_STT_BASE_URL:-}
PROCUREMENT_BOT_STT_API_KEY: ${AI_STT_API_KEY:-}
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-gpt-4o-mini}
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
restart: unless-stopped
@@ -0,0 +1,18 @@
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
index a78c1cb..75e7349 100644
--- a/backend/app/utils/security.py
+++ b/backend/app/utils/security.py
@@ -7,7 +7,13 @@ def get_actor(request: Request) -> str | None:
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
+
+ Services acting on behalf of a user (the Bale bot) pass ``X-Actor-Id``
+ (users.id); it wins over the raw token so the audit trail shows the human.
"""
+ actor_id = request.headers.get("X-Actor-Id")
+ if actor_id:
+ return actor_id.strip()
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
httpx>=0.27.0
sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
+448
View File
@@ -0,0 +1,448 @@
"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys.
It stubs the Bale client, the ERP API client, STT and the NLU module, replaces
the draft store with an in-memory fake, then drives the real handler pipeline
end-to-end through fake updates (text, voice, inline-button callbacks).
Run inside the bot container (or anywhere the requirements are installed):
python tools/smoke_test.py
Exit code 0 = all checks passed.
"""
import asyncio
import os
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# asyncpg is only needed when a real connection opens; stub it so app.db imports.
try:
import asyncpg # noqa: F401
except ImportError:
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
CHAT = 100200300
_passed = _failed = 0
def check(name: str, ok: bool, detail: str = "") -> None:
global _passed, _failed
ok = bool(ok)
mark = "PASS" if ok else "FAIL"
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
_passed += ok
_failed += (not ok)
# ---------------------------------------------------------------------------
# fakes
# ---------------------------------------------------------------------------
class FakeBale:
def __init__(self):
self.sent: list[dict] = []
self.edited: list[dict] = []
self.answered: list[str] = []
self._mid = 0
async def send_message(self, chat_id, text, reply_markup=None, reply_to=None):
self._mid += 1
self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup})
return {"message_id": self._mid, "chat": {"id": chat_id}}
async def edit_message_text(self, chat_id, message_id, text, reply_markup=None):
self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text,
"reply_markup": reply_markup})
return {"message_id": message_id}
async def answer_callback_query(self, callback_query_id, text=None):
self.answered.append(callback_query_id)
async def send_chat_action(self, chat_id, action="typing"):
pass
async def download_file(self, file_id):
return b"fake-ogg-bytes"
def last_text(self) -> str:
return self.sent[-1]["text"] if self.sent else ""
def last_markup(self) -> dict | None:
return self.sent[-1]["reply_markup"] if self.sent else None
class FakeApi:
"""Canned ERP responses + captured mutation payloads."""
def __init__(self):
self.created_quotes: list[dict] = []
self.created_suppliers: list[dict] = []
self.created_materials: list[dict] = []
self.accept_payloads: list[dict] = []
self.arrive_payloads: list[dict] = []
self.login_calls: list[dict] = []
async def login(self, username, password):
self.login_calls.append({"username": username, "password": password})
if username == "mohaghegh" and password == "secret":
return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی",
"last_name": "محقق", "role": "procurement_manager"}}
raise RuntimeError("invalid credentials")
async def list_suppliers(self, q=None, page_size=20):
return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}]
async def create_supplier(self, payload, actor_id=None):
self.created_suppliers.append(payload)
return {"id": "sup-new", "name": payload["name"],
"category": {"id": payload["category_id"], "name": "لوله و اتصالات"}}
async def list_materials(self, q=None, page_size=20):
return [
{"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3},
{"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3},
{"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2},
]
async def create_material(self, payload, actor_id=None):
self.created_materials.append(payload)
return {"id": "mat-new", "name": payload["name"]}
async def list_units(self, q=None, page_size=100):
return [{"id": 2, "code": "adad", "name_fa": "عدد"},
{"id": 3, "code": "kg", "name_fa": "کیلوگرم"}]
async def list_projects(self, page_size=100):
return [{"id": "prj-1", "name": "برج آسمان"}]
async def list_categories(self, page_size=100):
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
{"id": "cat-2", "name": "لوله و اتصالات"}]
def _expanded(self, quote_id, status):
"""Realistic GET /quotes?expand=line_items,termsheets,supplier shape."""
return {
"id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status,
"quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20",
"line_items": [
{"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"},
"quantity": 500, "unit_price": 45000, "projects": [],
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
{"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"},
"quantity": 200, "unit_price": 90000, "projects": [],
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
],
"termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0,
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}],
}
async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10):
if status == "offered":
return [self._expanded("q-aaaa1111", "offered")], {"total": 1}
if status == "accepted":
return [self._expanded("q-bbbb2222", "accepted")], {"total": 1}
return [], {"total": 0}
async def get_quote(self, quote_id, expand=None, actor_id=None):
return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered")
async def create_quote(self, payload, actor_id=None):
self.created_quotes.append(payload)
return {"id": "q-new", "supplier": {"name": "سازان نو"},
"line_items": payload.get("line_items") or [],
"termsheets": payload.get("termsheets") or []}
async def accept_quote(self, quote_id, payload, actor_id=None):
self.accept_payloads.append({"quote_id": quote_id, "payload": payload})
return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]}
async def arrive_items(self, quote_id, payload, actor_id=None):
self.arrive_payloads.append({"quote_id": quote_id, "payload": payload})
return {"id": quote_id, "status": "arrived",
"items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])}
for it in payload["items"]]}
# ---------------------------------------------------------------------------
# in-memory draft store + link store
# ---------------------------------------------------------------------------
MEM: dict = {"drafts": {}, "links": {}}
def _install_fake_stores() -> None:
import uuid as _uuid
import app.drafts as drafts
import app.runtime as runtime
async def create(chat_id, kind, payload, source=None):
draft_id = _uuid.uuid4()
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
missing=None, status="active", source=source,
review_message_id=None)
MEM["drafts"][str(draft_id)] = draft
return draft
async def get_active(chat_id, kind=None):
found = [d for d in MEM["drafts"].values()
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
return max(found, key=lambda d: d.id) if found else None
async def get_by_prefix(prefix, chat_id=None):
for draft in MEM["drafts"].values():
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
return draft
return None
async def save_payload(draft, missing=None, review_message_id=None):
draft.missing = missing
if review_message_id:
draft.review_message_id = review_message_id
async def set_status(draft_id, status):
if draft_id in MEM["drafts"]:
MEM["drafts"][draft_id].status = status
async def expire_stale(ttl_hours):
return 0
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
async def link_get(self, chat_id):
return MEM["links"].get(chat_id)
async def link_upsert(self, chat_id, user_id, username, display_name, role):
MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id,
casdoor_username=username,
display_name=display_name, role=role)
return MEM["links"][chat_id]
async def link_delete(self, chat_id):
return MEM["links"].pop(chat_id, None) is not None
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
async def fake_log(self, chat_id, direction, kind, body=None):
pass
NLU_QUEUE: list[dict] = []
async def fake_understand(settings, text, draft_summary=None):
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
async def fake_transcribe(settings, audio, mime_type=None, filename=None):
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
"پرداخت سه فاز")
# ---------------------------------------------------------------------------
# driver
# ---------------------------------------------------------------------------
def build_ctx():
import app.handlers as H
from app.resolve import Resolver
from app.runtime import BotContext
bale, api = FakeBale(), FakeApi()
ctx = BotContext(bale, api)
ctx.bale = bale
ctx.api = api
ctx.resolver = Resolver(api)
ctx._log = types.MethodType(fake_log, ctx)
H.understand = fake_understand
H.transcribe = fake_transcribe
return ctx, bale, api
def text_update(update_id, text):
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
def voice_update(update_id):
return {"update_id": update_id,
"message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}}
def callback_update(update_id, data):
return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data,
"message": {"chat": {"id": CHAT}}}}
async def main() -> int:
_install_fake_stores()
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
import app.handlers as H
from app.nlu import _extract_json, normalize_digits
# ---- pure logic -------------------------------------------------------
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
payload = new_quote_payload()
merge_quote_patch(payload, {
"supplier_name": "سازان نو",
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
"payment_terms": [{"label": "۳ فاز"}],
})
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
check("merge: no dup item", len(payload["items"]) == 1)
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
missing = compute_quote_missing(payload)
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
body, problems = build_quote_request(payload)
check("build: blocked without supplier", body is None)
# ---- pipeline: linking gate -------------------------------------------
ctx, bale, api = build_ctx()
await H.handle_update(ctx, text_update(1, "سلام"))
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
await H.handle_update(ctx, text_update(2, "mohaghegh"))
await H.handle_update(ctx, text_update(3, "secret"))
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh")
check("link: linked greeting", "اتصال انجام شد" in bale.last_text())
# ---- voice → draft quote ----------------------------------------------
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
"supplier_name": "سازان نو", "project_name": "برج آسمان",
"items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم",
"unit_price": 45000}],
"payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}],
}})
await H.handle_update(ctx, voice_update(4))
draft = await _active("quote")
check("voice: draft created", draft is not None)
check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1")
check("voice: project resolved", draft.payload.get("project_id") == "prj-1")
item = (draft.payload.get("items") or [{}])[0]
check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {}))
check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text())
check("voice: review mentions unit", "کیلوگرم" in bale.last_text())
# pick the material via inline button
markup = bale.last_markup() or {}
pick_data = _first_callback(markup, "pk:")
check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data)
await H.handle_update(ctx, callback_update(5, pick_data))
item = (draft.payload.get("items") or [{}])[0]
check("pick: material resolved", item.get("material_id") == "mat-1")
check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True)
# refine price via follow-up text
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
draft = await _active("quote")
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
# commit via ✅
await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}"))
check("commit: quote posted", len(api.created_quotes) == 1)
body = api.created_quotes[0]
check("commit: supplier id", body.get("supplier_id") == "sup-1")
check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1"
and body["item_termsheets"][0]["line_item_temp_id"] == "t1")
check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000)
check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز")
check("commit: report sent", "ثبت شد" in bale.last_text())
check("commit: draft committed", (await _active("quote")) is None)
# ---- add supplier ------------------------------------------------------
NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": {
"supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}})
await H.handle_update(ctx, text_update(8, "تأمین‌کننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912"))
draft = await _active("supplier")
check("supplier: draft created", draft is not None)
check("supplier: category resolved", draft.payload.get("category_id") == "cat-2")
await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}"))
check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر")
check("supplier: report", "تأمین‌کننده ثبت شد" in bale.last_text())
# ---- status query ------------------------------------------------------
NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9,
"patch": {"query": {"kind": "pending"}}})
await H.handle_update(ctx, text_update(10, "استعلام‌های در انتظار چیه؟"))
check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text())
# ---- accept flow -------------------------------------------------------
NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9,
"patch": {"target": {"supplier_name": "سازان نو"}}})
await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن"))
check("accept: summary shown", "پذیرش استعلام" in bale.last_text())
flow = ctx.pending.get(CHAT)
check("accept: pending flow", flow is not None and flow.kind == "accept")
await H.handle_update(ctx, callback_update(12, "ac:ok"))
check("accept: payload posted", len(api.accept_payloads) == 1)
payload = api.accept_payloads[0]["payload"]
check("accept: termsheet echoed with steps",
payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"])
check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000)
check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0)
check("accept: report", "پذیرش انجام شد" in bale.last_text())
# ---- arrive flow -------------------------------------------------------
NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9,
"patch": {"target": {"supplier_name": "سازان نو"}}})
await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید"))
check("arrive: items shown", "اعلام رسیدن" in bale.last_text())
await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰"))
flow = ctx.pending.get(CHAT)
check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm"
and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0})
await H.handle_update(ctx, callback_update(15, "ar:ok"))
check("arrive: payload posted", len(api.arrive_payloads) == 1
and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500)
check("arrive: report", "ثبت رسید انجام شد" in bale.last_text())
# ---- add material ------------------------------------------------------
NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9,
"patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}})
await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم"))
draft = await _active("material")
check("material: draft created", draft is not None)
check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3")
await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}"))
check("material: posted", api.created_materials
and api.created_materials[0]["canonical_unit_id"] == 3)
# ---- cancel / unknown --------------------------------------------------
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
await H.handle_update(ctx, text_update(19, "/cancel"))
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
return 1 if _failed else 0
async def _active(kind):
from app import drafts as draft_store
return await draft_store.get_active(CHAT, kind)
def _first_callback(markup: dict | None, prefix: str) -> str | None:
for row in (markup or {}).get("inline_keyboard") or []:
for btn in row:
if btn.get("callback_data", "").startswith(prefix):
return btn["callback_data"]
return None
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+19 -5
View File
@@ -26,7 +26,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User
from app.utils.errors import (
ForbiddenError,
@@ -170,11 +170,16 @@ async def pm_project_ids(db: AsyncSession, user: User) -> list:
return list(rows)
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
Raises UnauthorizedError for a present-but-invalid token; callers decide
their own fallback only for the no-token case (e.g. dev headers).
IMPORTANT: when ``db`` is omitted (pure auth-dependency usage), a
short-lived session of its own is used — querying the REQUEST session
here would auto-begin its transaction and break endpoints that open
explicit ``async with db.begin()`` blocks afterwards.
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
@@ -182,13 +187,22 @@ async def get_optional_user(request: Request, db: AsyncSession) -> UserContext |
token = auth.split(" ", 1)[1].strip()
if not token:
return None
if db is None:
async with AsyncSessionLocal() as own_session:
claims = await _verify_token(token)
return await context_from_claims(claims, own_session)
claims = await _verify_token(token)
return await context_from_claims(claims, db)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
ctx = await get_optional_user(request, db)
async def get_current_user(request: Request) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned).
Resolves the user in its own session (see get_optional_user) so the
global auth dependency never interferes with an endpoint's explicit
transaction handling.
"""
ctx = await get_optional_user(request)
if ctx is None:
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None:
+47 -8
View File
@@ -1,7 +1,14 @@
"""Application configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file.
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env``
file. The docker-compose files inject them from the git-ignored ``.env`` —
real credentials never live in this repository.
``ENVIRONMENT`` is the safety switch: when it is ``"prod"``, the API runs
fail-fast startup checks (see app.main._verify_production_settings) that
refuse to boot with wildcard CORS, default credentials or dev login
enabled.
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -14,7 +21,10 @@ class Settings(BaseSettings):
extra="ignore",
)
# PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist.
# "dev" (local defaults allowed) | "prod" (fail-fast checks on startup).
ENVIRONMENT: str = "dev"
# PostgreSQL DSN — the schema (migration.sql) must already exist.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Echo SQL statements to the console (dev aid).
@@ -22,6 +32,8 @@ class Settings(BaseSettings):
# MinIO object storage — invoice / proforma attachments (S3-compatible).
# Endpoint is the compose service name (`minio:9000`) inside Docker.
# Defaults are LOCAL DEV ONLY — production values come from .env and the
# prod startup checks reject them.
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
@@ -36,27 +48,54 @@ class Settings(BaseSettings):
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# CORS (dev-friendly default; the frontend is a static site).
CORS_ORIGINS: list[str] = ["*"]
# CORS — comma-separated exact browser origins (NOT "*" with
# credentials; the prod checks reject wildcard). Example:
# PROCUREMENT_CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
CORS_ORIGINS: str = "http://localhost,http://localhost:3000,http://localhost:5173"
# Developer Mode: in-page dev login panel (frontend build arg) AND the
# X-User-Role / X-User-Name-B64 header identity fallback in the
# inventory/pm routers. MUST stay false in production.
DEV_LOGIN_ENABLED: bool = False
# Casdoor (OIDC) — JWT verification against the issuer's JWKS.
CASDOOR_ENDPOINT: str = "http://localhost:8080"
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
CASDOOR_CLIENT_ID: str = "inventory-app-client"
CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
CASDOOR_CLIENT_SECRET: str = "" # no default secret — set via env/.env
CASDOOR_ORGANIZATION: str = "inventory"
CASDOOR_APP_NAME: str = "inventory-app"
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
# Casdoor console admin — used by POST /users/sync to mirror Casdoor
# identities into the app's users table (dev defaults: admin / 123).
# identities into the app's users table. No default password.
CASDOOR_ADMIN_USER: str = "admin"
CASDOOR_ADMIN_PASSWORD: str = "123"
CASDOOR_ADMIN_PASSWORD: str = ""
# Inventory dev fallback (only when no Authorization header is sent).
# Inventory dev fallback defaults (only used when DEV_LOGIN_ENABLED).
INVENTORY_DEFAULT_ROLE: str = "pm"
INVENTORY_DEFAULT_PROJECT_ID: str = ""
# ---- Observability (Phase 2) -------------------------------------------
# "text" (human console) | "json" (JSON lines for Loki/Promtail).
LOG_FORMAT: str = "text"
# Sentry/GlitchTip DSN — backend exception capture (empty = disabled).
SENTRY_DSN: str = ""
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
# OpenPanel server-side event ingestion (empty = disabled).
OPENPANEL_API_URL: str = "" # e.g. http://openpanel-api:8080 (self-hosted)
OPENPANEL_CLIENT_ID: str = ""
OPENPANEL_CLIENT_SECRET: str = ""
@property
def cors_origin_list(self) -> list[str]:
"""Comma-separated CORS_ORIGINS → list for CORSMiddleware."""
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@property
def is_prod(self) -> bool:
return self.ENVIRONMENT.lower() == "prod"
settings = Settings()
+99 -25
View File
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
_DASHBOARD_DDL = [
# One reference unit per category (units.html «واحد مرجع هر دسته»)
"""
CREATE TABLE IF NOT EXISTS category_reference_units (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
ref_unit TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
"""
CREATE TABLE IF NOT EXISTS unit_conversions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
brand TEXT,
from_unit TEXT NOT NULL,
to_unit TEXT NOT NULL,
factor NUMERIC(18,6) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
""",
@@ -345,6 +326,18 @@ BASE_UNITS: list[dict] = [
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
]
# تبدیل‌های ثابت جهانی («تبدیل‌های ثابت — همه متریال‌ها») — ضرایب استاندارد و
# فراگیر که در هر استارت‌آپ به‌صورت idempotent ذخیره می‌شوند. کلید یکتا:
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
# قالبی که فرانت‌اند در standard_conversions می‌نویسد.
STANDARD_CONVERSIONS: list[dict] = [
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
]
@@ -379,16 +372,97 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u,
)
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
# Seed the global fixed conversions (idempotent — unique key from/to unit).
for c in STANDARD_CONVERSIONS:
await run_statement(
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
"VALUES (:from_unit, :to_unit, :factor) "
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
c,
)
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("""
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price
AFTER INSERT OR UPDATE OF status ON item_decisions
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
""")
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
These three tables backed features that were never populated (0 rows on every
volume): per-category reference units and conversion factors (the
per-reference-unit price line) and the category -> attribute mapping. Dropping
them is idempotent and loses no data. material_categories itself stays — every
supplier carries one as its label.
"""
async with engine.begin() as conn:
await conn.execute(text(
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
))
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS audit_log (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor TEXT,
action TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT,
details JSONB NOT NULL DEFAULT '{}'
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id)",
"CREATE INDEX IF NOT EXISTS idx_audit_log_occurred ON audit_log(occurred_at DESC)",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("audit_log table ensured")
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
ddl = """
CREATE TABLE IF NOT EXISTS bot_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
missing JSONB,
status VARCHAR(16) NOT NULL DEFAULT 'active',
source VARCHAR(16),
source_message_id BIGINT,
review_message_id BIGINT,
last_bale_update_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status)",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts(review_message_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts(source_message_id)",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("bot_drafts table ensured")
+142 -4
View File
@@ -7,30 +7,57 @@
"""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_fastapi_instrumentator import Instrumentator
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text as sql_text
from sqlalchemy.exc import IntegrityError
from app.auth import get_current_user
from app.config import settings
from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import (
ensure_attachment_table,
ensure_audit_table,
ensure_bot_drafts_table,
ensure_drop_category_tables,
ensure_dashboard_tables,
ensure_material_topic_column,
ensure_quote_projects,
ensure_v26,
)
from app.observability import RequestContextMiddleware, setup_logging
from app.routers import ROUTERS
from app.services.quote_service import auto_expire_quotes
from app.utils.errors import AppError
from app.utils.ratelimit import limiter
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
setup_logging(settings.LOG_FORMAT)
logger = logging.getLogger("procurement")
if settings.SENTRY_DSN:
# GlitchTip/Sentry exception capture — after setup_logging so the SDK's
# own logs go through our formatter too. Disabled entirely without a DSN.
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
environment=settings.ENVIRONMENT,
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
integrations=[FastApiIntegration()],
send_default_pii=False,
)
logger.info("Sentry/GlitchTip exception capture enabled")
API_V1_PREFIX = "/api/v1"
@@ -45,13 +72,43 @@ async def _auto_expire_loop() -> None:
logger.exception("Auto-expiry pass failed")
def _verify_production_settings() -> None:
"""Fail-fast guard: refuse to boot in prod with dev defaults.
These exact misconfigurations are what previously shipped (wildcard CORS
with credentials, default MinIO/Postgres/Casdoor credentials, and the
always-on X-User-Role identity fallback). Ops fix: set the real values
in the server-side .env — see docs/SECRET-MANAGEMENT.md.
"""
problems: list[str] = []
if not settings.cors_origin_list or "*" in settings.cors_origin_list:
problems.append("PROCUREMENT_CORS_ORIGINS must list exact origins (no '*')")
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
problems.append("MinIO credentials are still the default minioadmin")
if not settings.CASDOOR_CLIENT_SECRET:
problems.append("PROCUREMENT_CASDOOR_CLIENT_SECRET is not set")
if not settings.CASDOOR_ADMIN_PASSWORD:
problems.append("PROCUREMENT_CASDOOR_ADMIN_PASSWORD is not set")
if settings.DEV_LOGIN_ENABLED:
problems.append("PROCUREMENT_DEV_LOGIN_ENABLED must be false in production")
if problems:
raise RuntimeError(
"Refusing to start in production — fix the environment:\n - " + "\n - ".join(problems)
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
if settings.is_prod:
_verify_production_settings()
await ensure_dashboard_tables(engine)
await ensure_attachment_table(engine)
await ensure_material_topic_column(engine)
await ensure_quote_projects(engine)
await ensure_v26(engine)
await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try:
yield
@@ -74,11 +131,21 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestContextMiddleware)
# Rate limiting — login brute-force brake (edge rate limit lives in Traefik).
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Prometheus /metrics (requests, latency, errors) — mounted at app level,
# outside the authenticated /api/v1 prefix; scrape target for the
# monitoring stack.
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
# ---------------------------------------------------------------------------
@@ -119,6 +186,7 @@ def _fa_validation_msg(msg: str) -> str:
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_request: Request, exc: RequestValidationError):
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
first = exc.errors()[0]
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
field = ".".join(loc) if loc else None
@@ -157,11 +225,81 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
# JWT auth is required on EVERY router; the auth router stays public
# (POST /auth/login issues the tokens) and /health lives below, outside the
# /api/v1 prefix. Routers with their own finer-grained scopes (inventory,
# pm, users) keep them — the global dependency guarantees nothing is open
# by omission.
from app.routers.auth import router as _auth_router # noqa: E402
# The files router enforces its own access check per route: a browser cannot
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
# routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402
from app.routers.drafts import router as _drafts_router # noqa: E402
for router in ROUTERS:
app.include_router(router, prefix=API_V1_PREFIX)
if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _drafts_router:
app.include_router(router, prefix=API_V1_PREFIX)
else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
@app.get("/health", tags=["system"])
async def health():
return {"status": "ok", "service": "procurement-api"}
async def _check_db() -> bool:
try:
async with AsyncSessionLocal() as session:
await session.execute(sql_text("SELECT 1"))
return True
except Exception:
logger.exception("readiness: database check failed")
return False
async def _check_minio() -> bool:
try:
from app.services.storage import get_client
get_client().bucket_exists(settings.MINIO_BUCKET)
return True
except Exception:
logger.exception("readiness: MinIO check failed")
return False
async def _check_casdoor() -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(
settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
)
return resp.status_code == 200
except Exception:
logger.exception("readiness: Casdoor JWKS check failed")
return False
@app.get("/health/ready", tags=["system"])
async def health_ready():
"""Readiness: verifies the dependencies the API actually needs.
The deploy webhook polls this after a rebuild, so a deploy that breaks
the DB/MinIO/Casdoor connectivity is visible immediately.
"""
checks = {
"database": await _check_db(),
"minio": await _check_minio(),
"casdoor": await _check_casdoor(),
}
ok = all(checks.values())
return JSONResponse(
status_code=200 if ok else 503,
content={"status": "ok" if ok else "degraded", "service": "procurement-api", "checks": checks},
)
+1
View File
@@ -1,5 +1,6 @@
"""ORM models — import order matters only for registration in Base.metadata."""
from app.db.base import Base
from app.models.audit import AuditLog
from app.models.catalog import (
AttributeDefinition,
Brand,
+19
View File
@@ -0,0 +1,19 @@
"""Audit log model — general activity trail (v2.7)."""
import uuid
from sqlalchemy import Column, DateTime, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
occurred_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
actor = Column(Text, nullable=True) # display name of the authenticated user
action = Column(String(64), nullable=False) # e.g. "user.role_changed"
entity = Column(String(64), nullable=False) # e.g. "user", "inventory_tx"
entity_id = Column(Text, nullable=True)
details = Column(JSONB, nullable=False, server_default="{}")
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base):
__tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column(
@@ -149,7 +146,6 @@ class Material(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from uuid import UUID
from sqlalchemy import BigInteger, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from app.db.base import Base
class BotDraft(Base):
__tablename__ = "bot_drafts"
id = Column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
chat_id = Column(BigInteger, nullable=False)
kind = Column(String(16), nullable=False)
payload = Column(JSONB, nullable=False, default=dict)
missing = Column(JSONB)
status = Column(String(16), nullable=False, default="active")
source = Column(String(16))
source_message_id = Column(BigInteger)
review_message_id = Column(BigInteger)
last_bale_update_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+86
View File
@@ -0,0 +1,86 @@
"""Observability plumbing — logging format, request IDs, request log.
- ``setup_logging()``: human-readable text by default, JSON lines when
``PROCUREMENT_LOG_FORMAT=json`` (what Promtail/Loki prefers).
- ``request_context`` middleware: assigns an X-Request-ID to every request
(honours one sent by the edge), echoes it in the response, and logs one
structured line per request (method, path, status, duration, request id)
— this is what makes access logs useful in Loki.
"""
import contextvars
import json
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
_SKIP_ACCESS_LOG = {"/health", "/health/ready", "/metrics"}
class JsonFormatter(logging.Formatter):
"""Minimal JSON-lines formatter for Loki/Promtail ingestion."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": request_id_var.get(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
for key in ("http_method", "http_path", "http_status", "duration_ms"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
return json.dumps(payload, ensure_ascii=False)
def setup_logging(log_format: str = "text", level: int = logging.INFO) -> None:
formatter = JsonFormatter() if log_format.lower() == "json" else logging.Formatter(
"%(asctime)s %(levelname)s %(name)s — %(message)s"
)
root = logging.getLogger()
for handler in root.handlers[:]:
root.removeHandler(handler)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
root.addHandler(handler)
root.setLevel(level)
class RequestContextMiddleware(BaseHTTPMiddleware):
"""X-Request-ID + one access-log line per request."""
async def dispatch(self, request: Request, call_next) -> Response:
rid = request.headers.get("x-request-id") or uuid.uuid4().hex
token = request_id_var.set(rid)
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = round((time.perf_counter() - started) * 1000, 1)
logging.getLogger("procurement.access").exception(
"unhandled exception",
extra={"http_method": request.method, "http_path": request.url.path,
"http_status": 500, "duration_ms": duration_ms},
)
request_id_var.reset(token)
raise
duration_ms = round((time.perf_counter() - started) * 1000, 1)
response.headers["X-Request-ID"] = rid
request_id_var.reset(token)
if request.url.path not in _SKIP_ACCESS_LOG:
logging.getLogger("procurement.access").info(
"%s %s -> %s",
request.method, request.url.path, response.status_code,
extra={"http_method": request.method, "http_path": request.url.path,
"http_status": response.status_code, "duration_ms": duration_ms},
)
return response
+2 -6
View File
@@ -4,8 +4,7 @@ from app.routers import (
auth,
brand_materials,
brands,
category_attributes,
category_ref_units,
drafts,
files,
inventory,
item_decisions,
@@ -24,7 +23,6 @@ from app.routers import (
supplier_materials,
suppliers,
termsheets,
unit_conversions,
units,
users,
)
@@ -35,7 +33,6 @@ ROUTERS = [
brands.router,
material_categories.router,
attribute_definitions.router,
category_attributes.router,
materials.router,
brand_materials.router,
suppliers.router,
@@ -51,10 +48,9 @@ ROUTERS = [
item_receipts.router,
quote_events.router,
files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router,
inventory.router,
pm.router,
users.router,
drafts.router,
]
+14 -2
View File
@@ -13,7 +13,7 @@ Error mapping (documented in docs/casdoor-auth.md):
import logging
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,9 @@ from app.auth import _verify_token, context_from_claims
from app.config import settings
from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict
from app.services import analytics
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth")
@@ -34,7 +36,8 @@ class LoginRequest(BaseModel):
@router.post("/login")
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
@limiter.limit("10/minute", error_message="تلاش‌های ورود بیش از حد مجاز — کمی بعد دوباره امتحان کنید")
async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Exchange username/password for a Casdoor JWT (OAuth password grant)."""
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
form = {
@@ -75,6 +78,15 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user)
await analytics.track(
"login_success",
{"username": payload.username, "role": ctx.role},
profile_id=ctx.user.casdoor_id,
)
await analytics.identify(
ctx.user.casdoor_id,
{"name": ctx.display_name, "role": ctx.role, "username": payload.username},
)
return {
"data": {
"access_token": access_token,
-136
View File
@@ -1,136 +0,0 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
-69
View File
@@ -1,69 +0,0 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+106
View File
@@ -0,0 +1,106 @@
"""Drafts router — exposes bot_drafts to frontend for Wizard pre-population and bi-directional sync."""
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.draft import BotDraft
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/drafts", tags=["drafts"])
class DraftUpdateIn(BaseModel):
payload: dict[str, Any] | None = None
status: str | None = None
class DraftCommitIn(BaseModel):
quote_id: str | None = None
def _serialize_draft(draft: BotDraft) -> dict:
return {
"id": str(draft.id),
"chat_id": draft.chat_id,
"kind": draft.kind,
"status": draft.status,
"payload": draft.payload or {},
"missing": draft.missing or [],
"source": draft.source,
"review_message_id": draft.review_message_id,
"last_bale_update_at": draft.last_bale_update_at.isoformat() if draft.last_bale_update_at else None,
"created_at": draft.created_at.isoformat() if draft.created_at else None,
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
}
@router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
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)}
@router.patch("/{draft_id}")
async def update_draft(draft_id: UUID, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
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("/{draft_id}/commit")
async def commit_draft(draft_id: UUID, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
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("/{draft_id}/cancel")
async def cancel_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
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)}
+102 -16
View File
@@ -17,16 +17,22 @@ import os
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, get_optional_user, require_role
from app.db.engine import get_db
from app.models.procurement import Quote
from app.services import file_service
from app.utils.errors import NotFoundError, ValidationError
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
# برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
}
@@ -36,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
def _content_disposition(file_name: str) -> str:
def _content_disposition(file_name: str, inline: bool = False) -> str:
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients.
``inline=True`` makes the browser render the file (PDF in a viewer,
image in the tab) instead of downloading it — required by the preview
modal, whose <iframe> would otherwise trigger a download.
"""
raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters
@@ -51,9 +61,37 @@ def _content_disposition(file_name: str) -> str:
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
# name): use a generic name that keeps the extension
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
"preview" if inline else "attachment"
)
kind = "inline" if inline else "attachment"
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
async def _require_file_access(
request: Request,
token: str | None,
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Authorise a file read by session header OR a signed ``?t=`` token.
These two GET routes sit outside the global Bearer dependency because a
browser cannot attach an Authorization header to <iframe>/<img> loads.
A caller is accepted when it presents either a valid Casdoor session
(resolved by get_optional_user) or a signed token scoped to exactly
this file, which expires within minutes.
"""
if request is not None and request.headers.get("authorization"):
user = await get_optional_user(request, db)
if user is not None and user.role is not None:
return
if token and verify_preview_token(token, quote_id, kind, attachment_id):
return
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
@@ -87,7 +125,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201)
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
@@ -98,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()}
@router.get("/{quote_id}/invoice")
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice")
return {"data": [a.to_dict() for a in attachments]}
# ---------------------------------------------------------------------------
# Preview URL — a real, short-lived URL the browser can load in an
# <iframe>/<img> (which cannot send an Authorization header). Using a real
# URL instead of a blob: avoids download managers that hijack blob URLs and
# leave 0-byte files behind.
# ---------------------------------------------------------------------------
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
async def preview_url(
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_ctx: UserContext = Depends(get_current_user),
):
if kind not in ("invoice", "proforma"):
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
await _quote_or_404(db, quote_id)
await _attachment_or_404(db, quote_id, attachment_id, kind)
token = make_preview_token(quote_id, kind, attachment_id)
# مسیر نسبی برگردانده می‌شود؛ فرانت با BASE_URL خودش کاملش می‌کند
return {
"data": {
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
"token": token,
}
}
@router.get("/{quote_id}/invoice/{attachment_id}")
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def download_invoice(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": _content_disposition(attachment.file_name),
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/invoice/{attachment_id}")
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
@@ -132,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201)
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
@@ -143,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()}
@router.get("/{quote_id}/proforma")
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "proforma")
@@ -151,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
@router.get("/{quote_id}/proforma/{attachment_id}")
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
async def download_proforma(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": _content_disposition(attachment.file_name),
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/proforma/{attachment_id}")
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+23 -73
View File
@@ -43,6 +43,7 @@ from app.utils.errors import (
UnauthorizedError,
ValidationError,
)
from app.utils.audit import record as audit_record
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
@@ -95,9 +96,11 @@ async def inventory_scope(
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# Dev fallback: header-based identity (fork's placeholder mode) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401.
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
# Casdoor). Disabled by default: unauthenticated header identity in
# production would hand admin access to anyone who can reach the API.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
if role == "procurement_manager":
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
@@ -271,12 +274,13 @@ async def update_warehouse(
wh.name = payload.name
if payload.description is not None:
wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id")
wh.project_id = payload.project_id
raise ValidationError(
"پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
field="project_id",
)
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@@ -310,7 +314,6 @@ async def delete_warehouse(
@router.get("")
async def list_inventory(
project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None,
material_id=None,
sort: str | None = None,
@@ -328,10 +331,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
if category_id is not None:
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
Material.category_id == category_id
)
if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None:
@@ -353,10 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
categories = {}
if materials:
cat_ids = {m.category_id for m in materials.values()}
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
brands = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -366,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [
inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id],
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
None, brands.get(i.brand_id), units.get(i.unit_id),
)
for i in items
]
@@ -492,6 +487,16 @@ async def create_transaction(
if dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record(
db,
actor=scope.actor,
action="inventory.transaction_created",
entity="inventory",
entity_id=str(txs[0].inventory_id) if txs else None,
details={"type": payload.transaction_type, "quantity": str(payload.quantity),
"warehouse_id": str(payload.warehouse_id),
"tx_ids": [str(tx.id) for tx in txs]},
)
return {"data": [transaction_to_dict(tx) for tx in txs]}
@@ -518,58 +523,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+11 -3
View File
@@ -4,11 +4,16 @@ State machine (P1 cross-endpoint rules):
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
pending -> rejected
chosen/rejected -> pending/anything-else = 409
Auth: JWT required globally (main.py). The state-changing endpoints are a
purchase decision — restricted to procurement_manager / admin via
require_role; reads stay open to every authenticated role.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
@@ -21,6 +26,9 @@ router = APIRouter(
tags=["item-decisions"],
)
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
_decision_writer = require_role("procurement_manager", "admin")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
@@ -59,7 +67,7 @@ async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db
return {"data": decision_full(decision)}
@router.put("")
@router.put("", dependencies=[Depends(_decision_writer)])
async def put_decision(
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
):
@@ -93,7 +101,7 @@ async def put_decision(
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
@router.patch("/status")
@router.patch("/status", dependencies=[Depends(_decision_writer)])
async def patch_decision_status(
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
):
@@ -118,7 +126,7 @@ async def patch_decision_status(
return {"data": decision_full(decision)}
@router.patch("")
@router.patch("", dependencies=[Depends(_decision_writer)])
async def patch_decision(
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
):
+13
View File
@@ -108,6 +108,7 @@ async def update_line_item(
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
await ensure_brand_material_belong(db, effective_bm, new_material_id)
old_material_id = li.material_id
if payload.material_id is not None:
li.material_id = payload.material_id
# v2.5: keep the supplier<->material catalog edge in sync.
@@ -121,6 +122,11 @@ async def update_line_item(
if payload.notes is not None:
li.notes = payload.notes
await db.flush()
if payload.material_id is not None and payload.material_id != old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
old_material_id = li.material_id
await db.delete(li) # specs / links / decision / receipt cascade via DDL
await db.flush()
if old_material_id:
from app.services.quote_service import cleanup_orphan_materials
await cleanup_orphan_materials(db, [old_material_id])
return {"data": {"id": str(line_item_id), "deleted": True}}
+9 -4
View File
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
# v2.7: materials no longer carry a category, so what blocks deletion is the
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
# material of that category is reached through those suppliers.
linked = await db.scalar(
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
)
if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست")
raise ConflictError(f"این دسته‌بندی برای {linked} تأمین‌کننده ثبت شده و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL
await db.flush()
return {"data": {"id": str(category_id), "deleted": True}}
-3
View File
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
async def list_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None,
status: str | None = None,
q: str | None = None,
@@ -107,8 +106,6 @@ async def list_materials(
db: AsyncSession = Depends(get_db),
):
stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
if brand_id:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
+23 -20
View File
@@ -19,8 +19,9 @@ Rules enforced here:
the canonical unit for auto-posted purchases.
- quote.status flips to 'arrived' when ALL chosen items are fully received.
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
X-Project-Id for pm) when no Authorization header — same pattern as the
Auth: Casdoor JWT (a global dependency in main.py) resolves the caller;
optional dev-header fallback (X-User-Role: pm|admin + X-Project-Id for pm)
exists only when PROCUREMENT_DEV_LOGIN_ENABLED=true — same pattern as the
inventory router.
"""
import base64
@@ -35,8 +36,9 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
@@ -48,6 +50,7 @@ from app.models.procurement import (
Supplier,
)
from app.schemas.procurement import PMReceiptCreate
from app.services import analytics
from app.services.inventory_service import canonical_specs_hash
from app.utils.errors import (
ForbiddenError,
@@ -98,9 +101,12 @@ async def arrivals_scope(
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)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
if not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM.
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager":
role = "admin"
@@ -195,18 +201,6 @@ async def pm_on_the_way(
# unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions}
cats = {}
if mat_ids:
rows = (
await db.execute(
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
out = []
for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id)
@@ -219,8 +213,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"material": {"id": str(m.id), "name": m.name},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
@@ -424,6 +417,16 @@ async def pm_confirm_receipt(
quote.arrived_date = payload.received_at
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
await analytics.track(
"receipt_confirmed",
{
"receipt_id": str(receipt.id),
"quote_id": str(quote.id),
"quantity": str(payload.received_qty),
"state": item_state,
},
profile_id=ctx.display_name,
)
return {"data": {
"receipt_id": str(receipt.id),
"state": item_state,
+27 -16
View File
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
from app.models.procurement import (
ItemDecision,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteProjects,
Supplier,
Termsheet,
)
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
@@ -36,15 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
def _quote_search(q: str) -> exists:
like = f"%{q}%"
@@ -72,7 +72,6 @@ async def get_quote_stats(
project_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db),
):
return {
@@ -82,7 +81,6 @@ async def get_quote_stats(
project_id=project_id,
quote_date_from=quote_date_from,
quote_date_to=quote_date_to,
category_id=category_id,
)
}
@@ -98,7 +96,6 @@ async def list_quotes(
status: str | None = None,
supplier_id=None,
project_id=None,
category_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
q: str | None = None,
@@ -112,9 +109,23 @@ async def list_quotes(
if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id:
stmt = stmt.where(Quote.project_id == project_id)
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
# any relevant link: legacy mirror, quote-level junction, line-item
# candidates, or an accepted (decision) target — NOT just the mirror
stmt = stmt.where(
Quote.id.in_(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
)
if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
+2 -7
View File
@@ -58,11 +58,7 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
return {"data": link_to_dict(link, material, category)}
return {"data": link_to_dict(link, material, None)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-146
View File
@@ -1,146 +0,0 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}
+6 -2
View File
@@ -21,6 +21,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
@@ -168,7 +169,7 @@ async def list_users(
@router.post("", status_code=201)
async def create_user(
payload: UserCreate,
_: UserContext = Depends(_org_manager),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Define a new user (manual identity — casdoor_id optional)."""
@@ -185,6 +186,7 @@ async def create_user(
)
db.add(user)
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})
return {"data": user_to_dict(user)}
@@ -192,7 +194,7 @@ async def create_user(
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
@@ -201,10 +203,12 @@ async def update_user_role(
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
previous_role = user.role
if user.role == "pm" and payload.role != "pm":
await _unassign_projects(db, user.id)
user.role = payload.role
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})
projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)}
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
class WarehouseUpdate(APIModel):
"""Rename/re-describe a warehouse.
`project_id` is accepted only to be rejected: a warehouse belongs
permanently to its parent project and cannot be reassigned (the router
answers 400 unless the value equals the current project).
"""
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
project_id: UUID | None = None
+1 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
"""Link a global material to a supplier."""
material_id: UUID
+77
View File
@@ -0,0 +1,77 @@
"""Server-side product analytics — OpenPanel Track API.
Fire-and-forget business events (quote lifecycle, receipts, logins) so the
OpenPanel dashboards show what actually happens inside the ERP, not just
page views. Never raises: analytics must not break a business flow.
Configuration (all optional — disabled when any is missing):
PROCUREMENT_OPENPANEL_API_URL e.g. http://openpanel-api:3333 (self-hosted)
PROCUREMENT_OPENPANEL_CLIENT_ID project client id (write mode)
PROCUREMENT_OPENPANEL_CLIENT_SECRET project client secret
Contract (OpenPanel Track API): POST {API_URL}/track with headers
``openpanel-client-id`` / ``openpanel-client-secret`` and a JSON body
``{"type": "track"|"identify", "profileId": ..., "event"/"properties": ...}``.
"""
import logging
from typing import Any
import httpx
from app.config import settings
logger = logging.getLogger("procurement.analytics")
_TIMEOUT = httpx.Timeout(3.0)
def _configured() -> bool:
return bool(
settings.OPENPANEL_API_URL
and settings.OPENPANEL_CLIENT_ID
and settings.OPENPANEL_CLIENT_SECRET
)
async def track(event: str, properties: dict[str, Any] | None = None, profile_id: str | None = None) -> None:
"""Record a named event; swallows every error."""
if not _configured():
return
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
# sends) — a flat body is rejected with 400, which this module swallows by design.
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
# rejected with a 400 that this module swallows.
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
if profile_id:
inner["profileId"] = profile_id
body = {"type": "track", "payload": inner}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json=body,
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001 — analytics must never raise
logger.debug("analytics track failed (%s): %s", event, exc)
async def identify(profile_id: str, properties: dict[str, Any] | None = None) -> None:
"""Create/update a user profile (e.g. on login)."""
if not _configured():
return
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
},
)
except Exception as exc: # noqa: BLE001
logger.debug("analytics identify failed: %s", exc)
+5 -11
View File
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
if "category" in expand:
cat_ids = {m.category_id for m in materials}
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
maps["categories"] = {c.id: c for c in rows}
unit_ids: set[int] = set()
if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials}
@@ -257,10 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at),
}
# Category is a supplier attribute now, not a material one — the key stays
# in the payload (null) so the contract shape is unchanged.
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
d["category"] = None
else:
d["category_id"] = str(m.category_id)
d["category_id"] = None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
@@ -326,9 +323,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set()
for bm in bms:
@@ -450,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"category": None,
"brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
@@ -110,9 +109,7 @@ async def validate_attributes(
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
+224 -62
View File
@@ -4,6 +4,7 @@ Every mutation here runs in ONE ``async with db.begin()`` block; any failure
rolls the whole operation back. All state changes log a quote_event in the
same transaction.
"""
import asyncio
import logging
from datetime import date, datetime, timezone
from uuid import UUID
@@ -33,6 +34,7 @@ from app.schemas.procurement import (
QuoteUpdate,
RejectQuoteIn,
)
from app.services import analytics
from app.services.material_service import ensure_brand_material_belong
from app.utils.errors import ConflictError, NotFoundError, ValidationError
@@ -56,6 +58,19 @@ def utcnow() -> datetime:
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
# Product analytics (fire-and-forget, never blocks or raises): the whole
# quote lifecycle funnels through here, so one hook covers every
# status transition for the OpenPanel dashboards.
try:
asyncio.get_running_loop().create_task(
analytics.track(
f"quote_{event_type}",
{"quote_id": str(quote_id), "actor": actor, **(payload or {})},
profile_id=actor,
)
)
except RuntimeError: # no running loop (defensive — callers are async)
pass
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
@@ -290,12 +305,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
return (count or 0) > 0
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
"""Check candidate material_ids and purge any that have zero references.
A material is considered an orphan if:
1. It is not referenced by ANY line_items across all quotes.
2. It is not referenced by ANY inventory stock items in warehouses.
If count == 0:
- Auxiliary records in supplier_materials, brand_materials, material_units,
and material_attribute_values are cleaned up.
- The material row is deleted from the central materials table.
Returns the list of purged material UUIDs.
"""
purged: list[UUID] = []
unique_ids = {m for m in material_ids if m is not None}
if not unique_ids:
return purged
from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
)
from app.models.inventory import InventoryItem
from app.models.procurement import LineItem, SupplierMaterial
for mat_id in unique_ids:
# Check active usage in line_items across the whole system
line_item_count = await db.scalar(
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
)
if (line_item_count or 0) > 0:
continue
# Check active usage in warehouse inventory
inv_count = await db.scalar(
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
)
if (inv_count or 0) > 0:
continue
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
await db.execute(
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
)
await db.execute(
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
)
await db.execute(
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
)
await db.execute(
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
)
await db.execute(
delete(Material).where(Material.id == mat_id)
)
purged.append(mat_id)
logger.info("Purged orphan material %s from central catalog", mat_id)
return purged
async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered and has no warehouse receipts.
Automatically purges any materials detached during this replacement if they become orphans.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
@@ -309,6 +390,11 @@ async def replace_quote_children(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status",
)
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
@@ -317,10 +403,21 @@ async def replace_quote_children(
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
)
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
await db.flush()
# 2. Identify detached materials: previous materials no longer present in the updated line items
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
detached_material_ids = old_material_ids - new_material_ids
# 3. Clean up any detached materials that are now orphans (0 usage)
if detached_material_ids:
await cleanup_orphan_materials(db, detached_material_ids)
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -346,7 +443,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute(
update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.where(ItemDecision.line_item_id.in_(li_ids))
.values(
status="pending",
decided_at=None,
@@ -524,21 +621,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
# termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids:
@@ -735,30 +818,49 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
# clean up attachments in MinIO before cascade
# collect candidate material_ids for orphan cleanup before deleting quote
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
# collect attachment keys before deleting quote row
from app.models.files import QuoteAttachment
from app.services import storage
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
for att in attachments:
try:
await storage.remove_object(att.object_key)
except Exception:
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
att_keys = [att.object_key for att in attachments]
quote_prefix = f"{quote.id}/"
await db.delete(quote)
return {
"id": str(quote.id),
"deleted": True,
"cascaded": {
"line_items": len(line_items),
"termsheets": len(termsheets),
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
"attachments": len(attachments),
},
}
await db.flush()
# Purge any materials that no longer have any references in the system
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
for key in att_keys:
try:
await storage.remove_object(key)
except Exception:
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
try:
await storage.remove_prefix(quote_prefix)
except Exception:
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
return {
"id": str(quote_id),
"deleted": True,
"cascaded": {
"line_items": len(line_items),
"termsheets": len(termsheets),
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
"attachments": len(attachments),
"purged_orphan_materials": len(purged_material_ids),
},
}
# ---------------------------------------------------------------------------
@@ -771,7 +873,6 @@ async def quote_stats(
project_id: UUID | None = None,
quote_date_from=None,
quote_date_to=None,
category_id: UUID | None = None,
) -> dict:
conditions = []
if supplier_id is not None:
@@ -782,8 +883,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = (
await db.execute(
@@ -815,38 +914,46 @@ async def quote_stats(
}
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for one project.
"""Counts + total value per quote status for ONE project, isolated per project.
total_value: SUM(quantity × unit_price) over line items using the chosen
termsheet link first, else the first link. Rejected line items are
excluded from value sums (contract P1-14).
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
least one non-rejected line item's decision targets this project
(item_decisions.project_id). A quote merely linked to the project as a
candidate (quote-level link, line-item candidates, or accepted for a
DIFFERENT project) stays in «offered» — this project never accepted it.
total_value: SUM(quantity × unit_price) over the bucket's line items,
using the chosen termsheet link first, else the first link. Rejected
line items are excluded from value sums (contract P1-14).
"""
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
# the route passes the raw path string — normalize for the Python-side
# UUID comparisons below (decision.project_id == project_id etc.)
project_id = UUID(str(project_id))
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
if not quotes:
# --- every quote RELEVANT to this project (any kind of link) ---
quote_id_rows = await db.execute(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
quote_ids = [row[0] for row in quote_id_rows.all()]
if not quote_ids:
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty}
quote_ids = [q.id for q in quotes]
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
li_ids = [li.id for li in line_items]
@@ -854,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {}
# line items explicitly linked to this project as candidates
candidate_li_ids = set(
(
await db.execute(
select(LineItemProjects.line_item_id).where(
LineItemProjects.line_item_id.in_(li_ids),
LineItemProjects.project_id == project_id,
)
)
).scalars().all()
) if li_ids else set()
item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
@@ -862,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it)
def _items_of(quote: Quote) -> list[LineItem]:
return [li for li in line_items if li.quote_id == quote.id]
def _accepted_for_project(quote: Quote) -> bool:
"""≥1 non-rejected line item whose decision targets THIS project."""
return any(
(decision := decisions.get(li.id)) is not None
and decision.status == "chosen"
and decision.project_id == project_id
for li in _items_of(quote)
)
def _has_any_target(quote: Quote) -> bool:
"""Whether any decision of this quote carries a target project at all
(legacy quotes accepted before the decision-project era have none —
they keep the old global-status classification)."""
return any(
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
for li in _items_of(quote)
)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes:
per_status[q.status]["count"] += 1
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
else:
per_status[q.status]["count"] += 1
for li in line_items:
quote = next(q for q in quotes if q.id == li.quote_id)
decision = decisions.get(li.id)
# --- which status bucket does this line item's value belong to? ---
accepted_here = (
decision is not None
and decision.status == "chosen"
and decision.project_id == project_id
)
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
if accepted_here:
bucket = quote.status
elif li.id in candidate_li_ids:
# the quote is still just a proposal for this project — the
# item's candidate value stays in the offered bucket
bucket = "offered"
else:
continue # this item targets another project entirely
else:
bucket = quote.status
if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -877,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None
if price_it is None:
continue
quote = next(q for q in quotes if q.id == li.quote_id)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
+19
View File
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key)
async def remove_prefix(prefix: str) -> None:
"""Delete all objects matching a prefix in the bucket (best-effort)."""
client = get_client()
def _sync_remove_prefix():
try:
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
for obj in objects:
try:
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
except Exception:
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
except Exception:
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
await asyncio.to_thread(_sync_remove_prefix)
@@ -1,10 +1,6 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
Linking a material to a supplier derives materials.category_id from the
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
Suppliers can supply any global material across categories.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
"""Upsert a supplier<->material link (idempotent).
Caller is responsible for the surrounding transaction.
"""
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
"""Remove the link."""
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
+41
View File
@@ -0,0 +1,41 @@
"""Audit helper — one row per sensitive mutation, written in the SAME
transaction as the change (rolled back together with it).
await record(db, actor=ctx.display_name, action="user.role_changed",
entity="user", entity_id=str(user.id),
details={"from": old_role, "to": user.role})
Never raises: an audit failure must not fail the business operation.
"""
import logging
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
logger = logging.getLogger("procurement.audit")
async def record(
db: AsyncSession,
*,
actor: str | None,
action: str,
entity: str,
entity_id: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
try:
db.add(
AuditLog(
actor=actor,
action=action,
entity=entity,
entity_id=str(entity_id) if entity_id else None,
details=details or {},
)
)
await db.flush()
except Exception: # noqa: BLE001 — audit must never break the caller
logger.exception("failed to write audit_log row (%s/%s)", action, entity)
+71
View File
@@ -0,0 +1,71 @@
"""Ephemeral, URL-safe file-preview tokens.
Why this exists
---------------
File downloads require an ``Authorization: Bearer`` header, but a browser
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
src>`` — which is what the preview modal uses. The previous workaround
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
outside the originating document, and hand the browser a 0-byte file under
a bare UUID name — so the preview broke and a junk download appeared.
Instead we mint a short-lived HMAC token scoped to a single attachment and
put it in the query string, giving the iframe a *real* URL that also
carries its own authorization. The token is not a session credential: it
authorises exactly one (quote_id, kind, attachment_id) triple and expires
in minutes.
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
"""
from __future__ import annotations
import hashlib
import hmac
import time
import uuid
from app.config import settings
# پیش‌نمایش کوتاه‌عمر است — فقط برای باز شدن مودال، نه اشتراک‌گذاری لینک
DEFAULT_TTL_SECONDS = 300
def _secret() -> bytes:
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
base = (
settings.CASDOOR_CLIENT_SECRET
or settings.MINIO_SECRET_KEY
or "procurement-preview-fallback"
)
return base.encode("utf-8")
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
def make_preview_token(
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
) -> str:
expiry = int(time.time()) + ttl
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
def verify_preview_token(
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
) -> bool:
"""True when the token is well-formed, unexpired and matches this file."""
if not token or "." not in token:
return False
expiry_raw, signature = token.split(".", 1)
try:
expiry = int(expiry_raw)
except ValueError:
return False
if expiry < int(time.time()):
return False
expected = _sign(quote_id, kind, attachment_id, expiry)
return hmac.compare_digest(expected, signature)
+11
View File
@@ -0,0 +1,11 @@
"""Shared rate limiter (slowapi) — in-memory, single-worker friendly.
Login brute-force brake: the edge (Traefik) has a coarse per-IP limit for
the whole API host; this adds a tight limit on the credential endpoint
itself. Kept in its own module so routers can import it without a circular
import on app.main.
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+28 -9
View File
@@ -1,14 +1,33 @@
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
"""Attribution for quote_events.actor — the VERIFIED user, not the raw token.
Historically this stored the bearer token string verbatim (unverifiable and
a token leak into the database). All routes that use it now sit behind the
global JWT dependency, so the token is resolved to the authenticated user
and the display name is recorded instead.
"""
from fastapi import Request
from app.auth import get_optional_user
def get_actor(request: Request) -> str | None:
"""Extract the bearer token (if any) to attribute quote_events.actor.
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
async def get_actor(request: Request) -> str | None:
"""Resolve the caller's display name from the Casdoor Bearer token.
Uses its own session (get_optional_user default) — same reason as
get_current_user: quote routers open explicit db.begin() blocks.
"""
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
ctx = await get_optional_user(request)
if ctx is not None:
return ctx.display_name
# Dev-mode header fallback (only active when DEV_LOGIN_ENABLED) carries
# the name in X-User-Name / X-User-Name-B64 — keep attribution useful.
name = request.headers.get("x-user-name")
import base64
b64 = request.headers.get("x-user-name-b64")
if b64:
try:
name = base64.b64decode(b64.encode()).decode("utf-8")
except Exception:
pass
return name
+10
View File
@@ -10,3 +10,13 @@ minio>=7.2.0
# v2.6 — Casdoor JWT auth
pyjwt[crypto]>=2.8.0
httpx>=0.27.0
# Casdoor seeding — bcrypt password hashes (never plaintext)
bcrypt>=4.1.0
# Observability — metrics + Sentry/GlitchTip exception capture
prometheus-fastapi-instrumentator>=7.0.0
sentry-sdk[fastapi]>=2.0.0
# Rate limiting (login brute-force brake; edge limit lives in Traefik)
slowapi>=0.1.9
+4 -4
View File
@@ -209,7 +209,7 @@ async def run() -> None:
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions "
"standard_conversions "
"RESTART IDENTITY CASCADE"
))
# Only the 8 canonical topic categories may exist — any leftover slug
@@ -261,9 +261,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
category_id=cat.id,
name=name,
canonical_unit_id=unit_ids[unit_code],
status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush()
suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
target_slug = sup_cat_slug.get(sup.name)
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
for m in cat_materials[:2]:
db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True,
+34 -15
View File
@@ -20,22 +20,34 @@ import json
import os
import urllib.request
import bcrypt
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
# Casdoor's own bootstrap default for the built-in console admin is "123" —
# the seeder LOGS IN with it (it cannot change the console password).
# Override with CASDOOR_ADMIN_PW after rotating (docs/SECRET-MANAGEMENT.md);
# production MUST set a real value here once the console password is rotated.
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
ORG = "inventory"
APP = "inventory-app"
CLIENT_ID = "inventory-app-client"
CLIENT_SECRET = "inventory-app-secret-2026"
# Must MATCH the API's PROCUREMENT_CASDOOR_CLIENT_SECRET (compose default
# below) — changing one without the other breaks POST /auth/login.
CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "inventory-app-secret-2026")
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD;
# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext.
SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456")
USERS = [
# (name, first_name, last_name, email, password)
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"),
("tester", "کاربر", "تست", "tester@sample.ir", "123456"),
# (name, first_name, last_name, email)
("razaei", "آقای", "رضایی", "razaei@sample.ir"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
("tester", "کاربر", "تست", "tester@sample.ir"),
]
@@ -69,17 +81,21 @@ def _login():
return jar
def _bcrypt(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def main():
cookies = _login()
if cookies is None:
return
# 1) organization (passwordType "plain" = dev: passwords stored as-is)
# 1) organization (bcrypt — passwords are never stored plaintext)
status, body = _request("POST", "/api/add-organization", {
"owner": "admin",
"name": ORG,
"displayName": "سازمان تدارکات",
"passwordType": "plain",
"passwordType": "bcrypt",
}, cookies)
print("add-organization:", status, body.get("msg", body.get("error", "")))
@@ -102,7 +118,8 @@ def main():
print("add-application:", status, body.get("msg", body.get("error", "")))
# 3) users (roles are NOT stored here — they live in the app's users table)
for name, first, last, email, pw in USERS:
pw_hash = _bcrypt(SEED_PASSWORD)
for name, first, last, email in USERS:
status, body = _request("POST", "/api/add-user", {
"owner": ORG,
"name": name,
@@ -110,21 +127,23 @@ def main():
"firstName": first,
"lastName": last,
"email": email,
"password": pw,
"passwordType": "plain",
"password": pw_hash,
"passwordType": "bcrypt",
"phone": "",
"type": "normal-user",
}, cookies)
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
# Ensure passwordType even for already-existing users (this Casdoor
# version refuses login when both user and org passwordType are empty).
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", {
"passwordType": "plain",
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", {
"passwordType": "bcrypt",
"password": pw_hash,
}, cookies)
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
print("Login in the app: http://localhost:3000/login")
print("\nDone. Casdoor console: http://localhost:8080")
print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
print("Login in the app: http://localhost/login")
if __name__ == "__main__":
+145 -61
View File
@@ -1,9 +1,14 @@
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
Run: .venv/Scripts/python.exe tools/smoke_test.py
Dataset-independent: assertions verify BEHAVIOR (status codes, envelopes,
shapes, filtering) rather than exact seed counts, so the suite works with
any seed version. Run from the api container or a host with access:
Run: python tools/smoke_test.py
Env: SMOKE_USER / SMOKE_PASSWORD (default tester / 123456 — seeded users)
"""
import os
import sys
from pprint import pprint
import httpx
@@ -23,84 +28,150 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- catalog ----------
r = c.get("/units", params={"page_size": 5})
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
units = r.json()["data"] if r.status_code == 200 else []
check("GET /units", r.status_code == 200 and len(units) == 5 and "si_factor" in units[0])
r = c.get("/units", params={"dimension": "mass"})
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
if units:
dim = units[0]["dimension"]
r = c.get("/units", params={"dimension": dim, "page_size": 100})
body = r.json()
check(
"GET /units?dimension filter",
r.status_code == 200 and body["meta"]["total"] >= 1
and all(u["dimension"] == dim for u in body["data"]),
r.text[:200],
)
r = c.get("/brands")
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
brands = r.json()["data"] if r.status_code == 200 else []
check("GET /brands", r.status_code == 200 and brands and r.json()["meta"]["total"] >= 1)
r = c.get("/material-categories", params={"q": "سیمان"})
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
if r.status_code == 200 and r.json()["data"]:
m = r.json()["data"][0]
# category q-search (only when categories exist)
r = c.get("/material-categories", params={"page_size": 100})
cats = r.json()["data"] if r.status_code == 200 else []
check("GET /material-categories", r.status_code == 200 and isinstance(cats, list))
if cats:
needle = cats[0]["name"][:3]
r = c.get("/material-categories", params={"q": needle})
check(
"GET /materials expand",
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
"GET /material-categories?q (unicode search)",
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
)
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
# materials: q-search against a REAL seeded name (fetched, not hard-coded)
r = c.get("/materials", params={"page_size": 100})
materials = r.json()["data"] if r.status_code == 200 else []
check("GET /materials", r.status_code == 200 and materials and r.json()["meta"]["total"] >= 1)
if materials:
target = materials[0]
needle = target["name"].split()[0]
r = c.get("/materials", params={"q": needle})
check(
"GET /materials?q (unicode search)",
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
)
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": needle})
expanded = [m for m in r.json().get("data", []) if m["id"] == target["id"]]
if r.status_code == 200 and expanded:
m = expanded[0]
check(
"GET /materials expand",
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
)
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
check("expand brands shape", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
else:
check("GET /materials expand", False, r.text[:300])
# ---------- EAV filters (positive filter only when the seed has values) ----------
r = c.get("/attribute-definitions", params={"page_size": 100})
attr_defs = r.json()["data"] if r.status_code == 200 else []
if attr_defs and materials:
r = c.get("/materials", params={"expand": "attributes", "page_size": 100})
with_attrs = [m for m in r.json().get("data", []) if m.get("attributes")]
if with_attrs:
m0 = with_attrs[0]
key = m0["attributes"][0]["attribute_key"]
value = str(m0["attributes"][0]["value"])
r = c.get("/materials", params={f"attr_{key}": value, "expand": "attributes"})
check(
f"GET /materials?attr_{key} filter",
r.status_code == 200 and any(x["id"] == m0["id"] for x in r.json().get("data", [])),
r.text[:200],
)
else:
print(" SKIP EAV positive filter — seed defines no material attribute values")
else:
check("GET /materials expand", False, r.text[:300])
print(" SKIP EAV positive filter — seed defines no attribute definitions")
# dynamic EAV filter: attr_rebar_diameter=14
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
if r.status_code == 200:
data = r.json()["data"]
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
else:
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
# EAV boolean filter: attr_rebar_ribbed=true
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
# brand filter
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand_id = r.json()["data"][0]["id"]
r = c.get("/materials", params={"brand_id": brand_id})
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
# unknown attr filter -> 400
# unknown attr filter -> 400 (dataset-independent)
r = c.get("/materials", params={"attr_nonexistent": "1"})
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
r = c.get("/materials?page_size=999")
check("page_size>100 -> 400", r.status_code == 400)
# brand filter via a brand that actually has material links
r = c.get("/brand-materials", params={"page_size": 100})
brand_links = r.json()["data"] if r.status_code == 200 else []
if brand_links:
brand_id = brand_links[0]["brand_id"]
r = c.get("/materials", params={"brand_id": brand_id})
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
r = c.get("/brand-materials", params={"brand_id": brand_id})
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
else:
check("GET /materials?brand_id=", False, "seed has no brand-material links")
# ---------- suppliers / projects ----------
r = c.get("/suppliers")
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
suppliers = r.json()["data"] if r.status_code == 200 else []
check("GET /suppliers", r.status_code == 200 and suppliers)
r = c.get("/projects")
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
projects = r.json()["data"] if r.status_code == 200 else []
check("GET /projects", r.status_code == 200 and projects)
r = c.get("/attribute-definitions", params={"expand": "unit"})
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
r = c.get("/attribute-definitions")
attr_shape_ok = r.status_code == 200 and all(
set(a) >= {"attribute_key", "label", "data_type"} for a in r.json().get("data", [])
)
check("GET /attribute-definitions", attr_shape_ok, r.text[:200])
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
r = c.get("/brand-materials", params={"brand_id": brand_id})
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
if cats:
r = c.get(f"/material-categories/{cats[0]['id']}/attributes")
check(
"GET category attributes (nested)",
r.status_code == 200 and isinstance(r.json()["data"], list)
and all("attribute" in a for a in r.json()["data"]),
r.text[:200],
)
# ---------- quotes ----------
r = c.get("/quotes", params={"page_size": 5})
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
quotes = r.json()["data"] if r.status_code == 200 else []
check("GET /quotes", r.status_code == 200 and quotes and r.json()["meta"]["total"] >= 1)
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
if r.status_code == 200 and r.json()["data"]:
q = r.json()["data"][0]
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project"})
accepted = [q for q in r.json().get("data", []) if q.get("line_items")] if r.status_code == 200 else []
if accepted:
q = accepted[0]
li = q["line_items"][0]
check(
"GET /quotes expand full shape",
@@ -111,21 +182,34 @@ if r.status_code == 200 and r.json()["data"]:
r.text[:400],
)
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
if q["termsheets"]:
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
else:
check("GET /quotes expand full shape", False, r.text[:400])
r = c.get("/quotes/stats")
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
stats_ok = r.status_code == 200 and "average_lead_time_days" in r.json()["data"]
if quotes:
stats_ok = stats_ok and r.json()["data"]["total"] == r.json()["data"]["offered"] + \
r.json()["data"]["accepted"] + r.json()["data"]["arrived"] + \
r.json()["data"]["rejected"] + r.json()["data"]["expired"]
check("GET /quotes/stats", stats_ok, r.text[:300])
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
r = c.get("/quotes", params={"q": "میلگرد"})
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
if materials:
needle = materials[0]["name"].split()[0]
r = c.get("/quotes", params={"q": needle})
check("GET /quotes?q (searches material names)", r.status_code == 200, r.text[:300])
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
if projects:
r = c.get(f"/projects/{projects[0]['id']}/stats")
check(
"GET /projects/{id}/stats",
r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"},
r.text[:300],
)
print(f"\n===== {PASS} passed, {FAIL} failed =====")
sys.exit(1 if FAIL else 0)
+93 -43
View File
@@ -19,9 +19,20 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
import os
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- units ----------
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
check("POST /units", r.status_code == 201 and r.json()["data"]["si_factor"] == 1.0, r.text[:200])
uid = r.json()["data"]["id"]
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
@@ -42,21 +53,28 @@ r = c.patch(f"/brands/{bid}", json={"name": "برند تست ۲"})
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
r = c.delete(f"/brands/{bid}")
check("DELETE /brands (unused)", r.status_code == 200)
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand = r.json()["data"][0]
_bml = c.get("/brand-materials", params={"page_size": 1}).json()["data"]
if _bml:
brand = {"id": _bml[0]["brand_id"]}
else:
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
r = c.delete(f"/brands/{brand['id']}")
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
# ---------- categories ----------
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
check("POST /material-categories", r.status_code == 201, r.text[:200])
cid = r.json()["data"]["id"]
if r.status_code == 409: # rerun safety — fetch the existing test category
cid = c.get("/material-categories", params={"q": "test-cat"}).json()["data"][0]["id"]
check("POST /material-categories (exists from previous run)", True)
else:
check("POST /material-categories", r.status_code == 201, r.text[:200])
cid = r.json()["data"]["id"]
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
r = c.delete(f"/material-categories/{cid}")
check("DELETE /material-categories (empty)", r.status_code == 200)
r = c.get("/material-categories", params={"q": "میلگرد"})
cat = r.json()["data"][0]
_mat0 = c.get("/materials", params={"page_size": 1, "expand": "category"}).json()["data"][0]
cat = _mat0["category"]
r = c.delete(f"/material-categories/{cat['id']}")
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
@@ -75,11 +93,16 @@ r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "number"})
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
# use a DEDICATED test material so the seed material's EAV values stay intact
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
# (dataset-independent: any category; rerun reuses the existing test material)
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
tmat = r.json()["data"]
if r.status_code == 409: # rerun
tmat = c.get("/materials", params={"q": "متریال تست EAV"}).json()["data"][0]
check("create test material for EAV tests (exists from previous run)", True)
else:
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
tmat = r.json()["data"]
mat = tmat
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": [
{"attribute_definition_id": adid, "value": 5},
@@ -99,7 +122,11 @@ check("DELETE attribute-definition (no values) -> 200", r.status_code == 200, r.
# ---------- category attributes ----------
# use a FRESH definition so the pair is not already in the seed junction
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
newdef = r.json()["data"]
if r.status_code == 409: # rerun safety — fetch the existing definition
newdef = next(d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]
if d["attribute_key"] == "test_ca_attr")
else:
newdef = r.json()["data"]
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"]})
@@ -114,10 +141,11 @@ check("cleanup: DELETE unused attribute-definition", r.status_code == 200)
# ---------- material units ----------
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
check("GET material units", r.status_code == 200, r.text[:200])
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 0.001, "is_default": False})
_unit_other = next(u for u in c.get("/units", params={"page_size": 100}).json()["data"] if u["code"] != "kg")
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 0.001, "is_default": False})
check("POST material unit", r.status_code == 201, r.text[:200])
muid = r.json()["data"]["id"]
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 2})
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 2})
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
@@ -125,10 +153,10 @@ r = c.delete(f"/materials/{mat['id']}/units/{muid}")
check("DELETE material unit", r.status_code == 200)
# ---------- brand materials ----------
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
m2 = r.json()["data"][0]
r = c.get("/brands", params={"q": "Bosch"})
b2 = r.json()["data"][0]
# dataset-independent: any material + a freshly created brand (no existing link)
m2 = c.get("/materials", params={"page_size": 1}).json()["data"][0]
r = c.post("/brands", json={"name": "برند لینک تست", "country": "ایران"})
b2 = r.json()["data"] if r.status_code == 201 else c.get("/brands", params={"q": "برند لینک تست"}).json()["data"][0]
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
check("POST brand-material", r.status_code == 201, r.text[:200])
bmid = r.json()["data"]["id"]
@@ -140,18 +168,18 @@ r = c.get(f"/brand-materials/{bmid}", params={"expand": "brand,material"})
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
r = c.delete(f"/brand-materials/{bmid}")
check("DELETE brand-material", r.status_code == 200)
r = c.delete(f"/brands/{b2['id']}")
check("cleanup: DELETE link-test brand", r.status_code == 200, r.text[:200])
# ---------- supplier/project patches ----------
r = c.get("/suppliers", params={"q": "آریا"})
s1 = r.json()["data"][0]
s1 = c.get("/suppliers", params={"page_size": 1}).json()["data"][0]
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
r = c.get("/projects", params={"q": "ونوس"})
p1 = r.json()["data"][0]
p1 = c.get("/projects", params={"page_size": 1}).json()["data"][0]
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
r = c.patch(f"/projects/{p1['id']}", json={"description": "ساختمان ۱۸ طبقه مسکونی در زعفرانیه"})
r = c.patch(f"/projects/{p1['id']}", json={"description": p1.get("description")})
# ---------- quote PATCH metadata ----------
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
@@ -166,16 +194,17 @@ r = c.get(f"/quotes/{qid}/events")
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
# ---------- termsheet delete rules ----------
# termsheet-count tolerance: this suite needs >=2 termsheets on the picked quote
r = c.get(f"/quotes/{qid}/termsheets")
ts = r.json()["data"]
if len(ts) == 1:
while len(ts) < 2:
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
check("POST second termsheet", r.status_code == 201, r.text[:200])
ts = r.json()["data"]
check("POST additional termsheet", r.status_code == 201, r.text[:200])
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
_remaining = c.get(f"/quotes/{qid}/termsheets").json()["data"]
r = c.delete(f"/quotes/{qid}/termsheets/{_remaining[-1]['id']}")
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
# restore a second termsheet for later tests
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
@@ -192,31 +221,52 @@ if len(steps) > 1:
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
# ---------- decision rules (on a pending quote) ----------
r = c.get(f"/quotes/{qid}/line-items")
li = r.json()["data"][0]
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
# ---------- decision rules (need a PENDING decision — rerun-safe pick) ----------
_pending = None
for _qo in c.get("/quotes", params={"status": "offered", "page_size": 20, "expand": "line_items"}).json()["data"]:
for _lix in _qo["line_items"]:
if _lix.get("decision") and _lix["decision"]["status"] == "pending":
_pending = (_qo["id"], _lix)
break
if _pending:
break
if _pending:
_qid2, li = _pending
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "rejected"})
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen"})
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/quotes/{_qid2}/line-items/{li['id']}/decision/status", json={"status": "pending"})
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
else:
print(" SKIP decision rules — no offered quote with a pending decision")
# ---------- item termsheets ----------
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
its = r.json()["data"]
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
# termsheet-count tolerance marker
if _pending:
_its_quote, li = _pending
r = c.get(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets")
its = r.json().get("data", [])
if its:
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
else:
print(" SKIP PATCH item-termsheet — picked line item has no item pricing rows")
else:
print(" SKIP PATCH item-termsheet — no pending decision available")
# ---------- invoice upload on accepted/arrived (multi-file) ----------
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
arrived = r.json()["data"]
if arrived:
qa = arrived[0]
# rerun safety — remove invoices left by previous runs
for _inv in c.get(f"/quotes/{qa['id']}/invoice").json()["data"]:
c.delete(f"/quotes/{qa['id']}/invoice/{_inv['id']}")
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
+109 -39
View File
@@ -23,16 +23,45 @@ def check(name, ok, extra=""):
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
import os
r = c.post("/auth/login", json={
"username": os.environ.get("SMOKE_USER", "tester"),
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
})
check("POST /auth/login", r.status_code == 200, r.text[:200])
if r.status_code == 200:
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
# ---------- supplier + project for the test quote ----------
sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران"}).json()["data"]
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
check("POST /suppliers + /projects", True)
# dataset-independent: fetch/create whatever the seed does or doesn't have
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
r_sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران", "category_id": cat["id"]})
if r_sup.status_code == 201:
sup = r_sup.json()["data"]
check("POST /suppliers", True)
else: # rerun — the test supplier already exists
sup = c.get("/suppliers", params={"q": "تأمین‌کننده تست"}).json()["data"][0]
check("POST /suppliers (exists from previous run — 409 tolerated)", r_sup.status_code == 409, r_sup.text[:200])
r_proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"})
if r_proj.status_code == 201:
proj = r_proj.json()["data"]
check("POST /projects", True)
else:
proj = c.get("/projects", params={"q": "پروژه تست"}).json()["data"][0]
check("POST /projects (exists from previous run — 409 tolerated)", r_proj.status_code == 409, r_proj.text[:200])
# ---------- material with EAV + brands ----------
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).json()["data"][0]
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
# ensure the EAV keys this suite exercises exist (seed-independent)
for _key, _dt in (("rebar_grade", "string"), ("rebar_diameter", "number"), ("rebar_ribbed", "boolean")):
if _key not in defs:
r = c.post("/attribute-definitions", json={"attribute_key": _key, "label": _key, "data_type": _dt})
check("POST /attribute-definitions (%s)" % _key, r.status_code == 201, r.text[:200])
defs[_key] = r.json()["data"]
r = c.post(
"/materials",
@@ -48,11 +77,15 @@ r = c.post(
],
},
)
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
material = r.json()["data"]
if r.status_code == 201:
material = r.json()["data"]
check("POST /materials (EAV+brands)", True)
else: # rerun — fetch the material a previous run created
material = c.get("/materials", params={"q": "میلگرد تست A3 سایز ۱۶"}).json()["data"][0]
check("POST /materials (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
check("new material findable via EAV filter", r.status_code == 200 and any(m["id"] == material["id"] for m in r.json()["data"]))
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
@@ -68,21 +101,32 @@ r = c.get("/category-reference-units", params={"category_id": cat["id"]})
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
uc = r.json()["data"]
if r.status_code == 409: # rerun — reuse the existing conversion
uc = next(x for x in c.get("/unit-conversions", params={"page_size": 100}).json()["data"]
if x["from_unit"] == "کیلوگرم" and x["to_unit"] == "متر" and x["category"]["id"] == cat["id"])
check("POST /unit-conversions (exists from previous run — 409 tolerated)", True)
else:
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
uc = r.json()["data"]
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
check("POST /unit-conversions duplicate -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
sc_id = r.json()["data"]["id"]
if r.status_code == 201:
sc_id = r.json()["data"]["id"]
check("POST /standard-conversions", True)
else: # rerun — reuse the existing conversion
sc_id = next(x["id"] for x in c.get("/standard-conversions", params={"page_size": 100}).json()["data"]
if x["from_unit"] == "تن" and x["to_unit"] == "کیلوگرم")
check("POST /standard-conversions (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
r = c.get("/standard-conversions")
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
# ---------- nested quote creation ----------
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
_all_materials = c.get("/materials", params={"page_size": 100}).json()["data"]
material2 = next(m for m in _all_materials if m["id"] != material["id"])
r = c.post(
"/quotes",
json={
@@ -125,7 +169,11 @@ check("DELETE /quotes offered -> OK", r.status_code == 200 and r.json()["data"][
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [], "item_termsheets": []})
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
# current contract: termsheets are optional at quote level (item pricing
# is driven by item_termsheets) — a quote without them is valid
check("POST /quotes empty termsheets -> 201 (optional since v2.6)", r.status_code == 201, r.text[:200])
if r.status_code == 201:
c.delete(f"/quotes/{r.json()['data']['id']}")
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
@@ -233,33 +281,49 @@ check("GET receipt of rejected item -> 404", r.status_code == 404)
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
# ---------- backward transition ----------
# ---------- backward transition (v2.6 rule: quotes WITH inventory receipts
# are immutable — they feed the stock ledger) ----------
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
check("PATCH backward accepted->offered", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
check(
"PATCH backward accepted->offered blocked after receipts",
r.status_code == 409 and "رسید" in r.json()["error"]["message"], r.text[:300],
)
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], str(decisions))
r = c.get(f"/quotes/{qid}/termsheets")
check("decisions unchanged after blocked revert", decisions == ["chosen", "chosen", "rejected"], str(decisions))
# transition tests need a quote WITHOUT receipts — q3:
q3 = c.post("/quotes", json={
"supplier_id": sup["id"], "project_id": proj["id"],
"quote_date": "2026-08-02T10:00:00Z", "expiry_date": "2026-08-20",
"line_items": [{"temp_id": "li-x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 2}],
"termsheets": [{"temp_id": "ts-x", "label": "نقدی", "schedule_steps": [{"due_date": "2026-08-10", "amount": 5000}]}],
"item_termsheets": [{"line_item_temp_id": "li-x", "termsheet_temp_id": "ts-x", "unit_price": 2500}],
}).json()["data"]
r = c.patch(f"/quotes/{q3['id']}/accept", json={
"accepted_termsheets": [{"id": q3["termsheets"][0]["id"],
"schedule_steps": [{"step_no": 1, "due_date": "2026-08-10", "amount": 5000}]}],
"item_decisions": [{"line_item_id": q3["line_items"][0]["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 2500}],
})
check("q3 accept", r.status_code == 200, r.text[:300])
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
check("PATCH backward accepted->offered (no receipts) -> OK", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
r = c.get(f"/quotes/{q3['id']}", params={"expand": "line_items"})
check("decisions reset to pending", [li["decision"]["status"] for li in r.json()["data"]["line_items"]] == ["pending"], r.text[:200])
r = c.get(f"/quotes/{q3['id']}/termsheets")
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
receipts_kept = all(li.get("receipt") is not None for li in r.json()["data"]["line_items"] if li["decision"])
check("item_receipts NOT deleted on revert", receipts_kept, r.text[:200])
r = c.patch(f"/quotes/{qid}", json={"status": "accepted"})
r = c.patch(f"/quotes/{q3['id']}", json={"status": "accepted"})
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
# ---------- reject / expire ----------
r = c.patch(f"/quotes/{qid}/reject", json={"reason": "قیمت بالا"})
r = c.patch(f"/quotes/{q3['id']}/reject", json={"reason": "قیمت بالا"})
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.get(f"/quotes/{qid}/events")
r = c.get(f"/quotes/{q3['id']}/events")
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
r = c.patch(f"/quotes/{qid}", json={"status": "offered"})
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
r = c.patch(f"/quotes/{qid}/expire")
r = c.patch(f"/quotes/{q3['id']}/expire")
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
r = c.delete(f"/quotes/{qid}")
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, r.text[:300])
r = c.delete(f"/quotes/{q3['id']}")
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 1, r.text[:300])
# ---------- files ----------
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
@@ -269,8 +333,13 @@ q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
# current contract: attachments are allowed in EVERY status (files.py) —
# the invoice can be attached from the edit page at any time
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
check("POST invoice on offered -> 409", r.status_code == 409, r.text[:200])
check("POST invoice on offered (any-status contract)", r.status_code == 201, r.text[:200])
inv_id = r.json()["data"]["id"]
r = c.delete(f"/quotes/{q2['id']}/invoice/{inv_id}")
check("DELETE test invoice", r.status_code == 200, r.text[:200])
r = c.get(f"/quotes/{q2['id']}/proforma")
lst = r.json()["data"]
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
@@ -282,8 +351,8 @@ check("DELETE proforma by id", r.status_code == 200 and r.json()["data"]["delete
r = c.get(f"/quotes/{q2['id']}/proforma")
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
# cleanup test material + supplier + project
# q2 still references supplier/project/material -> all deletes must be blocked
# ---------- cleanup ----------
# q2 (offered) is deletable; qid is a receipts-protected artifact that stays.
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
@@ -291,14 +360,15 @@ check("DELETE project with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/materials/{material['id']}")
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
r = c.delete(f"/quotes/{q2['id']}")
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
# qid (arrived, receipt-protected) still references everything -> protected
r = c.delete(f"/materials/{material['id']}")
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
check("DELETE material (still referenced by receipt-protected quote) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
check("DELETE supplier (still referenced) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
check("DELETE project (no quotes) -> 200", r.status_code == 200, r.text[:200])
check("DELETE project (still referenced) -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/unit-conversions/{uc['id']}")
check("DELETE unit-conversion", r.status_code == 200)
r = c.delete(f"/standard-conversions/{sc_id}")
Binary file not shown.
Binary file not shown.
+54 -27
View File
@@ -1,32 +1,72 @@
# ============================================================================
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
# redirect. Usage:
# redirect + security middlewares + required secrets.
#
# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \
# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \
# TRAEFIK_ACME_EMAIL=admin@example.com \
# All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES
# (compose loads it automatically — keep it on the VPS, never in git):
#
# cp .env.example .env # then fill in STRONG values
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# Requirements: DNS records for every host pointing at the server, ports
# 80 + 443 open (ACME http-challenge uses port 80), and the api's
# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host
# (see docs/casdoor-auth.md).
# 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is
# derived here from AUTH_HOST (see docs/casdoor-auth.md).
#
# Any `${VAR:?}` below refuses to boot when the variable is missing/empty —
# that is deliberate: production must not run on defaults.
# ============================================================================
services:
traefik:
environment:
# Certificates persist across restarts (acme.json in the traefik_certs volume).
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production}
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}
postgres:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
minio:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api.middlewares=secure-headers@file
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console.middlewares=secure-headers@file
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
api:
ports: [] # production: only reachable through Traefik
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:?set CASDOOR_CLIENT_SECRET in .env}
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:?set CASDOOR_ADMIN_PASSWORD in .env}
# Issuer must equal the browser-facing Casdoor origin (its discovery doc).
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST:?set AUTH_HOST}
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env}
PROCUREMENT_ENVIRONMENT: prod
PROCUREMENT_DEV_LOGIN_ENABLED: "false"
labels:
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls=true
- traefik.http.routers.api.tls.certresolver=letsencrypt
# HTTP → HTTPS
- traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file,api-bodylimit@file
- traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`)
- traefik.http.routers.api-redir.entrypoints=web
- traefik.http.routers.api-redir.middlewares=redirect-https@file
@@ -41,34 +81,21 @@ services:
- traefik.http.routers.casdoor.entrypoints=websecure
- traefik.http.routers.casdoor.tls=true
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt
- traefik.http.routers.casdoor.middlewares=secure-headers@file
- traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
- traefik.http.routers.casdoor-redir.entrypoints=web
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
frontend:
build:
args:
DEV_LOGIN_ENABLED: "false"
labels:
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend.entrypoints=websecure
- traefik.http.routers.frontend.tls=true
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
- traefik.http.routers.frontend.middlewares=secure-headers@file
- traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`)
- traefik.http.routers.frontend-redir.entrypoints=web
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file
minio:
ports: [] # production: only reachable through Traefik
labels:
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api.entrypoints=websecure
- traefik.http.routers.minio-api.tls=true
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
- traefik.http.routers.minio-api-redir.entrypoints=web
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console.entrypoints=websecure
- traefik.http.routers.minio-console.tls=true
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
- traefik.http.routers.minio-console-redir.entrypoints=web
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
+185 -29
View File
@@ -1,14 +1,21 @@
services:
postgres:
image: postgres:18
container_name: procurement-postgres
environment:
POSTGRES_DB: procurement
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "5435:5432"
- "5455:5432"
networks:
- default
- shared # monitoring stack scrapes it (postgres-exporter)
volumes:
- postgres_data:/var/lib/postgresql
@@ -22,14 +29,27 @@ services:
retries: 10
restart: unless-stopped
mem_limit: 1g
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6
container_name: procurement-traefik
restart: unless-stopped
mem_limit: 128m
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=erp_production_network
# Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
@@ -39,9 +59,11 @@ services:
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO
ports:
- "8084:80"
- "8445:443"
- "80:80"
- "443:443"
volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs
@@ -50,26 +72,29 @@ services:
minio:
image: minio/minio:latest
container_name: procurement-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9004:9000" # S3 API
- "9005:9001" # Web console
- "9000:9000" # S3 API
- "9001:9001" # Web console
volumes:
- minio_data:/data
labels:
- traefik.enable=true
# S3 API
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
- traefik.http.routers.minio-api.entrypoints=web
- traefik.http.routers.minio-api.service=minio-api-svc
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
# Web console
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
- traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc
@@ -80,9 +105,13 @@ services:
- default
restart: unless-stopped
mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init:
image: minio/mc:latest
container_name: procurement-minio-init
depends_on:
minio:
@@ -90,8 +119,8 @@ services:
environment:
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files
entrypoint: >
@@ -105,8 +134,54 @@ services:
restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup:
image: postgres:18
container_name: procurement-db-backup
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-postgres}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes:
- ${BACKUP_DIR:-./backups}:/backups
entrypoint: >
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
restart: unless-stopped
mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor:
image: casbin/casdoor:latest
container_name: procurement-casdoor
depends_on:
postgres:
condition: service_healthy
@@ -115,9 +190,9 @@ services:
driverName: postgres
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
dbName: casdoor
origin: "http://localhost:8083"
origin: "http://localhost:8080"
ports:
- "8083:8000"
- "8080:8000"
labels:
- traefik.enable=true
@@ -130,31 +205,61 @@ services:
- default
restart: unless-stopped
mem_limit: 768m
api:
image: v15-api:latest
build:
context: ./backend
dockerfile: Dockerfile
container_name: procurement-api
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
PROCUREMENT_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
PROCUREMENT_OPENPANEL_CLIENT_SECRET: ${OPENPANEL_CLIENT_SECRET:-}
ports:
- "8001:8000"
- "8000:8000"
networks:
- traefik_network
- default
- shared # monitoring stack scrapes api:8000/metrics
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
labels:
- traefik.enable=true
@@ -162,10 +267,6 @@ services:
- traefik.http.routers.api.entrypoints=web
- traefik.http.services.api.loadbalancer.server.port=8000
networks:
- traefik_network
- default
depends_on:
postgres:
condition: service_healthy
@@ -173,15 +274,28 @@ services:
condition: service_completed_successfully
restart: unless-stopped
mem_limit: 768m
frontend:
image: v15-frontend:latest
build:
context: ./frontend
args:
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
ports:
- "8085:80"
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels:
- traefik.enable=true
@@ -197,10 +311,52 @@ services:
- api
restart: unless-stopped
mem_limit: 128m
bot:
image: v15-bot:latest
build:
context: ./bot
dockerfile: Dockerfile
container_name: procurement-bot
volumes:
- ./bot/app:/srv/bot/app
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-deepseek-flash}
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network:
name: erp_production_network
name: traefik_network
driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared:
name: erp_shared
driver: bridge
volumes:
+96
View File
@@ -0,0 +1,96 @@
# Operations runbook
Day-2 operations for the ERP stack: deploys, health, backups, monitoring.
Secrets and rotation live in [`SECRET-MANAGEMENT.md`](SECRET-MANAGEMENT.md);
Casdoor/auth details in [`casdoor-auth.md`](casdoor-auth.md).
## Deploy flow (push-to-deploy)
1. Work lands on `production` only via merge from a feature branch.
2. Every push to `production` hits the webhook receiver (host port 9000),
which verifies the Gitea HMAC signature, rebuilds
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
and then polls `/health/ready` (defaults to
`https://$API_HOST/health/ready` from `.env`).
3. A failed build or a failed readiness check is a **failed deploy**:
`journalctl -u erp-deploy-webhook -n 200` on the VPS.
Rollback: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build`
after `git revert` + rebuild — or re-push the previous commit.
## Health endpoints (unauthenticated, for probes)
| Endpoint | Meaning |
|---|---|
| `GET /health` | liveness — process is up |
| `GET /health/ready` | readiness — Postgres + MinIO + Casdoor all reachable (503 + per-check booleans when degraded) |
| `GET /metrics` | Prometheus exposition (scraped by the monitoring stack) |
## Backups
The `db-backup` sidecar dumps BOTH databases (`procurement`, `casdoor`)
with `pg_dump -Fc` every `BACKUP_INTERVAL_SECONDS` (default daily) into
`BACKUP_DIR` (default `./backups` → set to `/srv/erp/backups` on the VPS),
keeping `BACKUP_KEEP_DAYS` (default 14) days.
Restore (⚠️ replaces all data — stop `api` + `casdoor` first):
```bash
cd /srv/erp && docker compose -f docker-compose.yml -f docker-compose.prod.yml stop api casdoor
docker cp backups/procurement-<stamp>.dump procurement-postgres:/tmp/
docker cp backups/casdoor-<stamp>.dump procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement-<stamp>.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor-<stamp>.dump
docker compose -f docker-compose.yml -f docker-compose.prod.yml start casdoor api
```
**Test the restore quarterly** on a scratch volume — an untested backup is
a hope, not a backup. Copy archives off-box periodically (the bind mount is
still on the same disk as the volumes).
## Audit trail
`audit_log` (in the `procurement` DB) records who changed what for
role assignments, user creation, and inventory transactions. Query recent
activity:
```bash
docker exec procurement-postgres psql -U postgres -d procurement \
-c "SELECT occurred_at, actor, action, entity, entity_id FROM audit_log ORDER BY occurred_at DESC LIMIT 50;"
```
`quote_events` continues to hold the quote lifecycle history (its `actor`
is now the verified user's display name, not a raw token).
## Monitoring stack
Start/update (separate compose project — app rebuilds don't touch it):
```bash
cd /srv/erp
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
```
- **Grafana** — `127.0.0.1:3001` on the VPS (SSH tunnel), admin password in
`.env`; the «ERP — Stack overview» dashboard auto-provisions.
- **GlitchTip** — public on `GLITCHTIP_HOST` (browser DSN) + loopback
`:8001` for administration. Create backend/frontend projects there and put
the DSNs in `.env` (`SENTRY_DSN_BACKEND` / `SENTRY_DSN_FRONTEND`).
- **OpenPanel** — see `monitoring/README.md` for the self-hosting setup and
the event list the app emits.
- **Alerts** — Prometheus rules surface in Grafana alerting (API down,
5xx rate, p95 latency, disk, RAM, DB connections). Attach a notification
channel in the Grafana UI.
Logs: every container's stdout ships to Loki via Promtail — in Grafana:
`{compose_project="erp", compose_service="api"}`; JSON lines carry
`request_id`, `http_status`, `duration_ms`.
## Routine checks
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps # all Up/healthy?
docker compose logs --since 10m api | grep -i error
ls -lh /srv/erp/backups | tail # dumps fresh?
curl -fsS https://$API_HOST/health/ready | jq # all checks true?
df -h / # ≥20% free (builds need headroom)
```
+67
View File
@@ -0,0 +1,67 @@
# Secret management & rotation
**Policy (short):** real credentials live only in git-ignored `.env` files —
the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and
local dev `.env` copies. Nothing secret goes into git: not in the README,
not in dumps, not in docs. The repo is (or was) **public** — assume anything
ever committed is compromised and rotate.
`docker-compose.prod.yml` declares every production secret as
`${VAR:?set in .env}` so the stack **refuses to boot** on defaults.
## Where each secret lives
| Secret | Used by | Defined in |
|---|---|---|
| `POSTGRES_PASSWORD` | postgres, api | server `.env` |
| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` |
| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) |
| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) |
| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` |
| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config |
| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) |
## Rotation checklist — run after any leak suspicion
The production branch history contained real credentials (MinIO admin,
Casdoor passwords, server IP, DB dumps). All of the following must be
rotated once, on the VPS:
```bash
cd /srv/erp
# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env
mc alias set local http://127.0.0.1:9000 <old-user> '<old-pass>'
mc admin user add local <new-user> '<new-pass>' # then:
mc admin policy attach local consoleAdmin --user <new-user>
mc admin user remove local <old-user>
# (simplest equivalent: mc admin user svcacct or change root via env +
# `docker compose up -d minio` — MinIO persists root creds from env)
# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env
docker exec -it procurement-postgres psql -U postgres -c \
"ALTER USER postgres PASSWORD '<new-strong-password>';"
docker compose up -d api # api re-reads the DSN from .env
# 3) Casdoor console admin (console → Identity → users → admin → reset
# password) — then update CASDOOR_ADMIN_PASSWORD in .env
# 4) Casdoor application client secret (console → Applications →
# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env
# 5) Seeded human users: reset each password in the Casdoor console.
# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update
# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook)
# 7) Apply everything:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
- **SSH**: disable password login (`PasswordAuthentication no` in
`/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with
sudo over `root` direct login.
- **Repo hygiene**: keep the repo private until the git history is either
purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every
leaked value is rotated. Deleting a file in a later commit does **not**
remove it from history.
- **Verify**: after rotation, `docker compose logs api | grep -i error`,
log into the app, upload + download one attachment, and confirm the
webhook deploy still works with a test push.
+11 -11
View File
@@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself.
|---|---|---|
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) |
| Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed |
| Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
| In-network Casdoor (from the api container) | http://casdoor:8000 | — |
@@ -22,13 +22,13 @@ scenarios, test results, and how to manage the system yourself.
| Username | Password | Casdoor org | App role (our `users` table) |
|---|---|---|---|
| `razaei` | `123456` | inventory | `admin` (super user) |
| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) |
| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) |
| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) |
| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe
to re-run).
Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are
created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run).
---
@@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced.
the OIDC client. Grant types: `authorization_code`, `refresh_token`,
`password`. Token format JWT (so the backend can verify via JWKS).
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs
to it. (The Casdoor console admin `admin/123` lives in the separate
to it. (The Casdoor console admin lives in the separate
`built-in` org.)
- **User — two records per person:**
1. Casdoor user (login credential: name + password).
@@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel
| Test | Action | Result | Evidence |
|---|---|---|---|
| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned |
| 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned |
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
| 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» |
@@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel
| 6 | Organization exists | ✅ `inventory` |
| 7 | Application exists | ✅ `inventory-app` |
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) |
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) |
| 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` |
| 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified |
| 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify |
@@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel
## 9. Managing Casdoor yourself
- **Console:** http://localhost:8080 — log in as **admin / 123**. From there
- **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there
you can manage Organizations, Users, Applications, Providers, Certificates.
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
re-runnable at any time:
+10 -3
View File
@@ -5,6 +5,13 @@
VITE_API_BASE_URL=
# Show the "Developer Mode" panel on the login page.
# true (default) / false — in Docker builds this is controlled by the
# DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
VITE_ENABLE_DEV_LOGIN=true
# true (default in local dev) / false — in Docker builds this is controlled
# by the DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
VITE_ENABLE_DEV_LOGIN=
# ---- Observability (all optional — empty = disabled, no network calls) ----
# OpenPanel: browser origin of the self-hosted dashboard/API + project client id.
VITE_OPENPANEL_URL=
VITE_OPENPANEL_CLIENT_ID=
# GlitchTip/Sentry DSN for frontend exception capture.
VITE_SENTRY_DSN=
+17 -3
View File
@@ -13,11 +13,25 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci
# Developer Mode panel on the login page (default enabled; set to
# "false" to build a production login without the dev panel).
ARG DEV_LOGIN_ENABLED=true
# Developer Mode panel on the login page (dev-only aid). Production builds
# MUST keep this "false" — identity comes from Casdoor tokens, and the
# backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true.
ARG DEV_LOGIN_ENABLED=false
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Observability (Phase 2) — baked into the bundle at build time.
# Empty values disable the feature (analytics.ts is a no-op then).
ARG VITE_OPENPANEL_URL=""
ARG VITE_OPENPANEL_CLIENT_ID=""
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
# so it needs its own arg — defined here or the value never reaches the bundle.
ARG VITE_OPENPANEL_SCRIPT_URL=""
ARG VITE_SENTRY_DSN=""
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Source code + build
COPY . .
RUN npm run build
+20
View File
@@ -29,6 +29,26 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# MIME types: the pdf.js worker ships as .mjs, and a browser REFUSES to
# start a module worker served as application/octet-stream (nginx's
# default for unknown extensions) — which crashes the PDF preview page.
# Declared explicitly rather than relying on the system mime.types.
types {
text/javascript mjs js;
text/css css;
text/html html;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/webp webp;
image/x-icon ico;
font/woff2 woff2;
font/woff woff;
application/json json;
application/pdf pdf;
application/wasm wasm;
}
# SPA fallback — all client-side routes go to index.html
location / {
try_files $uri $uri/ /index.html;
+461 -78
View File
@@ -11,16 +11,19 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10",
"@openpanel/web": "^1.0.1",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
@@ -172,6 +175,273 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@openpanel/sdk": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
"integrity": "sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==",
"license": "MIT"
},
"node_modules/@openpanel/web": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.4.1.tgz",
"integrity": "sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==",
"license": "MIT",
"dependencies": {
"@openpanel/sdk": "1.3.1",
"@rrweb/types": "2.0.0-alpha.20",
"rrweb": "2.0.0-alpha.20"
}
},
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
@@ -308,9 +578,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -328,9 +595,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -348,9 +612,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -368,9 +629,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -388,9 +646,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -408,9 +663,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -428,9 +680,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -448,9 +697,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1207,9 +1453,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1226,9 +1469,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1485,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1264,9 +1501,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1283,9 +1517,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1302,9 +1533,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1368,6 +1596,124 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.20",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz",
"integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==",
"license": "MIT"
},
"node_modules/@rrweb/utils": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.1.6.tgz",
"integrity": "sha512-BIRXcxiytJs5Guxw0tzsnVS7ukcMqtnxixjIGvV49VdJeOEG6NJeMU7o8efbHW1D52a12Y8QsgDjgDgblg3org==",
"license": "MIT"
},
"node_modules/@sentry/browser": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz",
"integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0",
"@sentry/feedback": "10.75.0",
"@sentry/replay": "10.75.0",
"@sentry/replay-canvas": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz",
"integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/conventions": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz",
"integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/feedback": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz",
"integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz",
"integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.75.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/replay": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz",
"integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.75.0",
"@sentry/core": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.75.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz",
"integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.75.0",
"@sentry/replay": "10.75.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1493,9 +1839,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1512,9 +1855,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1531,9 +1871,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1887,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1663,6 +1997,12 @@
"react": "^18 || ^19"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -1719,6 +2059,12 @@
}
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1731,6 +2077,15 @@
"node": ">=10"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -1991,9 +2346,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2014,9 +2366,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2037,9 +2386,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2060,9 +2406,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2134,6 +2477,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -2201,6 +2550,18 @@
}
}
},
"node_modules/pdfjs-dist": {
"version": "4.10.38",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2423,6 +2784,40 @@
"@rolldown/binding-win32-x64-msvc": "1.2.3"
}
},
"node_modules/rrdom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.1.6.tgz",
"integrity": "sha512-EMRrTs1GRsmb8bNyAs107wG7eRwVEOvDUBsnCyvQnVV6elrmrua/AeS0tdcA0gLW4eptr4l6bRsS/2QRICvdqQ==",
"license": "MIT",
"dependencies": {
"rrweb-snapshot": "^2.1.6"
}
},
"node_modules/rrweb": {
"version": "2.0.0-alpha.20",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz",
"integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==",
"license": "MIT",
"dependencies": {
"@rrweb/types": "^2.0.0-alpha.20",
"@rrweb/utils": "^2.0.0-alpha.20",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.0.0-alpha.20",
"rrweb-snapshot": "^2.0.0-alpha.20"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.1.6.tgz",
"integrity": "sha512-X03+x3AdWTbpD4Swb54CCJoEauYTv/O2ah0d3ZVv5rgr6bv0Ty5mxNjEyEEfNC1dQ8VdyjKKJgDG3A00FV0lNg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2788,9 +3183,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2811,9 +3203,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2834,9 +3223,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2857,9 +3243,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+3
View File
@@ -13,16 +13,19 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10",
"@openpanel/web": "^1.0.1",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
+15 -3
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects";
@@ -52,10 +52,22 @@ function Loading() {
);
}
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
// همه‌ی صفحات پشت ورود هستند — به جز پیش‌نویس‌های ویزارد از ربات یا بدون نشست که به صفحه‌ی ورود می‌روند.
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!user) return <Navigate to="/login" replace />;
const location = useLocation();
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search);
const hasDraft = searchParams.has("draft") || searchParams.has("draft_id");
if (location.pathname === "/wizard" && hasDraft) {
return <>{children}</>;
}
if (!user) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
}
return <>{children}</>;
}
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
}
function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value);
}}
onFocus={() => setOpen(true)}
onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>

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