38 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
Behrad 14a4a29431 feat(procurement): sync status pipeline cards, add supplier lead time badges, and enhance badge contrast 2026-09-20 15:10:10 +03:30
Behrad d2b9af5278 feat(dashboard): filter quotes by clicking status cards in StatsBar 2026-09-20 14:40:27 +03:30
MMD e637d80b76 Merge latest prod_v2 (FileList preview modal, EditModal tabs) into integration
prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
112 changed files with 8051 additions and 2164 deletions
+4
View File
@@ -26,8 +26,12 @@ frontend/.env
# ---- Local session / backups ---- # ---- Local session / backups ----
.zcode/ .zcode/
.zcodeignore
scratch/
v1.2 - Copy/ v1.2 - Copy/
inv dash/ inv dash/
bot/
voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ---- # ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml docker-compose.override.yml
+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()))
+75 -25
View File
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
_DASHBOARD_DDL = [ _DASHBOARD_DDL = [
# One reference unit per category (units.html «واحد مرجع هر دسته») # 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. # 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). # 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 CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit) 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": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False}, {"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": "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,13 +372,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u, 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("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement(""" await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
CREATE TRIGGER trg_supplier_materials_derive_category await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
AFTER INSERT ON supplier_materials await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price CREATE TRIGGER trg_item_decisions_sync_price
@@ -394,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
""") """)
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: async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" """v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """ ddl = """
@@ -416,3 +432,37 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
for stmt in indexes: for stmt in indexes:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("audit_log table ensured") 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")
+13
View File
@@ -27,6 +27,8 @@ from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import ( from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table,
ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
ensure_quote_projects, ensure_quote_projects,
@@ -105,6 +107,8 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_audit_table(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 task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try: try:
yield yield
@@ -227,10 +231,19 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# pm, users) keep them — the global dependency guarantees nothing is open # pm, users) keep them — the global dependency guarantees nothing is open
# by omission. # by omission.
from app.routers.auth import router as _auth_router # noqa: E402 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: for router in ROUTERS:
if router is _auth_router: if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX) 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: else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)]) app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __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"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column( 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() 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") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
+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())
+2 -6
View File
@@ -4,8 +4,7 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
category_attributes, drafts,
category_ref_units,
files, files,
inventory, inventory,
item_decisions, item_decisions,
@@ -24,7 +23,6 @@ from app.routers import (
supplier_materials, supplier_materials,
suppliers, suppliers,
termsheets, termsheets,
unit_conversions,
units, units,
users, users,
) )
@@ -35,7 +33,6 @@ ROUTERS = [
brands.router, brands.router,
material_categories.router, material_categories.router,
attribute_definitions.router, attribute_definitions.router,
category_attributes.router,
materials.router, materials.router,
brand_materials.router, brand_materials.router,
suppliers.router, suppliers.router,
@@ -51,10 +48,9 @@ ROUTERS = [
item_receipts.router, item_receipts.router,
quote_events.router, quote_events.router,
files.router, files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
users.router, users.router,
drafts.router,
] ]
-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)}
+98 -17
View File
@@ -17,14 +17,15 @@ import os
import uuid import uuid
from urllib.parse import quote 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 sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import UserContext, get_current_user, get_optional_user, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote from app.models.procurement import Quote
from app.services import file_service 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"]) router = APIRouter(prefix="/quotes", tags=["files"])
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 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. """Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients. 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") raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters # keep the fallback header-safe: no quotes, no control characters
@@ -56,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 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): if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian # nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension # name): use a generic name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment" fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}" "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: async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
@@ -92,7 +125,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # 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)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -103,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()} 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)): async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice") attachments = await file_service.list_for(db, quote_id, "invoice")
return {"data": [a.to_dict() for a in attachments]} 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}") @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) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") 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) content = await file_service.read_bytes(attachment)
return Response( return Response(
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": _content_disposition(attachment.file_name), "Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) @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)): async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
@@ -137,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201) @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)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -148,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()} 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)): async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "proforma") attachments = await file_service.list_for(db, quote_id, "proforma")
@@ -156,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
@router.get("/{quote_id}/proforma/{attachment_id}") @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) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") 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) content = await file_service.read_bytes(attachment)
return Response( return Response(
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": _content_disposition(attachment.file_name), "Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) @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)): async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+7 -70
View File
@@ -274,12 +274,13 @@ async def update_warehouse(
wh.name = payload.name wh.name = payload.name
if payload.description is not None: if payload.description is not None:
wh.description = payload.description wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id): 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 ValidationError(
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id") "پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
if scope.role == ROLE_PM and payload.project_id != scope.project_id: field="project_id",
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id") )
wh.project_id = payload.project_id
await db.flush() await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))} return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@@ -313,7 +314,6 @@ async def delete_warehouse(
@router.get("") @router.get("")
async def list_inventory( async def list_inventory(
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None, warehouse_id=None,
material_id=None, material_id=None,
sort: str | None = None, sort: str | None = None,
@@ -331,10 +331,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id Warehouse.project_id == project_id
) )
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: if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id) stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None: if material_id is not None:
@@ -356,10 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items} 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()} 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()} 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 = {} brands = {}
if brand_ids: if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()} brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -369,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [ return [
inventory_to_dict( inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id], 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 for i in items
] ]
@@ -531,58 +523,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT", "PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT", "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
]}
+9 -4
View File
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query 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) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists. # v2.7: materials no longer carry a category, so what blocks deletion is the
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id)) # 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: if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست") raise ConflictError(f"این دسته‌بندی برای {linked} تأمین‌کننده ثبت شده و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL await db.delete(category) # category_attributes cascade via DDL
await db.flush() await db.flush()
return {"data": {"id": str(category_id), "deleted": True}} 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( async def list_materials(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None, brand_id=None,
status: str | None = None, status: str | None = None,
q: str | None = None, q: str | None = None,
@@ -107,8 +106,6 @@ async def list_materials(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Material) stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
if brand_id: if brand_id:
# WHERE EXISTS brand_materials with that brand_id # WHERE EXISTS brand_materials with that brand_id
sub = select(1).where( sub = select(1).where(
+2 -15
View File
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -201,18 +201,6 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
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 = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
@@ -225,8 +213,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name}, "supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name, "material": {"id": str(m.id), "name": m.name},
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
+27 -16
View File
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet from app.models.procurement import (
ItemDecision,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteProjects,
Supplier,
Termsheet,
)
from app.schemas.procurement import ( from app.schemas.procurement import (
AcceptQuoteIn, AcceptQuoteIn,
ArriveItemsIn, ArriveItemsIn,
@@ -36,15 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers --------------------------------------------------- # --- 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: def _quote_search(q: str) -> exists:
like = f"%{q}%" like = f"%{q}%"
@@ -72,7 +72,6 @@ async def get_quote_stats(
project_id=None, project_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return { return {
@@ -82,7 +81,6 @@ async def get_quote_stats(
project_id=project_id, project_id=project_id,
quote_date_from=quote_date_from, quote_date_from=quote_date_from,
quote_date_to=quote_date_to, quote_date_to=quote_date_to,
category_id=category_id,
) )
} }
@@ -98,7 +96,6 @@ async def list_quotes(
status: str | None = None, status: str | None = None,
supplier_id=None, supplier_id=None,
project_id=None, project_id=None,
category_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
q: str | None = None, q: str | None = None,
@@ -112,9 +109,23 @@ async def list_quotes(
if supplier_id: if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id) stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id: if project_id:
stmt = stmt.where(Quote.project_id == project_id) # any relevant link: legacy mirror, quote-level junction, line-item
if category_id: # candidates, or an accepted (decision) target — NOT just the mirror
stmt = stmt.where(_quote_category_exists(category_id)) 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: if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from) stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: 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) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items} 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()} 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} return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
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}
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None return {"data": link_to_dict(link, material, None)}
return {"data": link_to_dict(link, material, category)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @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}}
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel): class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1) name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
class WarehouseUpdate(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) name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None description: str | None = None
project_id: UUID | None = None project_id: UUID | None = None
+1 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(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 material_id: UUID
+8 -7
View File
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
"""Record a named event; swallows every error.""" """Record a named event; swallows every error."""
if not _configured(): if not _configured():
return return
body = { # OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
"type": "track", # sends) — a flat body is rejected with 400, which this module swallows by design.
"event": event, # The payload field is "name" (ITrackPayload in packages/validation) — "event" is
"properties": properties or {}, # rejected with a 400 that this module swallows.
} inner: dict[str, Any] = {"name": event, "properties": properties or {}}
if profile_id: if profile_id:
body["profileId"] = profile_id inner["profileId"] = profile_id
body = {"type": "track", "payload": inner}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track", f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "profileId": profile_id, "properties": properties or {}}, json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
headers={ headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID, "openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET, "openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
+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] material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}} 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() unit_ids: set[int] = set()
if "canonical_unit" in expand: if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials} 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), "created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_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: if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id)) d["category"] = None
else: else:
d["category_id"] = str(m.category_id) d["category_id"] = None
if "canonical_unit" in expand: if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else: 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} material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all() materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials} 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() bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set() brand_ids: set[uuid.UUID] = set()
for bm in bms: for bm in bms:
@@ -450,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = { d["material"] = {
"id": str(m.id), "id": str(m.id),
"name": m.name, "name": m.name,
"category": category_brief(data.categories.get(m.category_id)), "category": None,
"brands": [ "brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code} {"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, []) for bm in data.material_brands.get(m.id, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial, BrandMaterial,
Material, Material,
MaterialAttributeValue, MaterialAttributeValue,
MaterialCategory,
Unit, Unit,
) )
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate 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: async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction. """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 v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
""" """
async with db.begin(): async with db.begin():
duplicate = await db.scalar( duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None: if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name") 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): if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id") raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material( material = Material(
category_id=payload.category_id,
name=payload.name, name=payload.name,
topic=payload.topic, topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id, canonical_unit_id=payload.canonical_unit_id,
+87 -27
View File
@@ -873,7 +873,6 @@ async def quote_stats(
project_id: UUID | None = None, project_id: UUID | None = None,
quote_date_from=None, quote_date_from=None,
quote_date_to=None, quote_date_to=None,
category_id: UUID | None = None,
) -> dict: ) -> dict:
conditions = [] conditions = []
if supplier_id is not None: if supplier_id is not None:
@@ -884,8 +883,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from) conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to) conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = ( rows = (
await db.execute( await db.execute(
@@ -917,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: 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 A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
termsheet link first, else the first link. Rejected line items are least one non-rejected line item's decision targets this project
excluded from value sums (contract P1-14). (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) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") 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() # --- every quote RELEVANT to this project (any kind of link) ---
if not quotes: 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} empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty} 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() 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] li_ids = [li.id for li in line_items]
@@ -956,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all() for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {} } 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 = ( item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all() (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else [] ) if li_ids else []
@@ -964,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets: for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it) 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} per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes: for q in quotes:
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
else:
per_status[q.status]["count"] += 1 per_status[q.status]["count"] += 1
for li in line_items: for li in line_items:
quote = next(q for q in quotes if q.id == li.quote_id)
decision = decisions.get(li.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": if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None: if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -979,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None price_it = links[0] if links else None
if price_it is None: if price_it is None:
continue continue
quote = next(q for q in quotes if q.id == li.quote_id) per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)} result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items(): for status, values in per_status.items():
@@ -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 Suppliers can supply any global material across categories.
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.
""" """
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material( async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial: ) -> 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. 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) link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link) db.add(link)
await db.flush() 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 return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None: 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( link = await db.scalar(
select(SupplierMaterial).where( select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id, SupplierMaterial.supplier_id == supplier_id,
+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)
+4 -4
View File
@@ -209,7 +209,7 @@ async def run() -> None:
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, " "item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, " "line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, " "material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions " "standard_conversions "
"RESTART IDENTITY CASCADE" "RESTART IDENTITY CASCADE"
)) ))
# Only the 8 canonical topic categories may exist — any leftover slug # 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] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
category_id=cat.id,
name=name, name=name,
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush() await db.flush()
suppliers.append(sup) suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category # 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: 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]: for m in cat_materials[:2]:
db.add(SupplierMaterial( db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True, supplier_id=sup.id, material_id=m.id, is_active=True,
+42 -1
View File
@@ -11,7 +11,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
- "5433:5432" - "5455:5432"
networks: networks:
- default - default
@@ -208,6 +208,7 @@ services:
mem_limit: 768m mem_limit: 768m
api: api:
image: v15-api:latest
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -276,6 +277,7 @@ services:
mem_limit: 768m mem_limit: 768m
frontend: frontend:
image: v15-frontend:latest
build: build:
context: ./frontend context: ./frontend
args: args:
@@ -289,6 +291,11 @@ services:
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels: labels:
- traefik.enable=true - traefik.enable=true
@@ -306,6 +313,40 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 128m 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: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
+4
View File
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Empty values disable the feature (analytics.ts is a no-op then). # Empty values disable the feature (analytics.ts is a no-op then).
ARG VITE_OPENPANEL_URL="" ARG VITE_OPENPANEL_URL=""
ARG VITE_OPENPANEL_CLIENT_ID="" 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="" ARG VITE_SENTRY_DSN=""
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \ ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \ VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN} VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Source code + build # Source code + build
+20
View File
@@ -29,6 +29,26 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 # SPA fallback — all client-side routes go to index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+263
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.30.0", "lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
@@ -174,6 +175,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@openpanel/sdk": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
@@ -2299,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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -10,7 +10,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@sentry/react": "^10.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10", "@hookform/resolvers": "3.10",
@@ -20,11 +19,13 @@
"@radix-ui/react-label": "^2.1.15", "@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.30.0", "lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
+15 -3
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react"; 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 { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects"; import { RequirePmProjects } from "./components/RequirePmProjects";
@@ -52,10 +52,22 @@ function Loading() {
); );
} }
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم. // همه‌ی صفحات پشت ورود هستند — به جز پیش‌نویس‌های ویزارد از ربات یا بدون نشست که به صفحه‌ی ورود می‌روند.
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
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}</>; return <>{children}</>;
} }
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
} }
function toItem(x: ComboboxSourceItem): ComboboxItem { function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => { onChange={(e) => {
setText(e.target.value); setText(e.target.value);
setHighlight(-1); setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
+118 -16
View File
@@ -1,6 +1,8 @@
import { useRef } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
import type { Attachment } from "../lib/types"; import type { Attachment } from "../lib/types";
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
export interface FileListProps { export interface FileListProps {
files: Attachment[]; files: Attachment[];
@@ -11,12 +13,25 @@ export interface FileListProps {
onDelete?: (a: Attachment) => void; onDelete?: (a: Attachment) => void;
// دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد) // دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد)
onDownload?: (a: Attachment) => void | Promise<void>; onDownload?: (a: Attachment) => void | Promise<void>;
// آدرس blob احراز هویت‌شده برای پیش‌نمایش. مودال پیش‌نمایش با objectURL
// محلی کار می‌کند چون <img>/<iframe> نمی‌توانند توکن بفرستند.
loadPreview?: (a: Attachment) => Promise<string>;
label?: string; label?: string;
} }
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده // مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) { export function FileList({
files,
onChange,
upload,
onDelete,
onDownload,
loadPreview,
label = "افزودن فاکتور",
}: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const remove = (i: number) => { const remove = (i: number) => {
const removed = files[i]; const removed = files[i];
@@ -39,31 +54,108 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
if (added.length) onChange([...files, ...added]); if (added.length) onChange([...files, ...added]);
}; };
// پیش‌نمایش: یک URL امضاشده‌ی کوتاه‌عمر می‌گیریم (نه blob) تا
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
const handleOpenPreview = async (inv: Attachment) => {
if (!loadPreview) return;
if (!inv.id) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
setLoadingPreview(inv.id);
try {
const url = await loadPreview(inv);
setPreviewItem({ url, name: inv.name });
} catch (err) {
toast.error(err instanceof Error ? err.message : "بارگذاری پیش‌نمایش ناموفق بود");
} finally {
setLoadingPreview(null);
}
};
const closePreview = () => setPreviewItem(null);
return ( return (
<div> <div className="space-y-2">
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>} {!files.length && (
{files.map((inv, i) => ( <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
<div key={i} className="inv-row"> هنوز فاکتور یا سندی بارگذاری نشده است.
<span className="inv-name">📎 {inv.name}</span> </div>
)}
{files.length > 0 && (
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const canPreview = !!loadPreview && !!inv.id;
const isLoading = !!inv.id && loadingPreview === inv.id;
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
onClick={() => canPreview && void handleOpenPreview(inv)}
title={canPreview ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImageFile(inv.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdfFile(inv.name) ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{canPreview && (
<button
type="button"
onClick={() => void handleOpenPreview(inv)}
disabled={isLoading}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
)}
{onDownload && ( {onDownload && (
<button <button
type="button" type="button"
className="inv-open"
title="دانلود"
onClick={() => { onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) => Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"), toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
); );
}} }}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
> >
↓ <Download className="h-3.5 w-3.5" />
</button> </button>
)} )}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
× <button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
))} </div>
);
})}
</div>
)}
<div> <div>
<input <input
ref={inputRef} ref={inputRef}
@@ -73,10 +165,20 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
/> />
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}> <button
+ {label} type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button> </button>
</div> </div>
{/* مودال پیش‌نمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
{previewItem && (
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
)}
</div> </div>
); );
} }
@@ -0,0 +1,148 @@
import { useState } from "react";
import { PdfCanvas } from "./PdfCanvas";
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
export const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
export const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
/**
* آیا مرورگر می‌تواند PDF را درون‌خطی نشان دهد؟
*
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
* شبیه‌سازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
* منتهی می‌شود، پس آنجا pdf.js استفاده می‌کنیم.
*/
export function canRenderPdfNatively(): boolean {
if (typeof navigator === "undefined") return true;
const ua = navigator.userAgent || "";
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیق‌ترین معیار است
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
return !mobile;
}
export interface FilePreviewModalProps {
// آدرس امضاشده‌ی کوتاه‌عمر — چون <img>/<iframe> نمی‌توانند هدر توکن بفرستند
url: string;
name: string;
onClose: () => void;
}
/**
* مودال پیش‌نمایش فایل — مشترک بین لیست پیوست‌ها (FileList) و کارت پیشنهاد
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم می‌شود (بدون
* وابستگی به پلاگین PDF مرورگر، که در شبیه‌سازی موبایل وجود ندارد)؛ سایر
* فرمت‌ها فقط دکمه‌ی دانلود دارند.
*/
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* هدر پیش‌نمایش */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
<div className="flex items-center gap-2 min-w-0">
{isImageFile(name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
)}
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={url}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="باز کردن در برگه جدید"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
title="بستن"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* بدنه نمایش فایل */}
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
{isImageFile(name) ? (
<img
src={url}
alt={name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(name) ? (
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را می‌دهد؛ در
// شبیه‌سازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
// canvas رسم می‌کند. انتخاب در زمان اجرا با تشخیص قابلیت.
canRenderPdfNatively() ? (
<iframe
src={url}
title={name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<PdfCanvas url={url} className="w-full" />
)
) : (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
<FileText className="h-12 w-12 text-gray-400" />
<div className="text-xs text-gray-600">
امکان نمایش پیش‌نمایش زنده برای این فرمت فایل وجود ندارد.
</div>
</div>
)}
</div>
</div>
</div>
);
}
/**
* هوک مدیریت پیش‌نمایش: بارگذاری blob احراز هویت‌شده + آزادسازی objectURL.
* خروجی برای رندر <FilePreviewModal> آماده است.
*/
/**
* هوک مدیریت پیش‌نمایش: گرفتن URL امضاشده و نگه‌داشتن آن برای مودال.
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
* نیازی به آزادسازی objectURL نیست.
*/
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const open = async (id: string, name: string) => {
if (!id) return;
setLoadingId(id);
try {
const url = await load(id, name);
setItem({ url, name });
} finally {
setLoadingId(null);
}
};
const close = () => setItem(null);
return { item, loadingId, open, close };
}
+15 -4
View File
@@ -12,15 +12,19 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="milestone-row"> <div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-28 shrink-0">
<JalaliDatePicker <JalaliDatePicker
value={milestone.when_iso} value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
placeholder="تاریخ" placeholder="تاریخ"
className="w-36 shrink-0"
disabled={disabled} disabled={disabled}
/> />
<div className="ms-amount-wrap"> </div>
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
onChange={(amount) => onChange({ ...milestone, amount })} onChange={(amount) => onChange({ ...milestone, amount })}
@@ -29,8 +33,15 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{/* دکمه حذف */}
{!disabled && ( {!disabled && (
<button type="button" className="ms-x" title="حذف" onClick={onRemove}> <button
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
× ×
</button> </button>
)} )}
+23 -17
View File
@@ -7,9 +7,11 @@ export interface NumberInputProps {
onChange: (n: number | null) => void; onChange: (n: number | null) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) maxDecimals?: number; // حداکثر رقم اعشار
disabled?: boolean; disabled?: boolean;
} }
@@ -19,6 +21,8 @@ export function NumberInput({
onChange, onChange,
placeholder, placeholder,
className, className,
inputClassName,
align = "right",
showWords = false, showWords = false,
inputMode = "numeric", inputMode = "numeric",
maxDecimals, maxDecimals,
@@ -36,8 +40,6 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : "")); const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => { useEffect(() => {
const cur = parseNum(text); const cur = parseNum(text);
if (value != null && value !== cur) { if (value != null && value !== cur) {
@@ -60,19 +62,28 @@ export function NumberInput({
const n = parseNum(text); const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return ( return (
<div className={cn("min-w-0", className)}> <div className={cn("min-w-0 w-full", className)}>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60" className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
inputMode={inputMode} inputMode={inputMode}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw); const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw); const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) { if (parsed != null && !trailingSep) {
@@ -85,16 +96,11 @@ export function NumberInput({
}} }}
onBlur={() => commit(text)} onBlur={() => commit(text)}
/> />
{showWords && ( {showWords && n != null && (
<span className={cn("num-pill", n == null && "empty")}> <div className="mt-1 block text-right text-[11px] text-muted">
{n != null ? ( <span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<> <span className="text-muted">({toWords(n)})</span>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span> </div>
</>
) : (
""
)}
</span>
)} )}
</div> </div>
); );
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
/**
* نمایشگر PDF با pdf.js (canvas) به‌جای <iframe>.
*
* چرا: کروم در حالت شبیه‌سازی موبایل (F12) پلاگین PDF درون‌خطی ندارد، پس
* <iframe src="...pdf"> را به‌جای نمایش، دانلود می‌کند — که دانلودمنیجرها
* شکارش می‌کنند و پیش‌نمایش خالی می‌ماند. با رسم روی canvas هیچ ناوبری‌ای
* به آدرس فایل انجام نمی‌شود، پس نه به پلاگین مرورگر وابسته‌ایم و نه چیزی
* برای دانلودمنیجر باقی می‌ماند.
*/
interface PdfCanvasProps {
url: string;
className?: string;
}
export function PdfCanvas({ url, className }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [pageCount, setPageCount] = useState(0);
useEffect(() => {
let cancelled = false;
let doc: { destroy: () => void } | null = null;
(async () => {
setLoading(true);
setError(null);
try {
// بارگذاری تنبل: pdf.js فقط وقتی پیش‌نمایش PDF باز می‌شود دانلود می‌شود
const pdfjs = await import("pdfjs-dist");
// فقط workerSrc ست می‌شود — ست‌کردن هم‌زمان workerPort باعث تعارض
// می‌شد و بارگذاری سند در حلقه گیر می‌کرد («در حال بارگذاری…»).
// فایل worker با پسوند .mjs سرو می‌شود و nginx آن را
// text/javascript اعلام می‌کند (وگرنه مرورگر worker را رد می‌کند).
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
// بایت‌ها را خودمان با fetch می‌گیریم و به pdf.js به‌صورت داده
// می‌دهیم، به‌جای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
// (IDM) درخواست pdf.js به آدرس PDF را می‌رباید و با 204 برمی‌گرداند
// («Unexpected server response (204)»). با داده‌ی درون‌حافظه هیچ
// درخواست قابل‌ربودنی برای دانلودمنیجر باقی نمی‌ماند.
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const task = pdfjs.getDocument({ data: bytes });
// اگر worker/شبکه در دسترس نباشد، به‌جای چرخ «در حال بارگذاری…»
// بی‌پایان، خطای قابل‌فهم نشان بده.
const loaded = await Promise.race([
task.promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
),
]);
doc = loaded as unknown as { destroy: () => void };
if (cancelled) return;
setPageCount(loaded.numPages);
// فعلاً صفحه‌ی اول — برای پیش‌نمایشِ سریع کافی است
const page = await loaded.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
const containerWidth = canvas.parentElement?.clientWidth || 800;
const baseViewport = page.getViewport({ scale: 1 });
const dpr = window.devicePixelRatio || 1;
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
const viewport = page.getViewport({ scale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, viewport }).promise;
if (!cancelled) setLoading(false);
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
}
}
})();
return () => {
cancelled = true;
try {
doc?.destroy();
} catch {
/* نادیده بگیر */
}
};
}, [url]);
return (
<div className={className}>
{loading && !error && (
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
)}
{error ? (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="text-xs text-gray-600">نمایش پیش‌نمایش ممکن نشد: {error}</div>
</div>
) : (
<>
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
{pageCount > 1 && (
<div className="mt-2 text-center text-[11px] text-gray-500">
صفحه ۱ از {pageCount}
</div>
)}
</>
)}
</div>
);
}
+48 -12
View File
@@ -1,15 +1,27 @@
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react"; import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
import { fmtNum } from "../lib/formatters"; import { fmtNum } from "../lib/formatters";
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
export interface StatsBarProps { export interface StatsBarProps {
total: number; total: number;
offered: number; offered: number;
accepted: number; accepted: number;
avgLeadTime: number | null; // روز arrived: number;
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
} }
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { export function StatsBar({
total,
offered,
accepted,
arrived,
activeFilter = "all",
onFilterChange,
}: StatsBarProps) {
const stats: { const stats: {
key: StatusFilter;
label: string; label: string;
shortLabel: string; shortLabel: string;
value: string; value: string;
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: LucideIcon; icon: LucideIcon;
}[] = [ }[] = [
{ {
key: "all",
label: "کل پیشنهادها", label: "کل پیشنهادها",
shortLabel: "کل", shortLabel: "کل",
value: fmtNum(total), value: fmtNum(total),
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
key: "offered",
label: "پیشنهاد باز", label: "پیشنهاد باز",
shortLabel: "باز", shortLabel: "باز",
value: fmtNum(offered), value: fmtNum(offered),
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: Inbox, icon: Inbox,
}, },
{ {
key: "accepted",
label: "پذیرفته‌شده", label: "پذیرفته‌شده",
shortLabel: "پذیرفته", shortLabel: "پذیرفته",
value: fmtNum(accepted), value: fmtNum(accepted),
@@ -39,21 +54,41 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
label: "میانگین زمان تحویل", key: "arrived",
shortLabel: "میانگین تحویل", label: "تحویل‌شده به کارگاه",
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), shortLabel: "تحویل",
unit: avgLeadTime != null ? "روز" : undefined, value: fmtNum(arrived),
color: "text-green", color: "text-green",
icon: Timer, icon: PackageCheck,
}, },
]; ];
const handleClick = (key: StatusFilter) => {
if (!onFilterChange) return;
if (key === "all") {
onFilterChange("all");
} else {
onFilterChange(activeFilter === key ? "all" : key);
}
};
return ( return (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3"> <section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => ( {stats.map((s) => {
const isClickable = Boolean(onFilterChange);
const isActive = activeFilter === s.key;
return (
<div <div
key={s.label} key={s.label}
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right" onClick={() => handleClick(s.key)}
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
> >
{/* عنوان کارت */} {/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between"> <div className="flex items-center justify-center sm:justify-between">
@@ -68,7 +103,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
</span> </span>
</div> </div>
{/* مقدار عددی به همراه واحد روز */} {/* مقدار عددی */}
<div <div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`} className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
> >
@@ -80,7 +115,8 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
)} )}
</div> </div>
</div> </div>
))} );
})}
</section> </section>
); );
} }
+1 -8
View File
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects"; export { useProjects, useAddProject } from "./useProjects";
export { export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
useUnits,
useSaveRefUnit,
useSaveConversion,
useDeleteConversion,
useSaveStandardConversion,
useDeleteStandardConversion,
} from "./useUnits";
+4 -40
View File
@@ -1,52 +1,16 @@
// ============================================================ // ============================================================
// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت // hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند)
// ============================================================ // ============================================================
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types"; import type { StandardConversion } from "../lib/types";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import { useInvalidateStore } from "./useStoreData"; import { useInvalidateStore } from "./useStoreData";
export function useUnits(): { export function useUnits(): { standardConversions: StandardConversion[] } {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
} {
const categoryUnits = useAppStore((s) => s.categoryUnits);
const unitConversions = useAppStore((s) => s.unitConversions);
const standardConversions = useAppStore((s) => s.standardConversions); const standardConversions = useAppStore((s) => s.standardConversions);
return { categoryUnits, unitConversions, standardConversions }; return { standardConversions };
}
export function useSaveRefUnit() {
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
onMutate: (cu) => setCategoryUnit(cu),
onSettled: () => invalidate(),
});
}
export function useSaveConversion() {
const setConversion = useAppStore((s) => s.setConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
onMutate: (c) => setConversion(c),
onSettled: () => invalidate(),
});
}
export function useDeleteConversion() {
const deleteConversion = useAppStore((s) => s.deleteConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.units.deleteConversion(id),
onMutate: (id) => deleteConversion(id),
onSettled: () => invalidate(),
});
} }
export function useSaveStandardConversion() { export function useSaveStandardConversion() {
+24
View File
@@ -22,8 +22,31 @@ declare global {
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim(); const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim(); const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim(); const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
/**
* Load the OpenPanel browser script (op1.js) and its queueing shim.
*
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
* script is in flight, so `op("init", …)` can be called immediately and the real
* script drains the queue on load. Without it, every `window.op?.()` call here is a
* silent no-op — which is what happened before this loader existed.
*/
function loadOpenPanelScript(src: string): void {
if (document.querySelector("script[data-openpanel]")) return;
const shim = document.createElement("script");
shim.textContent =
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
document.head.appendChild(shim);
const script = document.createElement("script");
script.src = src;
script.async = true;
script.defer = true;
script.dataset.openpanel = "1";
document.head.appendChild(script);
}
export function initAnalytics(): void { export function initAnalytics(): void {
if (SENTRY_DSN) { if (SENTRY_DSN) {
Sentry.init({ Sentry.init({
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
} }
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) { if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
try { try {
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
window.op?.("init", { window.op?.("init", {
apiUrl: OPENPANEL_URL, apiUrl: OPENPANEL_URL,
clientId: OPENPANEL_CLIENT_ID, clientId: OPENPANEL_CLIENT_ID,
+88 -115
View File
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
CategoryUnit,
ConfirmedReceipt, ConfirmedReceipt,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
@@ -30,7 +29,6 @@ import type {
Store, Store,
Supplier, Supplier,
TransactionType, TransactionType,
UnitConversion,
Warehouse, Warehouse,
} from "./types"; } from "./types";
@@ -70,6 +68,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
return headers; return headers;
} }
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همه‌ی
// مسیرها الزامی شد). نشست را پاک می‌کنیم و به صفحه‌ی ورود می‌رویم تا به‌جای
// «برنامه‌ی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
// دخالت نمی‌کنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقه‌ی ریدایرکت).
function handle401(path: string, res: Response): void {
if (res.status !== 401) return;
if (path.startsWith("/auth/login")) return;
if (useAuthStore.getState().isDev) return;
if (window.location.pathname === "/login") return;
useAuthStore.getState().logout();
window.location.replace("/login");
}
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار // پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار
// صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند. // صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند.
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
@@ -186,6 +198,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch { } catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0); throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
} }
handle401(path, res);
if (!res.ok) { if (!res.ok) {
let msg = `خطای سرور (${res.status})`; let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined; let errCode: string | undefined;
@@ -237,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data; return res.data;
} }
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data; return res.data;
@@ -261,6 +270,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
} catch { } catch {
throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟"); throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
} }
handle401(path, res);
if (!res.ok) { if (!res.ok) {
let msg = `خطای سرور (${res.status})`; let msg = `خطای سرور (${res.status})`;
try { try {
@@ -306,7 +316,9 @@ interface RawSupplier {
phone?: string | null; phone?: string | null;
contact_person?: string | null; contact_person?: string | null;
tax_id?: string | null; tax_id?: string | null;
categories?: string[] | null; // متریال‌های تأمین‌شده // v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null;
category?: { id: string; slug: string; name: string } | null;
} }
interface RawProject { interface RawProject {
id: string; id: string;
@@ -383,21 +395,6 @@ interface RawQuote {
line_items?: RawLineItem[] | null; line_items?: RawLineItem[] | null;
termsheets?: RawTermsheet[] | null; termsheets?: RawTermsheet[] | null;
} }
interface RawCategoryUnit {
id: string;
category_id: string;
ref_unit: string;
category?: RawCategory | null;
}
interface RawUnitConversion {
id: string;
category_id: string;
brand?: string | null;
from_unit: string;
to_unit: string;
factor: number;
category?: RawCategory | null;
}
interface RawStandardConversion { interface RawStandardConversion {
id: string; id: string;
from_unit: string; from_unit: string;
@@ -543,7 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
topic: topic:
raw.topic || raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "", brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
specs: (raw.attributes || []) specs: (raw.attributes || [])
@@ -624,11 +620,13 @@ function adaptQuote(raw: RawQuote): Quote {
} }
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
// هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
const own = raw.category?.name ? [raw.category.name] : [];
return { return {
id: raw.id, id: raw.id,
topic, topic,
// دسته‌های ذخیره‌شده روی تأمین‌کننده (بک‌اند) اولویت دارند؛ وگرنه از quotes استنتاج می‌شود categories: own.length ? own : categories,
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
name: raw.name, name: raw.name,
location: raw.location || "", location: raw.location || "",
phone: raw.phone || "", phone: raw.phone || "",
@@ -646,44 +644,29 @@ function deriveSupplierTopics(
materialsById: Map<string, Material>, materialsById: Map<string, Material>,
): Supplier[] { ): Supplier[] {
const topics = new Map<string, Set<string>>(); const topics = new Map<string, Set<string>>();
const cats = new Map<string, Set<string>>();
for (const q of quotes) { for (const q of quotes) {
for (const li of q.line_items) { for (const li of q.line_items) {
const m = materialsById.get(li.material_id); const m = materialsById.get(li.material_id);
if (!m) continue; if (!m) continue;
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
if (m.topic) topics.get(q.supplier_id)!.add(m.topic); if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
if (m.category) cats.get(q.supplier_id)!.add(m.category);
} }
} }
return rawSuppliers.map((s) => { return rawSuppliers.map((s) => {
const t = topics.get(s.id); const t = topics.get(s.id);
const c = cats.get(s.id); const derivedTopic = t && t.size ? [...t][0] : "";
// اگر از quotes topic/category پیدا نشد، registry را چک کن // اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!t || t.size === 0) { if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id); const reg = supplierTopicRegistry.get(s.id);
if (reg) { if (reg) return adaptSupplier(s, reg.topic, reg.categories);
return adaptSupplier(s, reg.topic, reg.categories);
} }
} // دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []); // تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
const backendCat = s.category?.name || "";
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
}); });
} }
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
}
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
return {
id: raw.id,
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
brand: raw.brand || "",
from_unit: raw.from_unit,
to_unit: raw.to_unit,
factor: raw.factor,
};
}
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
} }
@@ -801,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
export const api = { export const api = {
// ---------- کل store (یک بار در ریشه) ---------- // ---------- کل store (یک بار در ریشه) ----------
async listAll(): Promise<Store> { async listAll(): Promise<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([ await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`), getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`), getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`), getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`), getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`), getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
]); ]);
@@ -825,8 +806,6 @@ export const api = {
materials, materials,
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
quotes, quotes,
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion), standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes), topics: buildTopics(categoriesRes),
}; };
@@ -1041,6 +1020,41 @@ export const api = {
}, },
}, },
drafts: {
get: async (
id: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/${id}`);
},
update: async (
id: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/${id}`, { payload });
},
commit: async (
id: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/${id}/commit`, { quote_id: quoteId });
},
cancel: async (
id: string,
): Promise<void> => {
await postData(`/drafts/${id}/cancel`, {});
},
},
suppliers: { suppliers: {
list: async (): Promise<Supplier[]> => { list: async (): Promise<Supplier[]> => {
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`); const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
@@ -1101,19 +1115,13 @@ export const api = {
materials: { materials: {
list: async (): Promise<Material[]> => { list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`); const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`); const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
}, },
create: async (m: Material): Promise<Material> => { create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup(); const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند canonical_unit_id را الزامی می‌داند) // (بک‌اند canonical_unit_id را الزامی می‌داند)
const unitName = (m.base_unit || "").trim(); const unitName = (m.base_unit || "").trim();
@@ -1126,7 +1134,7 @@ export const api = {
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در // name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در
// attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود، // attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود،
// در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند. // در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند.
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید"; const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
// مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند // مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند
const attrs: { attribute_definition_id: string; value: string }[] = []; const attrs: { attribute_definition_id: string; value: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -1138,7 +1146,6 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
} }
const raw = await postData<RawMaterial>("/materials", { const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId ?? null,
name, name,
topic: m.topic || null, topic: m.topic || null,
canonical_unit_id: unitId, canonical_unit_id: unitId,
@@ -1168,46 +1175,6 @@ export const api = {
}, },
units: { units: {
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
return raw.map(adaptCategoryUnit);
},
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`);
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
},
getConversions: async (): Promise<UnitConversion[]> => {
const lookup = await fetchLookup();
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
return raw.map((uc) => adaptUnitConversion(uc, lookup));
},
saveConversion: async (c: UnitConversion): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(c.category));
if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`);
const body = {
category_id: categoryId,
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
};
if (looksLikeUuid(c.id)) {
await patchData(`/unit-conversions/${c.id}`, {
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
});
} else {
await postData("/unit-conversions", body);
}
},
deleteConversion: async (id: string): Promise<void> => {
await deleteData(`/unit-conversions/${id}`);
},
getStandardConversions: async (): Promise<StandardConversion[]> => { getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`); const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion); return raw.map(adaptStandardConversion);
@@ -1286,13 +1253,15 @@ export const api = {
// را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم. // را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم.
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => { download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
let res: Response; let res: Response;
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
try { try {
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, { res = await fetch(BASE_URL + dlPath, {
headers: authHeaders(), headers: authHeaders(),
}); });
} catch { } catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0); throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
} }
handle401(dlPath, res);
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status); if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1307,6 +1276,19 @@ export const api = {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
}, },
// آدرس blob احراز هویت‌شده برای پیش‌نمایش (تصویر/PDF در مودال).
// تگ‌های <img>/<iframe>/<a href> توکن نمی‌فرستند، پس بایت‌ها را با توکن
// آدرس پیش‌نمایش احراز هویت‌شده برای <iframe>/<img>.
// این‌ها نمی‌توانند هدر Authorization بفرستند، پس بک‌اند یک توکن
// امضاشده‌ی کوتاه‌عمر در کوئری می‌دهد. عمداً blob ساخته نمی‌شود:
// دانلودمنیجرها (IDM) blob را شکار می‌کنند و فایل ۰ بایتی می‌سازند،
// در حالی که این یک URL واقعی است و درست بارگذاری می‌شود.
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
const res = await getData<{ url: string; token: string }>(
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
);
return BASE_URL + res.url;
},
}, },
// ---------- برندها (از بک‌اند) ---------- // ---------- برندها (از بک‌اند) ----------
@@ -1705,7 +1687,8 @@ export const apiV26 = {
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body); const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => { // project_id عمداً در تایپ نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body); const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
@@ -1737,15 +1720,6 @@ export const apiV26 = {
types: async (): Promise<{ type: TransactionType; label: string }[]> => { types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
}, },
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
}, },
pm: { pm: {
@@ -1783,7 +1757,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> => }): Promise<{ id: string; name: string }> =>
postData("/materials", { postData("/materials", {
name: opts.name, name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null, topic: null,
canonical_unit_id: opts.canonical_unit_id, canonical_unit_id: opts.canonical_unit_id,
status: "active", status: "active",
+100 -79
View File
@@ -2,7 +2,7 @@
// منطق بیزنس مشترک پیشنهادها — پورت از app.js // منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================ // ============================================================
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types"; import type { LineItem, Material, Quote, Store, Supplier } from "./types";
import { normalize } from "./normalize"; import { normalize } from "./normalize";
import { groupCommas } from "./numutil"; import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters"; import { fmtDate } from "./formatters";
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
id: "", id: "",
name: "", name: "",
topic: "", topic: "",
category: "؟",
brand: "", brand: "",
specs: [], specs: [],
base_unit: "", base_unit: "",
@@ -35,43 +34,51 @@ export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|"); return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
} }
// specها با جداکننده‌ی « · » // specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string { export function specsLabel(specs: string[]): string {
return specs.join(" · "); return specs.join(" . ");
} }
/** /**
* عنوان تمیز و بدون تکرار کالا: * عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک. * ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/ */
export function formatMaterialTitle( export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined, m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
extraSpecs?: string[], extraSpecs?: string[],
): string { ): string {
if (!m) return "کالا"; if (!m) return "کالا";
const rawName = (m.name || "").trim(); let rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim(); const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim()) .map((s) => (s || "").trim())
.filter(Boolean); .filter(Boolean);
const normName = normalize(rawName);
const normBrand = normalize(rawBrand); const normBrand = normalize(rawBrand);
if (!rawName) { // اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`; if (normBrand && rawName) {
if (rawBrand) return rawBrand; const normName = normalize(rawName);
if (rawSpecs.length) return rawSpecs.join(" · "); if (normName.startsWith(normBrand)) {
return m.category && m.category !== "؟" ? m.category : "کالا"; const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
} }
let title = rawName; // قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
if (normBrand && !normName.includes(normBrand)) { const parts: string[] = [];
title = `${rawBrand} ${title}`; if (rawBrand) {
parts.push(rawBrand);
}
if (rawName) {
parts.push(rawName);
} else if (!rawBrand) {
parts.push("کالا");
} }
const normTitle = normalize(title); // اضافه کردن مشخصه‌ها بدون تکرار
const distinctSpecs: string[] = []; const normCurrent = normalize(parts.join(" "));
const seenNormSpecs = new Set<string>(); const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) { for (const s of rawSpecs) {
@@ -79,21 +86,75 @@ export function formatMaterialTitle(
if (!ns || seenNormSpecs.has(ns)) continue; if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns); seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم // اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue; if (normCurrent.includes(ns)) continue;
distinctSpecs.push(s); parts.push(s);
} }
if (distinctSpecs.length > 0) { return parts.join(" . ");
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
} }
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string { export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
return formatMaterialTitle(m); return formatMaterialTitle(m);
} }
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ
// پایه باقی بماند.
export function materialBaseName(name: string, brand: string, specs: string[]): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
/**
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
*
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
* برند و مشخصه‌ی یکسان (مثل دو چمن مصنوعیِ هم‌برند) را می‌گیرد. نامِ خالی یعنی
* هویت نامعلوم — در آن حالت تطبیق نمی‌دهیم و متریالِ تازه ساخته می‌شود.
*/
export function matchMaterial(
materials: Material[],
name: string,
brand: string,
specs: string[],
): Material | undefined {
const wantName = normalize(name);
if (!wantName) return undefined;
const wantBrand = normalize(brand);
const wantSpecs = specsKey(specs);
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
const base = normalize(materialBaseName(name, brand, specs));
return (
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
materials.find(
(m) =>
base &&
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
sameIdentity(m),
)
);
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) // آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
export function itemTermsheetIds(li: LineItem): string[] { export function itemTermsheetIds(li: LineItem): string[] {
if (li.termsheet_ids) return li.termsheet_ids; if (li.termsheet_ids) return li.termsheet_ids;
@@ -153,54 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
} }
// ---------- واحد مرجع و ضریب تبدیل ----------
// واحد مرجع یک دسته
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
return cu ? cu.ref_unit : "";
}
// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
export function findConversion(
store: Pick<Store, "unitConversions" | "standardConversions">,
category: string,
brand: string,
fromUnit: string,
toUnit: string,
): UnitConversion | StandardConversion | null {
const list = (store.unitConversions || []).filter(
(c) =>
normalize(c.category) === normalize(category) &&
normalize(c.from_unit) === normalize(fromUnit) &&
normalize(c.to_unit) === normalize(toUnit) &&
(!c.brand || normalize(c.brand) === normalize(brand)),
);
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
if (list[0]) return list[0];
return (
(store.standardConversions || []).find(
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
) || null
);
}
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
export function refConverted(
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
li: LineItem,
): { qty: number; unit: string; perRef: number | null } | null {
const m = materialById(store.materials, li.material_id);
const ref = refUnitForCategory(store, m.category);
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
if (!conv) return null;
const qty = (li.quantity || 0) * conv.factor;
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
const perRef = qty > 0 ? Math.round(total / qty) : null;
return { qty, unit: ref, perRef };
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها // واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود // واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
@@ -270,9 +283,8 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
// فیلترهای دفتر // فیلترهای دفتر
export function filterQuotes( export function filterQuotes(
store: Store, store: Store,
filters: { category: string; supplier: string; status: string; search: string }, filters: { supplier: string; status: string; search: string },
): Quote[] { ): Quote[] {
const catVal = filters.category.trim();
const supVal = filters.supplier.trim(); const supVal = filters.supplier.trim();
const stVal = filters.status.trim(); const stVal = filters.status.trim();
const q = filters.search.trim().toLowerCase(); const q = filters.search.trim().toLowerCase();
@@ -281,8 +293,13 @@ export function filterQuotes(
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"رسیده": "arrived", "رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
}; };
const stKey = STATUS_BY_LABEL[stVal] || ""; const stKey = STATUS_BY_LABEL[stVal] || stVal;
return store.quotes.filter((quote) => { return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id); const supObj = supplierById(store.suppliers, quote.supplier_id);
@@ -290,12 +307,11 @@ export function filterQuotes(
if (stKey && quote.status !== stKey) return false; if (stKey && quote.status !== stKey) return false;
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
if (q) { if (q) {
const hay = const hay =
`${supObj.name} ${quote.project || ""} ` + `${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false; if (!hay.toLowerCase().includes(q)) return false;
} }
return true; return true;
@@ -306,11 +322,16 @@ export function filterQuotes(
export function isExpired(q: Quote): boolean { export function isExpired(q: Quote): boolean {
return ( return (
q.status === "offered" && q.status === "offered" &&
!quoteHasArrivedOrAccepted(q) &&
!!q.expiry_date && !!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString()) new Date(q.expiry_date) < new Date(new Date().toDateString())
); );
} }
function quoteHasArrivedOrAccepted(q: Quote): boolean {
return q.status === "accepted" || q.status === "arrived";
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded // آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks( export function termSheetBlocks(
store: Store, store: Store,
+1 -21
View File
@@ -20,9 +20,8 @@ export interface Supplier {
// ماده/کالا // ماده/کالا
export interface Material { export interface Material {
id: string; // "m1", "m2", ... id: string; // "m1", "m2", ...
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه) name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs
topic: string; topic: string;
category: string; // مثلاً "میلگرد"
brand: string; // مثلاً "ذوب آهن" brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه base_unit: string; // واحد پایه
@@ -97,23 +96,6 @@ export interface Quote {
termsheets: Termsheet[]; termsheets: Termsheet[];
} }
// واحد مرجع هر دسته
export interface CategoryUnit {
id: string;
category: string;
ref_unit: string;
}
// ضریب تبدیل (دسته‌ای)
export interface UnitConversion {
id: string;
category: string;
brand: string; // خالی = همه برندها
from_unit: string;
to_unit: string;
factor: number; // 1 from_unit = factor * to_unit
}
// ضریب تبدیل استاندارد (جهانی) // ضریب تبدیل استاندارد (جهانی)
export interface StandardConversion { export interface StandardConversion {
id: string; id: string;
@@ -128,8 +110,6 @@ export interface Store {
materials: Material[]; materials: Material[];
projects: Project[]; projects: Project[];
quotes: Quote[]; quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
} }
+10 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react"; import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
@@ -33,6 +33,8 @@ export function LoginPage() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
const setDevUser = useAuthStore((s) => s.setDevUser); const setDevUser = useAuthStore((s) => s.setDevUser);
const navigate = useNavigate(); const navigate = useNavigate();
const [params] = useSearchParams();
const returnUrl = params.get("returnUrl");
// ---- فرم اصلی (Casdoor) ---- // ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -83,8 +85,12 @@ export function LoginPage() {
identifyUser({ id: user.id, name: user.display_name, role: user.role }); identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role }); track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`); toast.success(`خوش آمدید، ${user.display_name}`);
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید // مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/"); navigate(user.role === "pm" ? "/inventory" : "/");
}
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود"); toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally { } finally {
@@ -125,8 +131,9 @@ export function LoginPage() {
} }
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null); setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`); toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
// ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار if (returnUrl) {
if (devRole === "admin") navigate("/admin"); navigate(decodeURIComponent(returnUrl));
} else if (devRole === "admin") navigate("/admin");
else if (devRole === "pm") navigate("/inventory"); else if (devRole === "pm") navigate("/inventory");
else navigate("/"); else navigate("/");
}; };
+92 -71
View File
@@ -36,7 +36,6 @@ export interface AcceptModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -51,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [proformas, setProformas] = useState<Attachment[]>([]); const [proformas, setProformas] = useState<Attachment[]>([]);
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set()); const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({}); const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString()); const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
// وقتی quote باز شد، state اولیه را بساز
useMemo(() => { useMemo(() => {
if (!q) return; if (!q) return;
const offered = q.termsheets.filter((t) => t.status === "offered"); const offered = q.termsheets.filter((t) => t.status === "offered");
@@ -70,7 +66,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
// پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string> = {}; const projSel: Record<string, string> = {};
q.line_items.forEach((li) => { q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean); const names = (li.projects || []).map((p) => p.name).filter(Boolean);
@@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setReady(true); setReady(true);
}, [q]); }, [q]);
// پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const allItems = q.line_items || []; const allItems = q.line_items || [];
const active = activeItems(q); const active = activeItems(q);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها
const projectOptions = (() => { const projectOptions = (() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const confirm = () => { const confirm = () => {
if (!q) return; if (!q) return;
// پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store)
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>(); const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
cards.forEach((c, idx) => { cards.forEach((c, idx) => {
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null); const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
if (!cardMeta.size) { if (!cardMeta.size) {
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
return; return;
} }
// رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true; if (removedItems.has(li.id)) li.rejected = true;
}); });
@@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return; return;
} }
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند
const fallbackDom = [...cardMeta.keys()][0]; const fallbackDom = [...cardMeta.keys()][0];
const chosenDomIds = new Set<string>(); const chosenDomIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
@@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
} }
}); });
// قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود
active.forEach((li) => { active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom; const dom = assignments[li.id] || fallbackDom;
const finalId = domToFinal[dom]; const finalId = domToFinal[dom];
@@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
li.accepted_ts_id = finalId || null; li.accepted_ts_id = finalId || null;
}); });
// termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered
const usedIds = new Set<string>(); const usedIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id); if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
@@ -242,26 +229,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return ( return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<DialogHeader> <div className="flex flex-col max-h-[85vh] w-full">
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
</DialogHeader>
<div className="text-[12px] text-muted"> {/* هدر ثابت و هم‌تراز */}
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید. <div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name}
</DialogTitle>
</DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
</p>
</div> </div>
{/* کارت‌های termsheet */} {/* بدنه اسکرول‌پذیر */}
<div className="flex flex-col gap-2"> <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
{/* بخش ۱: تنظیم شرایط پرداخت */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
<div className="flex flex-col gap-4">
{cards.map((c, idx) => ( {cards.map((c, idx) => (
<div key={c.domId} className="modal-variant"> <div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<div className="modal-variant-head"> <div className="modal-variant-head mb-2 flex items-center justify-between">
<span className="variant-num">{cardLabel(c, idx)}</span> <span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}> <button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
× ×
</button> </button>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
{c.milestones.map((m, i) => ( {c.milestones.map((m, i) => (
<MilestoneRow <MilestoneRow
key={i} key={i}
@@ -275,31 +273,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/> />
))} ))}
</div> </div>
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}> <Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت + مرحله پرداخت جدید
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}> <Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ نوشتن conditions جدید + افزودن شرایط پرداخت جدید
</Button> </Button>
</section>
{/* تخصیص قلم‌ها */} {/* بخش ۲: تخصیص اقلام */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-4 text-[12px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div> </div>
<div> <div className="flex flex-col gap-2">
{allItems.map((li) => { {allItems.map((li) => {
const removed = removedItems.has(li.id); const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
return ( return (
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}> <div
key={li.id}
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
>
<input <input
type="checkbox" type="checkbox"
className="accent-accent" className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
checked={!removed} checked={!removed}
title="تیک = در معامله می‌ماند" title="تیک = در معامله می‌ماند"
onChange={(e) => { onChange={(e) => {
@@ -309,16 +311,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(next); setRemovedItems(next);
}} }}
/> />
<span className="flex-1 text-[13px]"> <div className="flex-1 min-w-[120px]">
<div className="text-[13.5px] font-medium text-slate-800">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </div>
<span className="text-[12px] text-muted"> <div className="text-[12px] text-muted mt-0.5">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</span> </div>
</div>
<select <select
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
value={assignments[li.id] || cards[0]?.domId || ""} value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => assign(li.id, e.target.value)} onChange={(e) => assign(li.id, e.target.value)}
disabled={removed}
> >
{cards.map((c, idx) => ( {cards.map((c, idx) => (
<option key={c.domId} value={c.domId}> <option key={c.domId} value={c.domId}>
@@ -330,35 +336,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
); );
})} })}
{active.length === 0 && ( {active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div> <div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div>
)} )}
</div> </div>
</div> </section>
{/* تخصیص قلم‌ها به پروژه */} {/* بخش ۳: تخصیص اقلام به پروژه */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-4 text-[12px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت. برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div> </div>
<div className="flex flex-col gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems {allItems
.filter((li) => !removedItems.has(li.id)) .filter((li) => !removedItems.has(li.id))
.map((li) => { .map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
return ( return (
<div key={li.id} className="rounded-lg border border-line p-2.5"> <div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-1.5 text-[12.5px]"> <div className="mb-3 flex items-center justify-between">
<span className="font-semibold"> <span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)} {formatMaterialTitle(m)}
</span> </span>
<span className="ms-2 text-[11px] text-muted"> <span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
</div> </div>
{projectOptions.length ? ( {projectOptions.length ? (
<select <select
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
value={itemProjectsSel[li.id] ?? ""} value={itemProjectsSel[li.id] ?? ""}
onChange={(e) => onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value })) setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
@@ -376,17 +384,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
))} ))}
</select> </select>
) : ( ) : (
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div> <div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)} )}
</div> </div>
); );
})} })}
</div> </div>
</div> </section>
{/* فاکتورهای اصلی */} {/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -398,12 +407,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
) )
} }
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)} onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/> />
</div> </div>
{/* پیش‌فاکتورها */} <div className="h-px bg-line w-full"></div>
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div> <div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList <FileList
files={proformas} files={proformas}
onChange={setProformas} onChange={setProformas}
@@ -415,24 +426,34 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
) )
} }
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)} onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور" label="افزودن پیش‌فاکتور"
/> />
</div> </div>
{/* تاریخ و زمان پذیرش */} <div className="h-px bg-line w-full"></div>
<div className="border-t border-line pt-3">
<div className="field max-w-xs"> <div className="max-w-xs">
<label>تاریخ و زمان پذیرش</label> <label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime /> <JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div> </div>
</section>
</div> </div>
<DialogFooter> {/* فوتر ثابت */}
<Button variant="slate" onClick={onClose}> <div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف انصراف
</Button> </Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button> <Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter> </DialogFooter>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+27 -15
View File
@@ -11,16 +11,15 @@ import {
} from "../../hooks"; } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils"; import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar } from "../../components/StatsBar"; import { StatsBar, type StatusFilter } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar"; import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable"; import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
@@ -29,7 +28,7 @@ export function DashboardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand); const toggleExpand = useAppStore((s) => s.toggleExpand);
@@ -41,8 +40,8 @@ export function DashboardPage() {
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const store = useMemo( const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }), () => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], [quotes, suppliers, materials, projects, standardConversions, topics],
); );
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
@@ -56,19 +55,31 @@ export function DashboardPage() {
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b))); return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]); }, [store, filters, sortDir]);
// شمارش تفکیکی پیشنهادها بر اساس پایپ‌لاین خرید
const stats = useMemo(() => { const stats = useMemo(() => {
const total = quotes.length; const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length; const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length; const accepted = quotes.filter((q) => q.status === "accepted").length;
const leadTimes = quotes const arrived = quotes.filter((q) => q.status === "arrived").length;
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date) return { total, offered, accepted, arrived };
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
}, [quotes]); }, [quotes]);
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); // وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
const activeStatusFilter: StatusFilter = useMemo(() => {
if (filters.status === "offered") return "offered";
if (filters.status === "accepted") return "accepted";
if (filters.status === "arrived") return "arrived";
return "all";
}, [filters.status]);
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
const handleStatusFilterChange = (status: StatusFilter) => {
setFilters((prev) => ({
...prev,
status: status === "all" ? "" : status,
}));
};
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return ( return (
@@ -119,12 +130,13 @@ export function DashboardPage() {
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
accepted={stats.accepted} accepted={stats.accepted}
avgLeadTime={stats.avgLead} arrived={stats.arrived}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/> />
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
categories={categoryOptions}
suppliers={supplierOptions} suppliers={supplierOptions}
sortDir={sortDir} sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))} onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
+382 -251
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2, AlertCircle } from "lucide-react"; import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types"; import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
import { api, nextDashedId, nextId } from "../../lib/api"; import { api, nextDashedId, nextId } from "../../lib/api";
@@ -14,17 +14,16 @@ import {
useAddSupplier, useAddSupplier,
useAddMaterial, useAddMaterial,
} from "../../hooks"; } from "../../hooks";
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants"; import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow"; import { TermsheetCard } from "../../components/TermsheetCard";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -37,7 +36,6 @@ import {
interface EditItemRow { interface EditItemRow {
key: string; key: string;
origLi?: LineItem; origLi?: LineItem;
projects?: { id: string; name: string }[];
cat: string; cat: string;
brand: string; brand: string;
specs: string[]; specs: string[];
@@ -53,7 +51,7 @@ interface EditItemRow {
interface EditTsCard { interface EditTsCard {
key: string; key: string;
domId: string; // "" یا "new-ts-..." برای کارت جدید domId: string;
milestones: Milestone[]; milestones: Milestone[];
} }
@@ -62,7 +60,6 @@ export interface EditModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال ویرایش کامل پیشنهاد — بهینه‌شده برای صفحات موبایل
export function EditModal({ quoteId, onClose }: EditModalProps) { export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -84,10 +81,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}, [q]); }, [q]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
const [supplierName, setSupplierName] = useState(""); const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -113,7 +112,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} }
}; };
// گزینه‌های مشخصه
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]); const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -134,7 +132,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows, globalSpecs]); }, [materials, itemRows, globalSpecs]);
// گزینه‌های پروژه
const projectOptions = useMemo(() => { const projectOptions = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -149,17 +146,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!q) return; if (!q) return;
const sup = supplierById(suppliers, q.supplier_id); const sup = supplierById(suppliers, q.supplier_id);
setSupplierName(sup.name); setSupplierName(sup.name);
// حفظ تمام پروژه‌های کاندیدا: اتحاد پروژه‌های سطح پیشنهاد و پروژه‌های کاندیدای هر قلم setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
const candidateSet = new Set<string>();
(q.projects || []).forEach((p) => p && candidateSet.add(p));
if (q.project) candidateSet.add(q.project);
q.line_items.forEach((li) => {
(li.projects || []).forEach((p) => {
const n = typeof p === "string" ? p : p.name;
if (n) candidateSet.add(n);
});
});
setSelectedProjects([...candidateSet]);
setNotes(q.notes || ""); setNotes(q.notes || "");
setItemRows( setItemRows(
q.line_items.map((li) => { q.line_items.map((li) => {
@@ -167,8 +154,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return { return {
key: li.id, key: li.id,
origLi: li, origLi: li,
projects: li.projects && li.projects.length ? [...li.projects] : [], cat: m.name || "",
cat: m.category || "",
brand: m.brand || "", brand: m.brand || "",
specs: materialSpecs(m), specs: materialSpecs(m),
qty: li.quantity, qty: li.quantity,
@@ -197,7 +183,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true); setReady(true);
}, [q, suppliers, materials]); }, [q, suppliers, materials]);
// بارگذاری فاکتورها
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -205,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
}, [quoteId]); }, [quoteId]);
if (!q || !ready) return null; const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const tsLabel = (card: EditTsCard, idx: number) => { const getCoveredIds = (card: EditTsCard) => {
const orig = q.termsheets.find((t) => t.id === card.domId); const cardId = card.domId || card.key;
return orig?.label || `گزینه ${faNum(idx + 1)}`; const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
}; };
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => { const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
}; };
const addItemRow = () => { const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [ setItemRows((rows) => [
...rows, ...rows,
{ {
key: "new-" + Math.random().toString(36).slice(2, 9), key: newKey,
cat: "", cat: "",
brand: "", brand: "",
specs: [], specs: [],
@@ -233,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "", unit: "",
price: null, price: null,
total: null, total: null,
tsDomIds: [], tsDomIds: defaultTsId ? [defaultTsId] : [],
acceptedTsId: null, acceptedTsId: null,
rejected: false, rejected: false,
restore: true, restore: true,
@@ -248,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => { const addTsCard = () => {
setTsCards((cards) => [ setTsCards((cards) => [
...cards, ...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, {
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]); ]);
}; };
const removeTsCard = (key: string) => { const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key)); setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
}; };
const confirm = async () => { const confirm = async () => {
@@ -264,7 +303,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده
if (supplierName.trim()) { if (supplierName.trim()) {
const norm = normalize(supplierName); const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm); const exist = suppliers.find((s) => normalize(s.name) === norm);
@@ -281,8 +319,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects]; next.projects = [...selectedProjects];
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
next.proformas = proformas;
// termsheetها
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => { tsCards.forEach((card, i) => {
@@ -292,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId ? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId; tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId); const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`; const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered"; const st = orig?.status || "offered";
@@ -299,7 +338,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}); });
next.termsheets = newTermsheets; next.termsheets = newTermsheets;
// اقلام
const newItems: LineItem[] = []; const newItems: LineItem[] = [];
const materialsCopy = [...materials]; const materialsCopy = [...materials];
for (const row of itemRows) { for (const row of itemRows) {
@@ -312,12 +350,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total; const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
let material = materialsCopy.find( let material = matchMaterial(materialsCopy, cat, brand, specs);
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
let materialId: string; let materialId: string;
if (material) { if (material) {
materialId = material.id; materialId = material.id;
@@ -326,7 +359,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
id: nextId("m", materialsCopy), id: nextId("m", materialsCopy),
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید", name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
topic: materialById(materials, row.origLi?.material_id || "").topic || "", topic: materialById(materials, row.origLi?.material_id || "").topic || "",
category: cat,
brand, brand,
specs, specs,
base_unit: unit, base_unit: unit,
@@ -344,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) { if (!rejected) {
row.tsDomIds.forEach((domId) => { row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId; const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId); if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
}); });
if (!termsheet_ids.length && next.termsheets.length) { if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id); termsheet_ids.push(next.termsheets[0].id);
@@ -361,15 +393,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
acceptedTsId = null; acceptedTsId = null;
} }
const candProjs =
row.projects && row.projects.length
? row.projects
: row.origLi?.projects && row.origLi.projects.length
? row.origLi.projects
: selectedProjects.map((pname) => ({ id: "", name: pname }));
newItems.push({ newItems.push({
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1), id: "li-" + (newItems.length + 1),
material_id: materialId, material_id: materialId,
quantity: qty, quantity: qty,
unit, unit,
@@ -379,7 +404,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
accepted_ts_id: acceptedTsId, accepted_ts_id: acceptedTsId,
rejected, rejected,
arrived_date: row.origLi?.arrived_date ?? null, arrived_date: row.origLi?.arrived_date ?? null,
projects: candProjs, projects: row.origLi?.projects ?? [],
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null), target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null), target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
}); });
@@ -388,14 +413,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const referencedTs = new Set<string>(); const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null; li.accepted_ts_id = null;
} }
}); });
// تاریخ‌ها و وضعیت
next.quote_date = quoteDate || q.quote_date; next.quote_date = quoteDate || q.quote_date;
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : ""; next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
next.accepted_date = acceptedDate; next.accepted_date = acceptedDate;
@@ -435,23 +459,102 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return ( return (
<> <>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6"> <DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
<DialogHeader> {/* هدر */}
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle> <DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
</DialogHeader> </DialogHeader>
{hasReceipts && ( {hasReceipts && (
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber"> <div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span> <span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div> </div>
)} )}
{/* اطلاعات بالایی — تک‌ستونه در موبایل، ۲ ستونه در دسکتاپ */} {/* تب‌بار */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("items")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "items"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Package className="h-3.5 w-3.5" />
<span>اقلام و سفارش</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(itemRows.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("terms")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "terms"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<CreditCard className="h-3.5 w-3.5" />
<span>شرایط پرداخت</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(tsCards.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("dates")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "dates"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Calendar className="h-3.5 w-3.5" />
<span>وضعیت و اسناد</span>
{invoices.length > 0 && (
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(invoices.length)}
</span>
)}
</button>
</div>
{/* محتوا */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام */}
{activeTab === "items" && (
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="text-xs font-semibold text-gray-600">تأمین‌کننده</label>
<div className="w-full min-w-0">
<Combobox <Combobox
value={supplierName} value={supplierName}
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)} onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
@@ -462,11 +565,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
onInputChange={(v) => setSupplierName(v)} onInputChange={(v) => setSupplierName(v)}
/> />
</div> </div>
</div>
<div className="flex min-w-0 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">پروژه</label> <label className="text-xs font-semibold text-gray-600">پروژه</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
values={selectedProjects} values={selectedProjects}
onChange={setSelectedProjects} onChange={setSelectedProjects}
@@ -476,113 +577,149 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div> </div>
</div> </div>
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2"> <div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-muted">یادداشت</label> <label className="text-xs font-semibold text-gray-600">یادداشت</label>
<textarea <textarea
rows={2} rows={2}
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
value={notes} value={notes}
placeholder="یادداشت‌های داخلی این معامله..."
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* اقلام */} {/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
<div className="border-t border-line pt-3"> <div className="space-y-3">
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div> <div className="flex items-center justify-between">
<div className="flex flex-col gap-3"> <span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
{itemRows.map((row) => ( </div>
{itemRows.map((row, idx) => (
<div <div
key={row.key} key={row.key}
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`} className={cn(
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
)}
> >
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4"> <div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
{/* متریال — تمام‌عرض در موبایل */} <div className="flex items-center gap-1.5 min-w-0">
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1"> <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
<label className="text-[11px] font-semibold text-muted">متریال</label> {faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900 truncate">
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
</span>
</div>
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="space-y-3">
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="متریال" placeholder="نام متریال"
value={row.cat} value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })} onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/> />
</div> </div>
{/* برند */} <div className="flex flex-col gap-1 min-w-0">
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<label className="text-[11px] font-semibold text-muted">برند</label>
<input <input
disabled={hasReceipts} disabled={hasReceipts}
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60" className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="برند" placeholder="برند"
value={row.brand} value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })} onChange={(e) => patchItem(row.key, { brand: e.target.value })}
/> />
</div> </div>
{/* واحد */}
<div className="col-span-1 flex min-w-0 flex-col gap-1">
<label className="text-[11px] font-semibold text-muted">واحد</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="واحد"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div> </div>
{/* مشخصه — تمام‌عرض در موبایل */} {/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1"> <div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted">مشخصه</label> <label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<div className="w-full min-w-0">
<MultiSelectCombobox <MultiSelectCombobox
disabled={hasReceipts} disabled={hasReceipts}
values={row.specs} values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })} onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions} options={specOptions}
placeholder="مشخصه" placeholder="انتخاب مشخصات..."
className="min-w-0"
/> />
</div> </div>
</div>
{/* تعداد */} {/* بخش مقادیر و محاسبات مالی */}
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
<label className="text-[11px] font-semibold text-muted">تعداد</label> {/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.qty} value={row.qty}
onChange={(qty) => { onChange={(qty) => {
patchItem(row.key, { patchItem(row.key, {
qty, qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total, total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
}); });
}} }}
placeholder="تعداد" placeholder="مقدار"
className="!text-right text-right"
/> />
</div> </div>
{/* قیمت واحد */} <div className="flex flex-col gap-1 min-w-0">
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label> <input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد (شاخه، متر، کیلو...)"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.price} value={row.price}
onChange={(price) => { onChange={(price) => {
patchItem(row.key, { patchItem(row.key, {
price, price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total, total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
}); });
}} }}
placeholder="قیمت واحد" placeholder="قیمت هر واحد"
showWords
maxDecimals={2} maxDecimals={2}
className="!text-right text-right"
/> />
</div> </div>
{/* قیمت کل */} {/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<div className="col-span-1 flex min-w-0 flex-col gap-1"> <div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[11px] font-semibold text-muted">قیمت کل</label> <label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
disabled={hasReceipts} disabled={hasReceipts}
value={row.total} value={row.total}
@@ -593,123 +730,133 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price, t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
}); });
}} }}
placeholder="قیمت کل" placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/> />
</div> </div>
{/* دکمه حذف */}
<div className="col-span-1 flex items-end justify-end pb-0.5">
{!hasReceipts && (
<button
type="button"
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span>حذف قلم</span>
</button>
)}
</div>
</div> </div>
{/* تخصیص termsheet */} {row.rejected && (
<div className="mt-2.5 border-t border-line/60 pt-2"> <div className="pt-2 border-t border-gray-100">
{row.rejected ? ( <label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<label className="ts-cb-label e-restore">
<input <input
type="checkbox" type="checkbox"
className="accent-blue-600 h-4 w-4 rounded"
checked={row.restore} checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })} onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/> />
بازگرداندن به معامله بازگرداندن این قلم به معامله
</label> </label>
) : (
<>
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => (
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
<input
type="checkbox"
disabled={hasReceipts}
className="me-1 accent-accent disabled:cursor-not-allowed"
checked={row.tsDomIds.includes(card.domId || card.key)}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
{tsLabel(card, idx)}
</label>
))}
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
))} ))}
</div>
{!hasReceipts && ( {!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}> <Button
+ افزودن قلم type="button"
variant="line"
size="sm"
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
onClick={addItemRow}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن قلم جدید</span>
</Button> </Button>
)} )}
</div> </div>
{/* termsheetها */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
<div className="flex flex-col gap-2.5">
{tsCards.map((card, idx) => (
<div key={card.key} className="variant-card">
<div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span>
{!hasReceipts && (
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
×
</button>
)}
</div> </div>
<div className="mb-1 flex flex-col gap-1.5"> )}
{card.milestones.map((ms, i) => (
<MilestoneRow {/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
key={i} {activeTab === "terms" && (
milestone={ms} <div className="space-y-3.5">
disabled={hasReceipts} <div className="flex items-center justify-between">
onChange={(nextMs) => <div>
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) }) <div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
} <div className="text-[11.5px] text-gray-500 mt-0.5">
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })} روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
/> </div>
))}
</div> </div>
{!hasReceipts && ( {!hasReceipts && (
<Button <Button
type="button"
variant="line" variant="line"
size="sm" size="sm"
className="mt-1" className="gap-1 text-xs text-gray-700"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })} onClick={addTsCard}
> >
+ مرحله پرداخت <Plus className="h-3.5 w-3.5" />
</Button> <span>افزودن گزینه جدید</span>
)}
</div>
))}
</div>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
+ افزودن termsheet
</Button> </Button>
)} )}
</div> </div>
{/* فاکتورها */} <div className="flex flex-col gap-3">
<div className="border-t border-line pt-3"> {tsCards.map((card, idx) => (
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <TermsheetCard
key={card.key}
index={idx + 1}
milestones={card.milestones}
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
items={itemOptions}
coveredIds={getCoveredIds(card)}
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
onRemove={() => removeTsCard(card.key)}
/>
))}
</div>
</div>
)}
{/* تب ۳: وضعیت و اسناد */}
{activeTab === "dates" && (
<div className="space-y-3.5">
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
<select
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
value={status}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
<div className="text-[11px] text-gray-500">
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
<div className="text-xs font-bold text-gray-900">تاریخ‌های چرخه خرید</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوست‌ها</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -721,72 +868,54 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
) )
} }
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)} onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/> />
</div> </div>
{/* تاریخ‌ها و وضعیت */} {/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */}
<div className="border-t border-line pt-3"> <div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="mb-2 text-[12px] font-bold text-muted">تاریخ‌ها و وضعیت:</div> <div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <FileList
<div className="flex min-w-0 flex-col gap-1.5"> files={proformas}
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label> onChange={setProformas}
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime /> upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div> </div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={status}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
{hasReceipts && (
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
</div> </div>
)} )}
</div> </div>
</div>
</div>
{/* فوتر و دکمه‌های اقدام */} {/* فوتر */}
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between"> <DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
{q.status === "offered" ? ( {q.status === "offered" ? (
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="w-full gap-1.5 sm:w-auto" className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
<span>حذف پیشنهاد</span> <span className="hidden sm:inline">حذف</span>
</Button> </Button>
) : ( ) : (
<div /> <div />
)} )}
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial"> <div className="flex items-center gap-2">
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
انصراف انصراف
</Button> </Button>
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial"> <Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
ذخیره تغییرات ذخیره تغییرات
</Button> </Button>
</div> </div>
@@ -794,19 +923,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* مودال تأیید حذف */} {/* مودال تایید حذف */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}> <Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6"> <DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader> <DialogHeader>
<DialogTitle>حذف پیشنهاد خرید</DialogTitle> <DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2 text-sm text-muted"> <div className="py-2 text-xs sm:text-sm text-gray-600">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است. آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div> </div>
<DialogFooter className="flex items-center justify-end gap-2 pt-2"> <DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button <Button
type="button" type="button"
variant="slate" variant="slate"
size="sm"
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
disabled={deleting} disabled={deleting}
> >
@@ -815,6 +945,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
size="sm"
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
disabled={deleting} disabled={deleting}
> >
+45 -39
View File
@@ -1,22 +1,44 @@
import { useState } from "react"; import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
export interface Filters { export interface Filters {
category: string;
supplier: string; supplier: string;
status: string; status: string;
search: string; search: string;
} }
// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری // گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]); const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
};
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
onChange: (f: Filters) => void; onChange: (f: Filters) => void;
categories: string[];
suppliers: { id: string; name: string }[]; suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc"; sortDir: "desc" | "asc";
onToggleSort: () => void; onToggleSort: () => void;
@@ -25,7 +47,6 @@ export interface FilterToolbarProps {
export function FilterToolbar({ export function FilterToolbar({
filters, filters,
onChange, onChange,
categories,
suppliers, suppliers,
sortDir, sortDir,
onToggleSort, onToggleSort,
@@ -34,10 +55,18 @@ export function FilterToolbar({
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch }); const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ // شمارش تعداد فیلترهای فعال به جز سرچ
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length; const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
const clearFilters = () => { const clearFilters = () => {
onChange({ ...filters, category: "", supplier: "", status: "" }); onChange({ ...filters, supplier: "", status: "" });
};
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
const handleStatusChange = (val: string) => {
const key = STATUS_TO_KEY[val] || val;
set({ status: key });
}; };
return ( return (
@@ -121,18 +150,6 @@ export function FilterToolbar({
{/* دراپ‌داون‌ها داخل کشو */} {/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5"> <div className="flex flex-col gap-3.5">
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه دسته‌ها"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label>
<Combobox <Combobox
@@ -148,12 +165,12 @@ export function FilterToolbar({
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label> <label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
<Combobox <Combobox
value={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه وضعیت‌ها" placeholder="همه وضعیت‌ها"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
</div> </div>
@@ -172,17 +189,6 @@ export function FilterToolbar({
{/* ===== نمای دسکتاپ (md به بالا) ===== */} {/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3"> <div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field min-w-[170px] flex-1"> <div className="field min-w-[170px] flex-1">
<label>تأمین‌کننده</label> <label>تأمین‌کننده</label>
<Combobox <Combobox
@@ -197,12 +203,12 @@ export function FilterToolbar({
<div className="field min-w-[150px] flex-1"> <div className="field min-w-[150px] flex-1">
<label>وضعیت</label> <label>وضعیت</label>
<Combobox <Combobox
value={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه" placeholder="همه"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
<div className="field min-w-[200px] flex-1"> <div className="field min-w-[200px] flex-1">
+90 -80
View File
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
import type { Quote, Store } from "../../lib/types"; import type { Quote, Store } from "../../lib/types";
import { import {
isExpired, isExpired,
itemsSummary,
materialById, materialById,
materialLabel, materialLabel,
projectCell, projectCell,
@@ -36,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
); );
} }
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
@@ -44,14 +42,16 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const pc = priceCell(total); const pc = priceCell(total);
const ts = termsheetSummary(store, q); const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q); const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون / دکمه پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = (
<button <button
onClick={onAccept} onClick={(e) => {
e.stopPropagation();
onAccept();
}}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform" className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@@ -136,39 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
expanded && "border-accent/60 ring-1 ring-accent/20", expanded && "border-accent/60 ring-1 ring-accent/20",
)} )}
> >
{/* سربرگ کارت */} {/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5"> <div
onClick={onToggleExpand}
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
{sup.name.slice(0, 1)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<Link <Link
to={`/suppliers/${q.supplier_id}`} to={`/suppliers/${q.supplier_id}`}
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]" onClick={(e) => e.stopPropagation()}
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
> >
{sup.name} {sup.name}
</Link> </Link>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{sup.location && (
<span className="truncate text-[10px] text-muted sm:text-[11px]">
{sup.location}
</span>
)}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</div>
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? ( {fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs"> <Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]} {STATUS_LABEL[q.status]}
</Badge> </Badge>
)} )}
{/* دکمه ویرایش */}
<button <button
className="chip h-7 px-2 sm:h-auto sm:px-2.5" type="button"
onClick={onEdit} className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد" title="ویرایش پیشنهاد"
aria-label="ویرایش" aria-label="ویرایش"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span> <span className="hidden sm:inline">ویرایش</span>
</button> </button>
{/* دکمه فلش اکسپند در هدر */}
<button <button
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5" type="button"
onClick={onToggleExpand} className={cn(
title={expanded ? "جمع کردن" : "نمایش جزئیات"} "inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
expanded && "bg-accent-bg text-accent-dark border-accent/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
aria-expanded={expanded} aria-expanded={expanded}
> >
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -176,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</div> </div>
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */} {/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"> <div
{/* ۱. پروژه — تمام‌عرض در موبایل */} onClick={onToggleExpand}
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
>
{/* ۱. پروژه */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1"> <InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]"> <div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
@@ -188,52 +233,28 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</InfoCell> </InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */} {/* ۲. اقلام */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1"> <InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? ( <div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
<div className="flex flex-col gap-1.5"> {activeItems.length > 0 ? (
<span className="font-bold text-xs text-ink sm:text-sm"> activeItems.map((li) => {
{fmtNum(summary.count)} قلم کالا
</span>
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
{store.quotes
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.slice(0, 3)
.map((li) => {
const m = materialById(store.materials, li.material_id); const m = materialById(store.materials, li.material_id);
return ( return (
<div <span key={li.id} className="break-words">
key={li.id} {materialLabel(m)}
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
>
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
• {materialLabel(m)}
</span> </span>
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
{fmtNum(li.quantity)}{" "}
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
</span>
</div>
); );
})} })
{(q.line_items?.length || 0) > 3 && (
<span className="pt-0.5 text-[10px] text-muted">
و {(q.line_items?.length || 0) - 3} قلم دیگر...
</span>
)}
</div>
</div>
) : ( ) : (
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]"> <span>—</span>
<span className="break-words">{summary.text}</span>
</div>
)} )}
</div>
{unitWarns.map((w) => ( {unitWarns.map((w) => (
<Link <Link
key={w.unit} key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`} to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
onClick={(e) => e.stopPropagation()}
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]" className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`} title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
> >
@@ -243,32 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))} ))}
</InfoCell> </InfoCell>
<InfoCell label="پروژه"> {/* ۳. مجموع مبلغ */}
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{(() => {
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
if (q.status === "accepted" || q.status === "arrived") {
// فقط پروژه‌ی هدف پذیرفته‌شده — همان منطق itemProjects در وضعیت accepted
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
return targetProj?.name ?? projectCell(q);
}
// وضعیت offered: همه‌ی پروژه‌های کاندیدا از line_items (همان منطق itemProjects)
const names = new Set<string>();
activeItems.forEach((li) =>
(li.projects || []).forEach((p) => {
const name = typeof p === "string" ? p : p.name;
if (name) names.add(name);
})
);
if (names.size > 0) return [...names].join("، ");
return projectCell(q);
})()}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع مبلغ" className="col-span-1"> <InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? ( {pc ? (
<div> <div>
@@ -325,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</span> </span>
</div> </div>
{/* جزئیات تکمیلی بازشونده */} {/* دکمه تمام‌عرض توضیحات بیشتر */}
{expanded && <QuoteDetail store={store} q={q} />} <button
type="button"
onClick={onToggleExpand}
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
>
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
</button>
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */}
{expanded && (
<div onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
</div> </div>
); );
} }
+26 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import type { Attachment, Quote, Store } from "../../lib/types"; import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { import {
@@ -7,12 +7,12 @@ import {
itemTermsheetIds, itemTermsheetIds,
lineItemTotal, lineItemTotal,
materialById, materialById,
refConverted,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters"; import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram"; import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils"; import { itemProjects } from "../../lib/quoteUtils";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
export interface QuoteDetailProps { export interface QuoteDetailProps {
store: Store; store: Store;
@@ -25,6 +25,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() })); const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
const [bump, setBump] = useState(0); const [bump, setBump] = useState(0);
// پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود).
// بایت‌ها با توکن گرفته می‌شوند چون <img>/<iframe> نمی‌توانند هدر بفرستند.
// کلید با پیشوند نوع پیوست ساخته می‌شود تا نوعش گم نشود.
const preview = useFilePreview((key, name) => {
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
void name;
return api.files.previewUrl(q.id, kind, id);
});
useEffect(() => { useEffect(() => {
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند // بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
const t = setTimeout(() => setBump((b) => b + 1), 350); const t = setTimeout(() => setBump((b) => b + 1), 350);
@@ -66,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid)); const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
const total = lineItemTotal(li); const total = lineItemTotal(li);
const projs = itemProjects(q, li); const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی): // رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد). // مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -130,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>} {whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
</div> </div>
)} )}
{conv && (
<div className="fb-conv">
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
</div>
)}
</div> </div>
); );
}; };
@@ -144,31 +146,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
attachments.proformas.forEach((pf, i) => { attachments.proformas.forEach((pf, i) => {
if (!pf.id) return; if (!pf.id) return;
attachParts.push( attachParts.push(
<span key={"pf" + i} className="inline-flex items-center">
<button <button
key={"pf" + i}
type="button" type="button"
className="detail-link" className="detail-link"
title="دانلود پیش‌فاکتور" title="مشاهده پیش‌فاکتور"
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)} disabled={preview.loadingId === `proforma:${pf.id}`}
onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
> >
<Paperclip className="me-1 inline h-3 w-3" /> <Paperclip className="me-1 inline h-3 w-3" />
پیش‌فاکتور: {pf.name} پیش‌فاکتور: {pf.name}
</button>, </button>
</span>,
); );
}); });
attachments.invoices.forEach((inv, i) => { attachments.invoices.forEach((inv, i) => {
if (!inv.id) return; if (!inv.id) return;
attachParts.push( attachParts.push(
<span key={"inv" + i} className="inline-flex items-center">
<button <button
key={"inv" + i}
type="button" type="button"
className="detail-link" className="detail-link"
title="دانلود فاکتور" title="مشاهده فاکتور"
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)} disabled={preview.loadingId === `invoice:${inv.id}`}
onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
> >
<Paperclip className="me-1 inline h-3 w-3" /> <Paperclip className="me-1 inline h-3 w-3" />
فاکتور اصلی: {inv.name} فاکتور اصلی: {inv.name}
</button>, </button>
</span>,
); );
}); });
@@ -251,6 +257,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
))} ))}
</div> </div>
)} )}
{preview.item && (
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
)}
</div> </div>
); );
} }
@@ -220,7 +220,7 @@ export function ChangeInventoryPage() {
let m = matByName(matName); let m = matByName(matName);
if (!m) { if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null }; m = { id: created.id, name: created.name, canonical_unit: null };
} }
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
+5 -54
View File
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav"; import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
@@ -55,8 +55,7 @@ export function InventoryPage() {
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
category: "",
warehouse: params.get("warehouse") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
@@ -81,7 +80,6 @@ export function InventoryPage() {
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.category && r.material.category?.id !== filters.category) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
@@ -132,7 +130,7 @@ export function InventoryPage() {
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user || (!isAdmin && !isPM)) {
@@ -248,40 +246,6 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && ( {showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* دسته‌بندی‌ها */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">دسته‌بندی متریال:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.category
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه دسته‌بندی‌ها
</button>
{lookup.categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.category === c.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{c.name}
</button>
))}
</div>
</div>
{/* پروژه‌ها (مخصوص ادمین) */} {/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && ( {isAdmin && lookup.projects.length > 0 && (
<div> <div>
@@ -324,7 +288,7 @@ export function InventoryPage() {
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
پاک کردن فیلترها پاک کردن فیلترها
@@ -347,7 +311,6 @@ export function InventoryPage() {
) : ( ) : (
groups.map((g) => { groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<InventoryMobileCard <InventoryMobileCard
key={g.key} key={g.key}
@@ -355,7 +318,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -410,7 +372,6 @@ export function InventoryPage() {
<tbody> <tbody>
{groups.map((g) => { {groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<GroupRow <GroupRow
key={g.key} key={g.key}
@@ -418,7 +379,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -475,7 +435,6 @@ function InventoryMobileCard({
isExp, isExp,
isAdmin, isAdmin,
matName, matName,
matCatName,
wh, wh,
projName, projName,
unit, unit,
@@ -486,7 +445,6 @@ function InventoryMobileCard({
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -508,11 +466,6 @@ function InventoryMobileCard({
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words"> <h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
{matName} {matName}
</h3> </h3>
{matCatName !== "—" && (
<span className="mt-0.5 inline-block text-[11px] text-muted">
{matCatName}
</span>
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -574,12 +527,11 @@ function InventoryMobileCard({
); );
} }
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: { function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
g: Group; g: Group;
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}> <tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td> <td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td> <td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
<div>{wh}</div> <div>{wh}</div>
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>} {isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
@@ -148,16 +148,8 @@ export function OrganizationPage() {
} }
}; };
const moveWarehouse = async (id: string, projectId: string) => { // انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
try { // انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
await apiV26.warehouses.update(id, { project_id: projectId });
toast.success("انبار منتقل شد ✓");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
}
};
const removeWarehouse = async (id: string) => { const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return; if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try { try {
@@ -329,16 +321,6 @@ export function OrganizationPage() {
> >
📊 داشبورد 📊 داشبورد
</button> </button>
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
value={w.project_id}
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
title="انتقال به پروژه دیگر"
>
{data.lookup.projects.map((p2) => (
<option key={p2.id} value={p2.id}>{p2.name}</option>
))}
</select>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button> <button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div> </div>
</div> </div>
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
export interface LookupData { export interface LookupData {
categories: { id: string; slug: string; name: string }[];
units: { id: number; code: string; name_fa: string | null }[]; units: { id: number; code: string; name_fa: string | null }[];
brands: { id: string; name: string }[]; brands: { id: string; name: string }[];
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]; materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]; projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
} }
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
const [inventory, setInventory] = useState<InventoryRow[]>([]); const [inventory, setInventory] = useState<InventoryRow[]>([]);
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]); const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
const [lookup, setLookup] = useState<LookupData>({ const [lookup, setLookup] = useState<LookupData>({
categories: [], units: [], brands: [], materials: [], projects: [], units: [], brands: [], materials: [], projects: [],
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([ const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.categories(),
apiV26.lookup.units(), apiV26.lookup.units(),
apiV26.lookup.brands(), apiV26.lookup.brands(),
apiV26.lookup.materials(), apiV26.lookup.materials(),
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
apiV26.inventory.list({ project_id: projectId }), apiV26.inventory.list({ project_id: projectId }),
apiV26.inventory.transactions({ project_id: projectId }), apiV26.inventory.transactions({ project_id: projectId }),
]); ]);
setLookup({ categories: cats, units, brands, materials: mats, projects: projs }); setLookup({ units, brands, materials: mats, projects: projs });
setWarehouses(whs); setWarehouses(whs);
setInventory(inv); setInventory(inv);
setTransactions(txs); setTransactions(txs);
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
return m ? m.name : "—"; return m ? m.name : "—";
} }
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
const c = categories.find((x) => x.id === id);
return c ? c.name : "—";
}
export function projName(projects: LookupData["projects"], id: string | null | undefined): string { export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
const p = projects.find((x) => x.id === id); const p = projects.find((x) => x.id === id);
return p ? p.name : "—"; return p ? p.name : "—";
+52 -32
View File
@@ -16,11 +16,9 @@ import {
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { import {
activeItems,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialLabel, materialLabel,
refConverted,
supplierById, supplierById,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
@@ -29,25 +27,45 @@ import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
function projectQuotes(quotes: Quote[], name: string): Quote[] { function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name); return quotes.filter(
(q) =>
(q.projects || []).includes(name) ||
q.project === name ||
(q.line_items || []).some(
(li) =>
li.target_project?.name === name ||
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
),
);
} }
// اقلام مربوط به این پروژه در یک quote // آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] { function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected); const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
if (targeted.length) return targeted;
if (items.some((li) => li.projects && li.projects.length)) { if (items.some((li) => li.projects && li.projects.length)) {
return items.filter((li) => (li.projects || []).some((p) => p.name === name)); return items.filter((li) => (li.projects || []).some((p) => p.name === name));
} }
return items; return items;
} }
function qTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t = lineItemTotal(li);
return sum + (t || 0);
}, 0);
}
function daysSince(iso: string | null): number | null { function daysSince(iso: string | null): number | null {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
@@ -62,11 +80,11 @@ export function ProjectsPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const addProject = useAddProject(); const addProject = useAddProject();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }; const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null); const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -147,10 +165,11 @@ export function ProjectsPage() {
// ---------- پنل جزئیات یک پروژه ---------- // ---------- پنل جزئیات یک پروژه ----------
function renderProjectDetail(store: Store, name: string): React.ReactElement { function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name); const quotes = projectQuotes(store.quotes, name);
const sumBy = (st: Quote["status"]) =>
quotes // جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
.filter((q) => q.status === st) const itemsTotal = (q: Quote) =>
.reduce((s, q) => s + qTotal(q), 0); itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
const matName = (mid: string) => { const matName = (mid: string) => {
const m = materialById(store.materials, mid); const m = materialById(store.materials, mid);
@@ -169,26 +188,27 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const itemLine = (li: Quote["line_items"][number]) => { const itemLine = (li: Quote["line_items"][number]) => {
const total = lineItemTotal(li); const total = lineItemTotal(li);
const conv = refConverted(store, li);
return ( return (
<div key={li.id}> <div key={li.id}>
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "} {matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
<b>{faNum(total || 0)} تومان</b> <b>{faNum(total || 0)} تومان</b>
{conv && (
<span className="pj-conv">
{" "}
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
</span>
)}
</div> </div>
); );
}; };
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes const offered = quotes
.filter((q) => q.status === "offered") .filter(
(q) =>
q.status === "offered" ||
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
)
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime()); .sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
const accepted = quotes.filter((q) => q.status === "accepted"); const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
const arrived = quotes.filter((q) => q.status === "arrived"); const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
const offeredRow = (q: Quote) => { const offeredRow = (q: Quote) => {
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null; const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
@@ -199,7 +219,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div> </div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div> <div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side"> <div className="pj-side">
جمع: <b>{faNum(qTotal(q))} تومان</b> جمع: <b>{faNum(itemsTotal(q))} تومان</b>
</div> </div>
<div className="pj-side"> <div className="pj-side">
اعتبار تا {fmtDate(q.expiry_date)}<br /> اعتبار تا {fmtDate(q.expiry_date)}<br />
@@ -240,7 +260,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div> </div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div> <div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side"> <div className="pj-side">
هزینه: <b>{faNum(qTotal(q))} تومان</b> هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
</div> </div>
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div> <div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
</div> </div>
@@ -255,13 +275,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
<div className="mb-3 flex flex-wrap gap-1.5"> <div className="mb-3 flex flex-wrap gap-1.5">
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span> <span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}> <span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
ارزش باز: {faNum(sumBy("offered"))} تومان ارزش باز: {faNum(sumBy(offered))} تومان
</span> </span>
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}> <span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
</span> </span>
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}> <span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
ارزش رسیده: {faNum(sumBy("arrived"))} تومان ارزش رسیده: {faNum(sumBy(arrived))} تومان
</span> </span>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
+28 -4
View File
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() { export function CategoryPage() {
useStoreData(); useStoreData();
const { category: catParam = "" } = useParams(); const { category: catParam = "" } = useParams();
@@ -36,12 +36,29 @@ export function CategoryPage() {
contact: "", contact: "",
}); });
// همه‌ی تأمین‌کننده‌ها برای انتخاب سریع (task: کومبوکس کش‌شده) // همه‌ی تأمین‌کننده‌ها برای انتخاب سریع
const supplierOptions = useMemo( const supplierOptions = useMemo(
() => suppliers.map((s) => ({ id: s.id, name: s.name })), () => suppliers.map((s) => ({ id: s.id, name: s.name })),
[suppliers], [suppliers],
); );
// محاسبه میانگین زمان تحویل اختصاصی هر تأمین‌کننده بر اساس سفارش‌های تحویل‌شده
const supLeadTimes = useMemo(() => {
const map = new Map<string, number | null>();
suppliers.forEach((s) => {
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
map.set(s.id, avg);
});
return map;
}, [quotes, suppliers]);
if (!cat) return <Navigate to="/suppliers" replace />; if (!cat) return <Navigate to="/suppliers" replace />;
const sups = suppliersOfCategory(cat, cat, suppliers); const sups = suppliersOfCategory(cat, cat, suppliers);
@@ -69,7 +86,6 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید."); toast.error("نام تأمین‌کننده را وارد کنید.");
return; return;
} }
// تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const s: Supplier = { const s: Supplier = {
id: nextId("s", suppliers), id: nextId("s", suppliers),
topic: cat, topic: cat,
@@ -101,7 +117,15 @@ export function CategoryPage() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sups.map((s) => ( {sups.map((s) => (
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} /> <SupplierCard
key={s.id}
s={s}
topic={cat}
deletable
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
/>
))} ))}
</div> </div>
)} )}
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks"; import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory"; import { PurchaseHistory } from "./PurchaseHistory";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده // صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() { export function SupplierDetailPage() {
useStoreData(); useStoreData();
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
const [linked, setLinked] = useState<string[]>([]); const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// محاسبه میانگین زمان تحویل این تأمین‌کننده بر اساس خریدهای تحویل‌شده
const avgLeadTime = useMemo(() => {
if (!s?.id) return null;
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
}, [quotes, s?.id]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
let alive = true; let alive = true;
@@ -48,20 +71,8 @@ export function SupplierDetailPage() {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]); }, [materials]);
// متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها) // دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7)
const purchased = useMemo(() => { const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
const out = new Set<string>();
quotes.forEach((q) => {
if (q.supplier_id !== id) return;
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
if (!m || !m.category) return;
out.add(m.category);
});
});
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}, [quotes, materials, id]);
if (!s) { if (!s) {
return ( return (
@@ -80,7 +91,6 @@ export function SupplierDetailPage() {
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`; const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => { const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return; if (!id || busy) return;
setBusy(true); setBusy(true);
void (async () => { void (async () => {
@@ -128,21 +138,51 @@ export function SupplierDetailPage() {
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" /> <Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout> <PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div> <div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge> <Badge variant="outline">{topicName}</Badge>
</div> </div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]"> {avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<span> <span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"} <MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span> </span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"} {/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
</span> <div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span> <span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"} <User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span> </span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div> </div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Link <Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`} to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
@@ -163,7 +203,7 @@ export function SupplierDetailPage() {
</div> </div>
</div> </div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */} {/* متریال‌های این تأمین‌کننده */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm"> <div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold"> <h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده <Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
@@ -203,7 +243,7 @@ export function SupplierDetailPage() {
)} )}
</div> </div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */} {/* سابقه خرید از این تأمین‌کننده */}
<div className="mt-4 max-w-4xl"> <div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} /> <PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div> </div>
+50 -16
View File
@@ -1,16 +1,16 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react"; import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
import type { Material, Supplier } from "../../lib/types"; import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { import { fmtNum } from "../../lib/formatters";
const out = new Set<string>();
materials // تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category) function cleanPhoneForTel(phone?: string | null): string {
.forEach((m) => out.add(m.category)); if (!phone) return "";
suppliers const enDigits = phone
.filter((s) => topic && normalize(s.topic) === normalize(topic)) .replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c))); .replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return [...out].sort((a, b) => a.localeCompare(b, "fa")); return enDigits.replace(/(?!^\+)[^\d]/g, "");
} }
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
@@ -19,44 +19,77 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
); );
} }
// کارت تأمین‌کننده // کارت تأمین‌کننده همراه با برچسب میانگین زمان تحویل
export function SupplierCard({ export function SupplierCard({
s, s,
topic, topic,
deletable, deletable,
onDelete, onDelete,
materials = [], materials = [],
avgLeadTime,
}: { }: {
s: Supplier; s: Supplier;
topic: string; topic: string;
deletable?: boolean; deletable?: boolean;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
// متریال‌های خریداری‌شده از این تأمین‌کننده (به‌جای دسته‌بندی)
materials?: string[]; materials?: string[];
avgLeadTime?: number | null;
}) { }) {
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link"> {/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
{s.name} {s.name}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0">
{avgLeadTime != null && (
<span
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
title="میانگین زمان تحویل سفارش‌ها"
>
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
<span>{fmtNum(avgLeadTime)} روز</span>
</span>
)}
{deletable && onDelete && ( {deletable && onDelete && (
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}> <button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}>
× ×
</button> </button>
)} )}
</div> </div>
</div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"} <MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span> </span>
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span> <span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"} <Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span> </span>
)}
<span> <span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"} <User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span> </span>
</div> </div>
{materials.length > 0 && ( {materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5"> <div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1"> <div className="mb-1 flex flex-wrap gap-1">
@@ -68,6 +101,7 @@ export function SupplierCard({
</div> </div>
</div> </div>
)} )}
<div className="sc-actions"> <div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline"> <Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />

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