feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence
This commit is contained in:
@@ -135,3 +135,16 @@ class ErpClient:
|
|||||||
return (await self._request(
|
return (await self._request(
|
||||||
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
|
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
|
||||||
)).get("data") or {}
|
)).get("data") or {}
|
||||||
|
|
||||||
|
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
|
||||||
|
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
|
||||||
|
url = f"{self._base}/quotes/{quote_id}/{kind}"
|
||||||
|
files = {"file": (file_name, content, mime_type)}
|
||||||
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to upload quote attachment to ERP: %s", e)
|
||||||
|
return {}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ class Settings(BaseSettings):
|
|||||||
# Frontend URL (for opening Wizard from Bale bot)
|
# Frontend URL (for opening Wizard from Bale bot)
|
||||||
FRONTEND_URL: str = "http://localhost:8081"
|
FRONTEND_URL: str = "http://localhost:8081"
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
|
||||||
|
|
||||||
|
# Persistent storage directory for draft attachments (proformas)
|
||||||
|
ATTACHMENTS_DIR: str = "data/attachments/proformas"
|
||||||
|
|
||||||
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
||||||
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
||||||
AI_API_KEY: str = ""
|
AI_API_KEY: str = ""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app import drafts as draft_store
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.nlu import understand
|
from app.nlu import understand
|
||||||
from app.runtime import BotContext, LinkedUser
|
from app.runtime import BotContext, LinkedUser
|
||||||
|
from app.storage import save_proforma_file
|
||||||
from app.stt import transcribe, transcribe_multi
|
from app.stt import transcribe, transcribe_multi
|
||||||
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
||||||
|
|
||||||
@@ -38,35 +39,15 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||||
text = (message.get("text") or "").strip()
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
source = "text"
|
source = "text"
|
||||||
voice_transcripts: list[dict] | None = None
|
voice_transcripts: list[dict] | None = None
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
|
||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
await commands.handle_command(ctx, chat_id, text)
|
await commands.handle_command(ctx, chat_id, text)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not text and (message.get("voice") or message.get("audio") or message.get("document")):
|
|
||||||
source = "voice"
|
|
||||||
await ctx.bale.send_chat_action(chat_id, "typing")
|
|
||||||
file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"]
|
|
||||||
try:
|
|
||||||
audio = await ctx.bale.download_file(file_id)
|
|
||||||
voice_transcripts = await transcribe_multi(
|
|
||||||
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 voice_transcripts:
|
|
||||||
await ctx.send(chat_id, VOICE_EMPTY)
|
|
||||||
return
|
|
||||||
text = voice_transcripts[0]["text"]
|
|
||||||
|
|
||||||
# Pending multi-step interactions take the message first.
|
# Pending multi-step interactions take the message first.
|
||||||
flow = ctx.pending.get(chat_id)
|
flow = ctx.pending.get(chat_id)
|
||||||
if flow:
|
if flow:
|
||||||
@@ -79,11 +60,43 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
|||||||
if user is None:
|
if user is None:
|
||||||
return # gate either started linking or is mid-linking
|
return # gate either started linking or is mid-linking
|
||||||
|
|
||||||
message_id = message.get("message_id")
|
|
||||||
reply_to = message.get("reply_to_message")
|
reply_to = message.get("reply_to_message")
|
||||||
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
|
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
|
||||||
|
|
||||||
target_draft = None
|
def _is_audio(msg: dict) -> bool:
|
||||||
|
if msg.get("voice") or msg.get("audio"):
|
||||||
|
return True
|
||||||
|
doc = msg.get("document")
|
||||||
|
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _extract_voice() -> list[dict] | None:
|
||||||
|
nonlocal source
|
||||||
|
source = "voice"
|
||||||
|
await ctx.bale.send_chat_action(chat_id, "typing")
|
||||||
|
audio_media = message.get("voice") or message.get("audio") or message.get("document")
|
||||||
|
file_id = audio_media["file_id"]
|
||||||
|
try:
|
||||||
|
audio_bytes = await ctx.bale.download_file(file_id)
|
||||||
|
transcripts = await transcribe_multi(
|
||||||
|
settings,
|
||||||
|
audio_bytes,
|
||||||
|
mime_type=audio_media.get("mime_type"),
|
||||||
|
filename=audio_media.get("file_name"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||||
|
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
|
||||||
|
return None
|
||||||
|
if not transcripts:
|
||||||
|
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
|
||||||
|
return None
|
||||||
|
return transcripts
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||||
|
# =========================================================================
|
||||||
if reply_to_msg_id is not None:
|
if reply_to_msg_id is not None:
|
||||||
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
||||||
if target_draft is None:
|
if target_draft is None:
|
||||||
@@ -94,6 +107,92 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check for proforma attachment (document or photo)
|
||||||
|
proforma_attached = False
|
||||||
|
saved_meta = None
|
||||||
|
doc = message.get("document")
|
||||||
|
photos = message.get("photo")
|
||||||
|
|
||||||
|
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||||
|
doc_file_id = doc["file_id"]
|
||||||
|
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
|
||||||
|
doc_mime = doc.get("mime_type") or "application/pdf"
|
||||||
|
elif photos:
|
||||||
|
largest = photos[-1] if isinstance(photos, list) else photos
|
||||||
|
doc_file_id = largest["file_id"]
|
||||||
|
doc_file_name = f"proforma_{message_id}.jpg"
|
||||||
|
doc_mime = "image/jpeg"
|
||||||
|
else:
|
||||||
|
doc_file_id = None
|
||||||
|
doc_file_name = None
|
||||||
|
doc_mime = None
|
||||||
|
|
||||||
|
if doc_file_id:
|
||||||
|
await ctx.bale.send_chat_action(chat_id, "upload_document")
|
||||||
|
try:
|
||||||
|
content = await ctx.bale.download_file(doc_file_id)
|
||||||
|
saved_meta = await save_proforma_file(
|
||||||
|
target_draft.id,
|
||||||
|
doc_file_name,
|
||||||
|
content,
|
||||||
|
mime_type=doc_mime,
|
||||||
|
storage_dir=settings.ATTACHMENTS_DIR,
|
||||||
|
)
|
||||||
|
payload = dict(target_draft.payload or {})
|
||||||
|
payload["proforma_file"] = saved_meta["file_name"]
|
||||||
|
payload["proforma"] = saved_meta
|
||||||
|
target_draft.payload = payload
|
||||||
|
await draft_store.save_payload(target_draft, mark_bale_synced=True)
|
||||||
|
proforma_attached = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to persist proforma")
|
||||||
|
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیشفاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_audio(message):
|
||||||
|
voice_transcripts = await _extract_voice()
|
||||||
|
if not voice_transcripts:
|
||||||
|
return
|
||||||
|
text = voice_transcripts[0]["text"]
|
||||||
|
|
||||||
|
if proforma_attached and not text and not voice_transcripts:
|
||||||
|
await quote.render(ctx, chat_id, target_draft, fresh=False)
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
f"📎 پیشفاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not proforma_attached and not text and not voice_transcripts:
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
"⚠️ برای ویرایش پیشنویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیشفاکتور را بفرستید.",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BRANCH 2: NEW QUOTE MODE — Messages without reply
|
||||||
|
# =========================================================================
|
||||||
|
else:
|
||||||
|
target_draft = None
|
||||||
|
|
||||||
|
if _is_audio(message):
|
||||||
|
voice_transcripts = await _extract_voice()
|
||||||
|
if not voice_transcripts:
|
||||||
|
return
|
||||||
|
text = voice_transcripts[0]["text"]
|
||||||
|
elif text:
|
||||||
|
source = "text"
|
||||||
|
else:
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
draft_summary = _draft_summary(target_draft) if target_draft else None
|
draft_summary = _draft_summary(target_draft) if target_draft else None
|
||||||
input_payload = voice_transcripts if voice_transcripts else text
|
input_payload = voice_transcripts if voice_transcripts else text
|
||||||
result = await understand(settings, input_payload, draft_summary)
|
result = await understand(settings, input_payload, draft_summary)
|
||||||
|
|||||||
@@ -198,9 +198,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
|||||||
reply_to: int | None = None) -> None:
|
reply_to: int | None = None) -> None:
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
from app.security import create_draft_token
|
||||||
|
|
||||||
payload = draft.payload
|
payload = draft.payload
|
||||||
missing = compute_quote_missing(payload)
|
missing = compute_quote_missing(payload)
|
||||||
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}"
|
token = create_draft_token(
|
||||||
|
draft.id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
secret=settings.DRAFT_TOKEN_SECRET,
|
||||||
|
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
|
||||||
|
)
|
||||||
|
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
|
||||||
text = quote_review_text(payload, missing, status_note=note)
|
text = quote_review_text(payload, missing, status_note=note)
|
||||||
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
|
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
|
||||||
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
|
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
|
||||||
@@ -302,9 +310,29 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
|||||||
ctx.pending.pop(chat_id, None)
|
ctx.pending.pop(chat_id, None)
|
||||||
ctx.resolver.invalidate()
|
ctx.resolver.invalidate()
|
||||||
|
|
||||||
|
quote_id = (created or {}).get("id") or ""
|
||||||
|
# If a proforma file was attached, persist it to the created ERP quote
|
||||||
|
proforma_info = payload.get("proforma")
|
||||||
|
if quote_id and proforma_info and proforma_info.get("saved_path"):
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
p_path = Path(proforma_info["saved_path"])
|
||||||
|
if p_path.exists():
|
||||||
|
file_bytes = p_path.read_bytes()
|
||||||
|
await ctx.api.upload_quote_file(
|
||||||
|
quote_id,
|
||||||
|
"proforma",
|
||||||
|
proforma_info.get("file_name") or "proforma.pdf",
|
||||||
|
file_bytes,
|
||||||
|
mime_type=proforma_info.get("mime_type") or "application/pdf",
|
||||||
|
actor_id=user.actor_id,
|
||||||
|
)
|
||||||
|
logger.info("Successfully uploaded proforma to quote %s", quote_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
|
||||||
|
|
||||||
if draft.review_message_id:
|
if draft.review_message_id:
|
||||||
try:
|
try:
|
||||||
quote_id = (created or {}).get("id") or ""
|
|
||||||
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
|
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
|
||||||
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
|
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||||
await draft_store.set_bale_updated_at(draft.id)
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Security and token generation for standalone draft review."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - (len(s) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_token(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||||
|
expires_in: int = 7200,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an HMAC-SHA256 signed token for secure draft access."""
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"draft_id": str(draft_id),
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
payload_b64 = _b64url_encode(payload_bytes)
|
||||||
|
|
||||||
|
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
sig_b64 = _b64url_encode(sig)
|
||||||
|
|
||||||
|
return f"{payload_b64}.{sig_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft_token(
|
||||||
|
token: str,
|
||||||
|
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_bytes = _b64url_decode(payload_b64)
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Persistent storage for draft attachments (proforma files)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.storage")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
|
||||||
|
raw = (filename or "proforma.pdf").strip()
|
||||||
|
name = Path(raw).name
|
||||||
|
# Remove control characters and path traversal
|
||||||
|
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
|
||||||
|
clean = clean.strip(". ")
|
||||||
|
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
async def save_proforma_file(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
mime_type: str | None = None,
|
||||||
|
storage_dir: str = "data/attachments/proformas",
|
||||||
|
) -> dict:
|
||||||
|
"""Save proforma bytes permanently to disk.
|
||||||
|
|
||||||
|
Returns dict metadata for persisting in draft payload.
|
||||||
|
"""
|
||||||
|
clean_name = sanitize_filename(file_name)
|
||||||
|
target_dir = Path(storage_dir) / str(draft_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
unique_prefix = uuid4().hex[:8]
|
||||||
|
saved_filename = f"{unique_prefix}_{clean_name}"
|
||||||
|
target_path = target_dir / saved_filename
|
||||||
|
|
||||||
|
# Write bytes to disk
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_name": clean_name,
|
||||||
|
"saved_filename": saved_filename,
|
||||||
|
"saved_path": str(target_path.resolve()),
|
||||||
|
"relative_path": str(target_path.as_posix()),
|
||||||
|
"size_bytes": len(content),
|
||||||
|
"mime_type": mime_type or "application/octet-stream",
|
||||||
|
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
|
|||||||
MINIO_BUCKET: str = "procurement-files"
|
MINIO_BUCKET: str = "procurement-files"
|
||||||
MINIO_SECURE: bool = False
|
MINIO_SECURE: bool = False
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL (must match bot)
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
||||||
AUTO_EXPIRE_ENABLED: bool = True
|
AUTO_EXPIRE_ENABLED: bool = True
|
||||||
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.draft import BotDraft
|
from app.models.draft import BotDraft
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.draft_token import verify_draft_token
|
||||||
|
from app.utils.errors import NotFoundError, UnauthorizedError
|
||||||
|
|
||||||
router = APIRouter(prefix="/drafts", tags=["drafts"])
|
router = APIRouter(prefix="/drafts", tags=["drafts"])
|
||||||
|
|
||||||
@@ -38,6 +39,93 @@ def _serialize_draft(draft: BotDraft) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_token_or_raise(token: str) -> dict:
|
||||||
|
token_payload = verify_draft_token(token)
|
||||||
|
if not token_payload or not token_payload.get("draft_id"):
|
||||||
|
raise UnauthorizedError("لینک بازبینی نامعتبر یا منقضی شده است")
|
||||||
|
return token_payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
async def verify_and_get_draft(token: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Public endpoint for token-based draft review without ERP login."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft), "token_payload": token_payload}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/by-token")
|
||||||
|
async def update_draft_by_token(token: str, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Update draft payload using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
if body.payload is not None:
|
||||||
|
merged = dict(draft.payload or {})
|
||||||
|
merged.update(body.payload)
|
||||||
|
draft.payload = merged
|
||||||
|
|
||||||
|
if body.status is not None:
|
||||||
|
draft.status = body.status
|
||||||
|
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/by-token/commit")
|
||||||
|
async def commit_draft_by_token(token: str, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Commit draft using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "committed"
|
||||||
|
payload = dict(draft.payload or {})
|
||||||
|
if body.quote_id:
|
||||||
|
payload["committed_quote_id"] = body.quote_id
|
||||||
|
draft.payload = payload
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/by-token/cancel")
|
||||||
|
async def cancel_draft_by_token(token: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Cancel draft using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "cancelled"
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{draft_id}")
|
@router.get("/{draft_id}")
|
||||||
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""HMAC-SHA256 token verification for standalone draft review."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - (len(s) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(secret: str | None = None) -> str:
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
return settings.DRAFT_TOKEN_SECRET
|
||||||
|
except Exception:
|
||||||
|
return "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_token(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
expires_in: int = 7200,
|
||||||
|
) -> str:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"draft_id": str(draft_id),
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
payload_b64 = _b64url_encode(payload_bytes)
|
||||||
|
|
||||||
|
sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
sig_b64 = _b64url_encode(sig)
|
||||||
|
|
||||||
|
return f"{payload_b64}.{sig_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft_token(
|
||||||
|
token: str,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
expected_sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_bytes = _b64url_decode(payload_b64)
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -83,6 +83,7 @@ export default function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/review" element={<WizardPage isStandalone />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function MobileTabBar() {
|
|||||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pathname === "/wizard") return null;
|
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
||||||
const onOfficePage =
|
const onOfficePage =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
pathname === "/projects" ||
|
pathname === "/projects" ||
|
||||||
|
|||||||
@@ -1053,6 +1053,38 @@ export const api = {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await postData(`/drafts/${id}/cancel`, {});
|
await postData(`/drafts/${id}/cancel`, {});
|
||||||
},
|
},
|
||||||
|
verifyToken: async (
|
||||||
|
token: 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/verify?token=${encodeURIComponent(token)}`);
|
||||||
|
},
|
||||||
|
updateByToken: async (
|
||||||
|
token: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload });
|
||||||
|
},
|
||||||
|
commitByToken: async (
|
||||||
|
token: string,
|
||||||
|
quoteId?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId });
|
||||||
|
},
|
||||||
|
cancelByToken: async (
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
suppliers: {
|
suppliers: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { track } from "../../lib/analytics";
|
import { track } from "../../lib/analytics";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check, ShieldCheck, AlertCircle, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { LineItem, Quote } from "../../lib/types";
|
import type { LineItem, Quote } from "../../lib/types";
|
||||||
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||||
@@ -39,11 +39,111 @@ import {
|
|||||||
|
|
||||||
const TOTAL_STEPS = 5;
|
const TOTAL_STEPS = 5;
|
||||||
|
|
||||||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید
|
// ---------- صفحه خروج امن (Safe Exit Screen) ----------
|
||||||
export function WizardPage() {
|
function SafeExitScreen({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 p-8 text-center shadow-xl">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-emerald-100 dark:bg-emerald-950/60 text-emerald-600 dark:text-emerald-400">
|
||||||
|
<Check className="w-8 h-8 stroke-[2.5]" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||||||
|
{title || "پیشنویس استعلام ذخیره گردید"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||||||
|
{subtitle ||
|
||||||
|
"اطلاعات با موفقیت ذخیره شد. اکنون میتوانید این برگه را ببندید و برای ادامه کار به ربات بله بازگردید."}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 rounded-xl transition shadow-xs text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
window.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بستن این برگه
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
(در صورت مسدود بودن بستن خودکار توسط مرورگر، لطفاً تب مرورگر را به صورت دستی ببندید)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- صفحه خطای توکن نامعتبر ----------
|
||||||
|
function TokenInvalidScreen({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-slate-900 rounded-2xl border border-rose-200 dark:border-rose-900/50 p-8 text-center shadow-xl">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-5 bg-rose-100 dark:bg-rose-950/60 text-rose-600 dark:text-rose-400">
|
||||||
|
<AlertCircle className="w-8 h-8 stroke-[2.5]" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-3">
|
||||||
|
لینک نامعتبر یا منقضی شده
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
|
||||||
|
{message ||
|
||||||
|
"اعتبار این لینک بازبینی به پایان رسیده است. لطفاً از طریق ربات بله، مجدداً دکمه بازبینی را دریافت فرمایید."}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="line"
|
||||||
|
className="w-full rounded-xl py-2 text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
window.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بستن برگه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید و بازبینی پیشنویس
|
||||||
|
export function WizardPage({ isStandalone = false }: { isStandalone?: boolean }) {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
const token = params.get("token");
|
||||||
|
const isStandaloneMode = isStandalone || Boolean(token);
|
||||||
|
|
||||||
|
const [exitInfo, setExitInfo] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}>({ show: false });
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Setup History Trap when in standalone mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStandaloneMode) return;
|
||||||
|
try {
|
||||||
|
window.history.replaceState({ page: "review_root" }, "", window.location.href);
|
||||||
|
window.history.pushState({ page: "review_active" }, "", window.location.href);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const onPop = () => {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "بازبینی به پایان رسید",
|
||||||
|
subtitle: "پیشنویس ذخیره گردید. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
window.history.pushState(null, "", window.location.href);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, [isStandaloneMode]);
|
||||||
|
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -214,16 +314,25 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
}, [params, suppliers, topics]);
|
}, [params, suppliers, topics]);
|
||||||
|
|
||||||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?draft=&draft_id=) ----------
|
// ---------- بارگذاری اولیه پیشنویس ربات بله (?token= یا ?draft=) ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
if (!draftId || draftFetchedRef.current) return;
|
if ((!token && !draftId) || draftFetchedRef.current) return;
|
||||||
|
|
||||||
draftFetchedRef.current = true;
|
draftFetchedRef.current = true;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const draft = await api.drafts.get(draftId);
|
let draft: any = null;
|
||||||
if (!draft || !draft.payload) return;
|
if (token) {
|
||||||
|
const res = await api.drafts.verifyToken(token);
|
||||||
|
draft = (res as any)?.data || res;
|
||||||
|
} else if (draftId) {
|
||||||
|
draft = await api.drafts.get(draftId);
|
||||||
|
}
|
||||||
|
if (!draft || !draft.payload) {
|
||||||
|
if (token) setTokenError("پیشنویس معتبری یافت نشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadedDraftId(draft.id);
|
setLoadedDraftId(draft.id);
|
||||||
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
||||||
|
|
||||||
@@ -234,31 +343,57 @@ export function WizardPage() {
|
|||||||
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
if (token) {
|
||||||
|
setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است.");
|
||||||
|
} else {
|
||||||
|
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [params, applyDraftPayloadToForm]);
|
}, [params, token, applyDraftPayloadToForm]);
|
||||||
|
|
||||||
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
if (!draftId) return;
|
if (!token && !draftId) return;
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||||
try {
|
try {
|
||||||
const draft = await api.drafts.get(draftId);
|
let draft: any = null;
|
||||||
|
if (token) {
|
||||||
|
const res = await api.drafts.verifyToken(token);
|
||||||
|
draft = (res as any)?.data || res;
|
||||||
|
} else if (draftId) {
|
||||||
|
draft = await api.drafts.get(draftId);
|
||||||
|
}
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
if (draft.status === "committed") {
|
if (draft.status === "committed") {
|
||||||
isCommittedOrCancelledRef.current = true;
|
isCommittedOrCancelledRef.current = true;
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "این پیشنهاد ثبت نهایی شد ✅",
|
||||||
|
subtitle: "استعلام با موفقیت در سیستم ثبت گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
||||||
navigate("/");
|
navigate("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.status === "cancelled") {
|
if (draft.status === "cancelled") {
|
||||||
isCommittedOrCancelledRef.current = true;
|
isCommittedOrCancelledRef.current = true;
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "پیشنویس لغو شد",
|
||||||
|
subtitle: "این پیشنویس از طریق ربات بله لغو گردید. میتوانید این برگه را ببندید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
||||||
navigate("/");
|
navigate("/");
|
||||||
return;
|
return;
|
||||||
@@ -279,12 +414,12 @@ export function WizardPage() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [params, navigate, applyDraftPayloadToForm]);
|
}, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]);
|
||||||
|
|
||||||
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
if ((!token && !draftId) || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
isSelfUpdatingRef.current = true;
|
isSelfUpdatingRef.current = true;
|
||||||
@@ -312,10 +447,19 @@ export function WizardPage() {
|
|||||||
expiry_date: expiryDate || null,
|
expiry_date: expiryDate || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.drafts.update(draftId, payloadToSave);
|
if (token) {
|
||||||
const updated = await api.drafts.get(draftId);
|
await api.drafts.updateByToken(token, payloadToSave);
|
||||||
if (updated?.updated_at) {
|
const res = await api.drafts.verifyToken(token);
|
||||||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
const updated = (res as any)?.data || res;
|
||||||
|
if (updated?.updated_at) {
|
||||||
|
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||||||
|
}
|
||||||
|
} else if (draftId) {
|
||||||
|
await api.drafts.update(draftId, payloadToSave);
|
||||||
|
const updated = await api.drafts.get(draftId);
|
||||||
|
if (updated?.updated_at) {
|
||||||
|
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Auto-save to draft failed:", err);
|
console.warn("Auto-save to draft failed:", err);
|
||||||
@@ -327,7 +471,7 @@ export function WizardPage() {
|
|||||||
}, 800);
|
}, 800);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]);
|
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]);
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
const completedSteps = useMemo(() => {
|
const completedSteps = useMemo(() => {
|
||||||
@@ -607,8 +751,16 @@ export function WizardPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await createQuote.mutateAsync(quote);
|
const created = await createQuote.mutateAsync(quote);
|
||||||
|
const token = params.get("token");
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
if (draftId) {
|
if (token) {
|
||||||
|
isCommittedOrCancelledRef.current = true;
|
||||||
|
try {
|
||||||
|
await api.drafts.commitByToken(token, created.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not commit draft via token:", err);
|
||||||
|
}
|
||||||
|
} else if (draftId) {
|
||||||
isCommittedOrCancelledRef.current = true;
|
isCommittedOrCancelledRef.current = true;
|
||||||
try {
|
try {
|
||||||
await api.drafts.commit(draftId, created.id);
|
await api.drafts.commit(draftId, created.id);
|
||||||
@@ -627,6 +779,15 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
|
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
|
||||||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
||||||
|
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "پیشنهاد با موفقیت در سیستم ثبت گردید ✅",
|
||||||
|
subtitle: `شناسه استعلام: #${created.id.slice(0, 8)} — تغییرات شما با موفقیت اعمال شد. اکنون میتوانید این برگه را ببندید و به ربات بله بازگردید.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const target = resolveErrorTarget(e);
|
const target = resolveErrorTarget(e);
|
||||||
@@ -635,15 +796,30 @@ export function WizardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
|
const token = params.get("token");
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
if (draftId) {
|
isCommittedOrCancelledRef.current = true;
|
||||||
isCommittedOrCancelledRef.current = true;
|
if (token) {
|
||||||
|
try {
|
||||||
|
await api.drafts.cancelByToken(token);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not cancel draft via token:", err);
|
||||||
|
}
|
||||||
|
} else if (draftId) {
|
||||||
try {
|
try {
|
||||||
await api.drafts.cancel(draftId);
|
await api.drafts.cancel(draftId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Could not cancel draft:", err);
|
console.warn("Could not cancel draft:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "پیشنویس لغو شد",
|
||||||
|
subtitle: "این پیشنویس استعلام لغو گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate("/");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -661,14 +837,55 @@ export function WizardPage() {
|
|||||||
void doSave();
|
void doSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (exitInfo.show) {
|
||||||
|
return <SafeExitScreen title={exitInfo.title} subtitle={exitInfo.subtitle} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenError) {
|
||||||
|
return <TokenInvalidScreen message={tokenError} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-10">
|
<div className="min-h-screen pb-10">
|
||||||
<Header
|
{isStandaloneMode ? (
|
||||||
title="افزودن پیشنهاد جدید"
|
<header className="sticky top-0 z-40 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md border-b border-line px-4 py-3.5 shadow-xs">
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||||
backTo="/"
|
<div className="flex items-center gap-3">
|
||||||
backLabel="بازگشت به دفتر"
|
<div className="h-9 w-9 rounded-xl bg-blue-600 text-white flex items-center justify-center shadow-xs">
|
||||||
/>
|
<ShieldCheck className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-base font-bold text-ink">
|
||||||
|
بازبینی و تکمیل پیشنویس استعلام
|
||||||
|
</h1>
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300">
|
||||||
|
ربات بله
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
محیط امن بازبینی — اطلاعات با ربات بله همگامسازی میشود
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted hover:text-rose-600 rounded-xl hover:bg-panel transition-colors"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
<span>انصراف و خروج</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<Header
|
||||||
|
title="افزودن پیشنهاد جدید"
|
||||||
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
|
backTo="/"
|
||||||
|
backLabel="بازگشت به دفتر"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{loadedDraftId && (
|
{loadedDraftId && (
|
||||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">
|
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">
|
||||||
|
|||||||
Reference in New Issue
Block a user