Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693e7511c6 | ||
|
|
19c3055008 | ||
|
|
77c0cacbb6 | ||
|
|
638076dbaa | ||
|
|
07643dbdf5 | ||
|
|
0a7db5ebe9 | ||
|
|
da68fc683f | ||
|
|
17458d8697 | ||
|
|
1b5fc589fd | ||
|
|
a2e7c27e85 | ||
|
|
09245cdc2c | ||
|
|
dc31f837fd | ||
|
|
57e0e51c16 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
9e19be8354 | ||
|
|
4cfb9ec6f6 | ||
|
|
6ee0822a96 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f | ||
|
|
d69ec7f7a9 | ||
|
|
34290b9371 | ||
|
|
cff35e3660 | ||
|
|
b469238153 | ||
|
|
2fd72fecf7 |
@@ -26,8 +26,11 @@ frontend/.env
|
|||||||
|
|
||||||
# ---- Local session / backups ----
|
# ---- Local session / backups ----
|
||||||
.zcode/
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
|
scratch/
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
inv dash/
|
inv dash/
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+50
-19
@@ -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)
|
||||||
""",
|
""",
|
||||||
@@ -413,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 = """
|
||||||
@@ -435,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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -231,12 +235,15 @@ from app.routers.auth import router as _auth_router # noqa: E402
|
|||||||
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
# 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.
|
# 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.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:
|
elif router is _files_router:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX)
|
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)])
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""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.draft_token import verify_draft_token
|
||||||
|
from app.utils.errors import NotFoundError, UnauthorizedError
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}")
|
||||||
|
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)}
|
||||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import exists, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user, require_role
|
from app.auth import UserContext, get_optional_user, require_role
|
||||||
@@ -27,13 +27,12 @@ from app.models.inventory import (
|
|||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
)
|
)
|
||||||
from app.models.procurement import Project, Supplier, SupplierMaterial
|
from app.models.procurement import Project
|
||||||
from app.schemas.inventory import (
|
from app.schemas.inventory import (
|
||||||
InventoryTransactionCreate,
|
InventoryTransactionCreate,
|
||||||
WarehouseCreate,
|
WarehouseCreate,
|
||||||
WarehouseUpdate,
|
WarehouseUpdate,
|
||||||
)
|
)
|
||||||
from app.services.expand_service import category_by_material
|
|
||||||
from app.services.inventory_service import (
|
from app.services.inventory_service import (
|
||||||
apply_transaction,
|
apply_transaction,
|
||||||
get_warehouse_or_404,
|
get_warehouse_or_404,
|
||||||
@@ -315,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,
|
||||||
@@ -333,20 +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:
|
|
||||||
# v2.7: materials carry no category — match through their suppliers
|
|
||||||
# (any link, mirroring GET /materials?category_id=…).
|
|
||||||
stmt = stmt.where(
|
|
||||||
exists(
|
|
||||||
select(1)
|
|
||||||
.select_from(SupplierMaterial)
|
|
||||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
||||||
.where(
|
|
||||||
SupplierMaterial.material_id == InventoryItem.material_id,
|
|
||||||
Supplier.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:
|
||||||
@@ -368,8 +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()}
|
||||||
# v2.7: the category shown for a material comes from its first linked supplier.
|
|
||||||
material_category = await category_by_material(db, mat_ids)
|
|
||||||
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()}
|
||||||
@@ -379,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],
|
||||||
material_category.get(i.material_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
|
||||||
]
|
]
|
||||||
@@ -541,71 +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")
|
|
||||||
|
|
||||||
# v2.7: a material has no category column — it belongs to a category when a
|
|
||||||
# linked supplier does (same rule as GET /materials?category_id=…).
|
|
||||||
stmt = select(Material).where(
|
|
||||||
Material.status == "active",
|
|
||||||
exists(
|
|
||||||
select(1)
|
|
||||||
.select_from(SupplierMaterial)
|
|
||||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
||||||
.where(
|
|
||||||
SupplierMaterial.material_id == Material.id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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
|
|
||||||
]}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
|||||||
MaterialUnit,
|
MaterialUnit,
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
from app.models.procurement import LineItem
|
||||||
from app.schemas.catalog import (
|
from app.schemas.catalog import (
|
||||||
MaterialAttributeValuesPut,
|
MaterialAttributeValuesPut,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
@@ -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,17 +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:
|
|
||||||
sub = (
|
|
||||||
select(1)
|
|
||||||
.select_from(SupplierMaterial)
|
|
||||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
||||||
.where(
|
|
||||||
SupplierMaterial.material_id == Material.id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stmt = stmt.where(exists(sub))
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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,12 +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()}
|
||||||
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
|
||||||
cats = {}
|
|
||||||
if sup_cat_ids:
|
|
||||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
|
||||||
cats = {c.id: c for 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)
|
||||||
@@ -219,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(sup.category_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),
|
||||||
|
|||||||
@@ -45,14 +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(
|
|
||||||
Supplier.id == Quote.supplier_id,
|
|
||||||
Supplier.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_search(q: str) -> exists:
|
def _quote_search(q: str) -> exists:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
@@ -80,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 {
|
||||||
@@ -90,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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,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,
|
||||||
@@ -137,8 +126,6 @@ async def list_quotes(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(_quote_category_exists(category_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:
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from app.models.procurement import (
|
|||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
QuoteProjects,
|
QuoteProjects,
|
||||||
Supplier,
|
Supplier,
|
||||||
SupplierMaterial,
|
|
||||||
Termsheet,
|
Termsheet,
|
||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
@@ -203,47 +202,9 @@ def quote_flat(q: Quote) -> dict:
|
|||||||
# Materials
|
# Materials
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def category_by_material(db: AsyncSession, material_ids) -> dict:
|
|
||||||
"""Resolve each material's display category through its FIRST linked supplier.
|
|
||||||
|
|
||||||
v2.7 made materials category-free: ``materials.category_id`` no longer
|
|
||||||
exists and the category lives on the supplier (``suppliers.category_id``).
|
|
||||||
A material linked to several suppliers therefore has no single category of
|
|
||||||
its own — we keep the v2.5 first-link-wins rule (oldest supplier_materials
|
|
||||||
row) so the display value stays stable and matches the old derived column.
|
|
||||||
Materials with no link, or whose first supplier has no category, resolve to
|
|
||||||
nothing (the serializers emit null).
|
|
||||||
"""
|
|
||||||
ids = {mid for mid in material_ids if mid is not None}
|
|
||||||
if not ids:
|
|
||||||
return {}
|
|
||||||
links = (
|
|
||||||
await db.execute(
|
|
||||||
select(SupplierMaterial.material_id, Supplier.category_id)
|
|
||||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
||||||
.where(SupplierMaterial.material_id.in_(ids))
|
|
||||||
.order_by(SupplierMaterial.created_at)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
first_cat: dict = {}
|
|
||||||
for material_id, category_id in links:
|
|
||||||
if category_id is not None and material_id not in first_cat:
|
|
||||||
first_cat[material_id] = category_id
|
|
||||||
if not first_cat:
|
|
||||||
return {}
|
|
||||||
cats = (
|
|
||||||
await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(set(first_cat.values()))))
|
|
||||||
).scalars().all()
|
|
||||||
by_id = {c.id: c for c in cats}
|
|
||||||
return {mid: by_id[cid] for mid, cid in first_cat.items() if cid in by_id}
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
|
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
|
||||||
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": {}}
|
||||||
# Loaded regardless of expand: the non-expanded branch still returns
|
|
||||||
# category_id, and a silent null there is the bug this replaced.
|
|
||||||
maps["material_categories"] = await category_by_material(db, material_ids)
|
|
||||||
|
|
||||||
unit_ids: set[int] = set()
|
unit_ids: set[int] = set()
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
@@ -291,13 +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),
|
||||||
}
|
}
|
||||||
# v2.7: never the material's own column (it no longer has one) — the
|
# Category is a supplier attribute now, not a material one — the key stays
|
||||||
# category comes from the first linked supplier via _category_by_material.
|
# in the payload (null) so the contract shape is unchanged.
|
||||||
material_category = maps["material_categories"].get(m.id)
|
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = category_brief(material_category)
|
d["category"] = None
|
||||||
else:
|
else:
|
||||||
d["category_id"] = str(material_category.id) if material_category else None
|
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:
|
||||||
@@ -340,8 +300,6 @@ class _LineItemData:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.materials: dict[uuid.UUID, Material] = {}
|
self.materials: dict[uuid.UUID, Material] = {}
|
||||||
self.categories: dict[uuid.UUID, MaterialCategory] = {}
|
self.categories: dict[uuid.UUID, MaterialCategory] = {}
|
||||||
# material id -> its first linked supplier's category (v2.7)
|
|
||||||
self.material_category: dict[uuid.UUID, MaterialCategory] = {}
|
|
||||||
self.units: dict[int, Unit] = {}
|
self.units: dict[int, Unit] = {}
|
||||||
self.brands: dict[uuid.UUID, Brand] = {}
|
self.brands: dict[uuid.UUID, Brand] = {}
|
||||||
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
||||||
@@ -373,7 +331,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
||||||
data.brands = {b.id: b for b in brands}
|
data.brands = {b.id: b for b in brands}
|
||||||
data.material_category = await category_by_material(db, material_ids)
|
|
||||||
|
|
||||||
# Pricing always needs item_termsheets in expanded mode.
|
# Pricing always needs item_termsheets in expanded mode.
|
||||||
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
@@ -487,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.material_category.get(m.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, [])
|
||||||
|
|||||||
@@ -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,18 +914,6 @@ async def quote_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
|
||||||
"""EXISTS: quote's supplier belongs to category_id."""
|
|
||||||
sub = (
|
|
||||||
select(1)
|
|
||||||
.where(
|
|
||||||
Supplier.id == Quote.supplier_id,
|
|
||||||
Supplier.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, isolated per project.
|
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# پلیبوک اجرایی: افزودن موتور STT بله به خط لوله چندمدلهی بات تدارکات
|
||||||
|
|
||||||
|
> **نسخه:** ۱.۰
|
||||||
|
> **مخاطب:** ایجنت هوش مصنوعی مجری + تیم فنی + مدیر محصول
|
||||||
|
> **ماهیت سند:** سند تصمیممحور — هر تست به یک «دروازهی تصمیم (Gate)» میرسد و هر دروازه، جدول «وضعیت مشاهدهشده ← اقدام» دارد. ایجنت باید فقط بر اساس وضعیتهای واقعیِ مشاهدهشده در همان لحظه، شاخهی درست را انتخاب کند.
|
||||||
|
> **هدف نهایی:** افزودن ترنسکریپشن موتور بله (STT سمت سرور بله) بهعنوان یکی از کاندیداهای خط لولهی Multi-Provider STT فعلیِ بات، بدون هیچ تغییر ساختاری در بات.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۰. قواعد الزامآور اجرا (برای ایجنت — قبل از هر اقدام بخوانید)
|
||||||
|
|
||||||
|
1. **مبنای گیت فقط `production` است.** سایر برنچها (از جمله `prod-v7` که کد بات روی آن است) متعلق به جریان کاری خودشاناند و **نباید** توسط ایجنت دستکاری، مرج یا پوش شوند. تنها مهندس ارشد شرکت مرج میکند. **هیچ پوشی بدون تأیید صریح مالک انجام نشود.**
|
||||||
|
2. **درخواستهای شبکه به بله باید کمحجم و انساننما باشند.** هشدار جدی: سازندگان کتابخانههای مهندسیمعکوس بله تأکید کردهاند که «استفادهی زیاد از POST gRPCِ غیر-احراز (پیام/مدیا/حضور) میتواند فلگ، rate-limit یا بن موقت ایجاد کند». پس: ترجیح کانال WebSocket، ترتیبی نه موازی، تأخیر انسانی، سقف روزانه.
|
||||||
|
3. **حسابها:** فقط شمارهی اختصاصی شرکت (Bale ئرنر) — هرگز شمارهی شخصی. حساب فقط برای این کار باشد.
|
||||||
|
4. **حریم خصوصی:** صدای کاربران تدارکات روی سرورهای بله پردازش میشود (همین حالا هم ویس از بله میآید)؛ اما اگر ایجنت صدا را مجدداً به یک چت آپلود میکند (مسیر R2/R3)، باید بلافاصله پس از ترنسکرایب پیام را حذف کند و آدرسی از صدا نگه ندارد.
|
||||||
|
5. **هیچ سکرتی (توکن بات، توکن کاربر، session_file، کلید LLM) نباید در گیت کامیت شود.** سکرتهای موجود در `docker-compose.yml` (توکن بات و کلید DeepSeek بهعنوان پیشفرض) باید در فرصت مناسب rotate و به `${VAR:?…}` تبدیل شوند.
|
||||||
|
6. **معیار موفقیت، سرعت نیست — دقت است.** بدون عبور از دروازهی دقت (G5) هیچ ساخت/استقراری مجاز نیست.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. واقعیتهای تأییدشده (پایهی همهی تصمیمها)
|
||||||
|
|
||||||
|
### ۱.۱ — سیستم فعلی: بات و خط لولهی STT
|
||||||
|
|
||||||
|
| مؤلفه | مسیر | توضیح |
|
||||||
|
|---|---|---|
|
||||||
|
| خط لوله STT | `bot/app/stt.py` | `transcribe_multi()` صدای MP3 (خروجی ffmpeg: mono 16kHz) را **به موازات** به همهی پرووایدرها میفرستد؛ هر پرووایدر یک `POST {base_url}/audio/transcriptions` (multipart، فیلد `file`، بههمراه `model` و `language=fa`) است و خروجی `{"text": "..."}` انتظار دارد. **fail-soft:** خطای هر پرووایدر فقط آن را حذف میکند (`return None`)؛ بقیه ادامه میدهند. |
|
||||||
|
| پیکربندی پرووایدرها | `bot/app/config.py` | `STT_PROVIDER_1..3_NAME/BASE_URL/API_KEY/MODEL/LANGUAGE` + `STT_PROVIDERS_JSON` + fallback قدیمی `STT_*`. نکته: یک پرووایدر فقط وقتی فعال میشود که **هم `BASE_URL` و هم `API_KEY`** پر باشند. |
|
||||||
|
| مصرف خروجیها | `bot/app/nlu.py` | همهی ترنسکریپتهای کاندید به DeepSeek میروند و خروجی نهایی `consensus_text` + `intent` + `patch` (اقلام استعلام) است. **یعنی افزودن بله = یک کاندید بیشتر در ورودی consensus.** |
|
||||||
|
| مسیر ویس در بات | `bot/app/handlers/__init__.py` | ویس → دانلود (`getFile`) → `transcribe_multi` → متن بهعنوان ورودی NLU. |
|
||||||
|
|
||||||
|
**نتیجهی معماری (کلیدی):** افزودن هر مسیر STT = ساخت **یک سرویس adapter با قرارداد OpenAI-compatible** + پر کردن `STT_PROVIDER_3_*`. **صفر تغییر در کد بات.** (این نقطهی اتصال رسمی و پایدار است.)
|
||||||
|
|
||||||
|
### ۱.۲ — شواهد تجربی Bot API (تستشده — تکرارپذیر)
|
||||||
|
|
||||||
|
روی توکن بات فعلی (`procurement_assistant_bot`)، متدهای مشکوک STT تست شدند:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://tapi.bale.ai/bot<TOKEN>/transcribeVoice → {"ok":false,"error_code":404,"description":"Bad Request: handler not found"}
|
||||||
|
POST .../getTranscription → (همان 404)
|
||||||
|
POST .../transcribe → (همان 404)
|
||||||
|
POST .../speechToText → (همان 404)
|
||||||
|
POST .../voiceToText → (همان 404)
|
||||||
|
POST .../getMe (کنترل) → {"ok":true,"result":{"username":"procurement_assistant_bot",...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
مستندات رسمی (`docs.bale.ai`) هم هیچ متد یا فیلد ترنسکریپشن برای آبجکت Voice/Audio ندارند.
|
||||||
|
**نتیجه: Bot API بله قطعاً STT ندارد. مسیر «فیلد مخفی» فقط با تست T1 (بخش ۳) قابل رد شدن نهایی است.**
|
||||||
|
|
||||||
|
### ۱.۳ — یافتههای گزارشهای ایجنت شرکت (خلاصهی راستیآزماییشده)
|
||||||
|
|
||||||
|
| یافته | وضعیت راستیآزمایی |
|
||||||
|
|---|---|
|
||||||
|
| کلاینت وب بله روی **gRPC-Web + Protocol Buffers** (`@bufbuild/protobuf`) است | **منطقی و همراستا:** کتابخانههای مستقل مهندسیمعکوس بله هم همین را تأیید میکنند؛ باید در T2 مجدداً مشاهده شود. |
|
||||||
|
| گیتویها: `https://maviz-ws.bale.ai` و `wss://maviz-ws.bale.ai/ws/?uid={user_id}` (+ بتا/نکست) | در وب ایندکس نشده (طبیعی) — **باید در T2 با چشم مشاهده شود.** |
|
||||||
|
| ماژولهای `SpeechTranscript` و `AIChat` در باندل | سرنخ معتبر — **باید در T2/T3 تأیید و دیکود شود.** |
|
||||||
|
| «روش دوم غیرعملی است چون باید کل .protoها بازسازی شوند» | **نادرست/بیشبرآورد:** با دیکود جنریک Protobuf و استخراج توصیفگر از خود باندل (T3) یا با استفاده از aiobale-py (T4)، این کار در حد «افزودن یک متد» کوچک میشود. |
|
||||||
|
| ماتریس تأخیر («روش دوم زیر ۱ ثانیه») | **گمراهکننده:** گلوگاه، استنتاج ASR روی سرور بله است؛ عدد واقعی فقط از T2/T5 میآید. |
|
||||||
|
|
||||||
|
### ۱.۴ — اکوسیستم مهندسی معکوس موجود (مهم — دارایی راهبردی)
|
||||||
|
|
||||||
|
- **`aiobale-py`** (v0.3.9، فعال): فریمورک async برای بله روی **WebSocket + Protobuf**، الهامگرفته از aiogram؛ ~۸۰ متد RPC (پیام، فایل، گروه، مخاطبین…)، ورود تلفنی با OTP، `session_file`، توکن JWT، rate-limiter داخلی، docs و مخزن عمومی:
|
||||||
|
- PyPI: `pip install aiobale-py` (ایمپورت: `aiobale`)
|
||||||
|
- GitHub: `github.com/aminmadaniofficial/aiobale` — مستندات: `aminmadaniofficial.github.io/aiobale/`
|
||||||
|
- **متد ترنسکرایب ندارد** → باید همان RPC کشفشده در T2 به آن افزوده شود (زیرساخت لاگین/WS/کدک آماده است).
|
||||||
|
- نسخهی اولیهی `Enalite/aiobale` (GitHub الان 404؛ نام PyPI توسط یک پکیج خالی اشغال شده) → **فقط به فورک فعال `aiobale-py` اتکا کنید.**
|
||||||
|
- پروژهی جانبی مرتبط: `bale-mcp-server` (MCP برای بله با aiobale-py) — برای الگوگیری نحوهی استفاده.
|
||||||
|
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir) (`/ai/stt?url=...&token=...`) — مسیر جایگزین R7 (بخش ۵).
|
||||||
|
|
||||||
|
### ۱.۵ — وضعیت برنچها (قواعد همکاری)
|
||||||
|
|
||||||
|
| برنچ | وضعیت | مجاز برای ایجنت؟ |
|
||||||
|
|---|---|---|
|
||||||
|
| `production` (`253c922`) | مبنای رسمی کار | ✅ فقط خواندن/مبنا |
|
||||||
|
| `prod-v7` (کد بات/ایجنت، `638076d`) | کار مالک این سند؛ منتظر تست نهایی و مرج توسط مهندس ارشد | ⛔ دستکاری/پوش ممنوع |
|
||||||
|
| `prod-v8/9/10`، `pm`، `pm_2` و بقیه | برنچهای سایر اعضا؛ در حال تست | ⛔ هیچ دخالتی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. نقشهی وضعیتها (State Machine) — نمای کلی مسیر
|
||||||
|
|
||||||
|
```
|
||||||
|
S0 (شروع)
|
||||||
|
│
|
||||||
|
├─T1─> G1 ──(فیلد ترنسکریپشن پیدا شد)───────────────► R1: پایان! (بدون کرالر) ✅
|
||||||
|
│ └─(پیدا نشد)──► T2
|
||||||
|
│
|
||||||
|
├─T2─> G2 ──(RPC ساده/دیکودپذیر)──► T3+T4 ─> G3 ──(موفق)──► T5+ G4 ─► T7 ─> G5
|
||||||
|
│ ├─(فقط روی WS)──────────► همان T3+T4 (aiobale مسیر WS است) ▲
|
||||||
|
│ ├─(رمزنگاری/امضای خاص)──► R4 (Playwright) یا R3 ─► T5/T7
|
||||||
|
│ └─(نیاز به اثر انگشت مرورگر)─► R4 ─► T5/T7
|
||||||
|
│
|
||||||
|
├─(T5/T7)─> G5 ──(بهبود دقت کافی)──► G6 (ToS) ──(سبز)──► BUILD (R2/R3/R4) ─► G7 (سواک) ─► استقرار کامل
|
||||||
|
│ └─(بهبود ناکافی)──► STOP: همان پرووایدرهای فعلی ⛔
|
||||||
|
│ └─(قرمز)──► STOP یا فقط R4 در حد محدود ⛔
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. فاز A — تستهای کشف (T1…T9)
|
||||||
|
|
||||||
|
> برای هر تست: هدف، پیشنیاز، مراحل، خروجی، معیار قبولی، و حالتهای ممکن. ایجنت بعد از هر تست **حتماً** به جدول دروازهی مربوطه (بخش ۴) مراجعه کند.
|
||||||
|
|
||||||
|
### T1 — تست payload خام ویس (۳۰ دقیقه)
|
||||||
|
|
||||||
|
**هدف:** رد یا تأیید نهایی وجود فیلد ترنسکریپشن مخفی در پیامهای Bot API.
|
||||||
|
**پیشنیاز:** یک ربات تستی **مستقل** (از BotFather بله بسازید؛ از ربات فعلی استفاده نکنید چون در حال long-poll است و مصرفکنندهی دوم تعارض ایجاد میکند). یک حساب بله برای فرستادن ویس.
|
||||||
|
**مراحل:**
|
||||||
|
1. اسکریپت مینیمال پایتون: `getUpdates` بدون offset + چاپ JSON کامل هر آپدیت در فایل.
|
||||||
|
2. یک ویس فارسی واقعی (۵–۱۰ ثانیه) به ربات تستی بفرستید.
|
||||||
|
3. JSON کامل ویس را ذخیره کنید؛ کلیدهای آبجکت `voice`/`audio`/`message` را لیست کنید.
|
||||||
|
4. `getFile` را هم صدا بزنید و فیلدهای پاسخ را ضبط کنید.
|
||||||
|
**معیار قبولی/حالتها:**
|
||||||
|
- **حالت A:** هر فیلدی مثل `transcription`/`transcript`/`speech`/`text` روی ویس وجود دارد → **پایان پروژهی کرالر (R1)**؛ فقط به بات وصل کنید.
|
||||||
|
- **حالت B:** خبری نیست (قویترین احتمال طبق مستندات) → برو به T2.
|
||||||
|
|
||||||
|
### T2 — کپچر ترافیک کلاینت وب (نیمروز) — قاطعترین تست
|
||||||
|
|
||||||
|
**هدف:** کشف «دقیق» مکانیزم ترنسکرایب: نام RPC، ترابری (unary/WS)، قالب فریم، هدر احراز، و **زمان واقعی ASR**.
|
||||||
|
**پیشنیاز:** حساب سوختهی اختصاصی (سیمکارت شرکت)، مرورگر Chromium، ابزار دیکود Protobuf: `protoscope` یا `blackboxprotobuf` (نصب با pip/go)، و اختیاری mitmproxy.
|
||||||
|
**مراحل:**
|
||||||
|
1. ورود به `web.bale.ai`؛ DevTools → Network با گزینهی Preserve log؛ فیلتر `WS` و `Fetch/XHR` را فعال نگه دارید.
|
||||||
|
2. یک ویس در یک چت باز کنید و دکمهی «تبدیل به متن / متن پیاده» را بزنید.
|
||||||
|
3. **همهی درخواستهای همان لحظه** را ضبط کنید:
|
||||||
|
- اگر `POST` روی هاستی مثل `maviz-ws.bale.ai` با `content-type: application/grpc-web+proto` (یا `grpc-web-text`) ظاهر شد → همین RPC است.
|
||||||
|
- اگر قاب باینری روی WebSocket `wss://…/ws/?uid=…` رفت → RPC روی WS است.
|
||||||
|
4. برای هر نمونه ذخیره کنید: `req.bin`، `resp.bin`، هدرهای مهم (نام هدر توکن: `authorization`/`x-auth`/…)، URL کامل، و **زمان پاسخ از تب Timing** (همین = تأخیر واقعی ASR بله).
|
||||||
|
5. ۵ تا ۱۰ ویس متنوع (کوتاه/بلند/با لهجه) تکرار کنید تا پایداری اسکیما معلوم شود؛ در `samples/` ذخیره و در `findings.md` خلاصه کنید.
|
||||||
|
6. دیکود آفلاین: به یاد داشته باشید پیامهای gRPC-Web یک **پیشوند ۵ بایتی** دارند (۱ بایت فلگ + ۴ بایت طول) و سپس Protobuf میآید → ابتدا پیشوند را بردارید، سپس:
|
||||||
|
`protoc --decode_raw < resp_payload.bin` یا `protoscope resp_payload.bin`
|
||||||
|
7. اگر آرایهی WS دیده شد: فریمها را از DevTools کپی و با protoscope دیکود کنید؛ به دنبال پیام معادل `SpeechTranscript` بگردید.
|
||||||
|
**خروجی:** `capture/findings.md` شامل: نام دقیق RPC، ترابری، framing، هدر احراز، تأخیر واقعی، و نقشهی مقدماتی فیلدها (کدام فیلد = message_id؟ file_id؟ متن؟).
|
||||||
|
|
||||||
|
### T3 — استخراج اسکیما (۱–۲ روز)
|
||||||
|
|
||||||
|
**هدف:** تبدیل کپچرها به یک اسکیمای قابل استفاده (و ترجیحاً `.proto`).
|
||||||
|
**روشها (به ترتیب):**
|
||||||
|
1. **از خود باندل:** باندل وب بله (`/static/js/index.*.js` و در صورت وجود `.js.map`) را از قبل دارید؛ پروژههای ساختهشده با `@bufbuild/protobuf` معمولاً **`FileDescriptorProto` باینری را داخل JS جاسازی میکنند**. روش عملی: جستوجوی بایتهای توصیفگر، استخراج و سپس:
|
||||||
|
`protoc --decode=google.protobuf.FileDescriptorProto google/protobuf/descriptor.proto < desc.bin`
|
||||||
|
(یا دیکود همان بایتها با پایتون + `grpcio-tools`). اگر sourcemap موجود بود، مستقیمتر است.
|
||||||
|
2. **از نمونهها:** با چند نمونهی req/resp دیکودشده با `protoc --decode_raw`، همبستگی فیلدها را استخراج کنید (فیلد شمارهی متن جواب، message_id درخواست و…).
|
||||||
|
3. **مسیر موازی (اپ اندروید):** APK رسمی بله (از سایت رسمی) + `jadx`؛ جستوجوی `"SpeechTranscript"`؛ در اندروید معمولاً شمارهی فیلدها واضحترند.
|
||||||
|
**معیار قبولی:** توان ساخت درخواست معتبر و خواندن پاسخ — حتی بهصورت «فیلد ۱ = message_id، فیلد ۲ = متن». داشتن `.proto` کامل لازم نیست.
|
||||||
|
|
||||||
|
### T4 — اسپایک aiobale-py (۱ روز)
|
||||||
|
|
||||||
|
**هدف:** اثبات امکان اجرای ترنسکرایب با یک اکانت کاربری، بدون مرورگر.
|
||||||
|
**پیشنیاز:** venv ایزوله (**نه** محیط بات)، حساب سوخته، اسکیمای T3.
|
||||||
|
**مراحل:**
|
||||||
|
1. `pip install aiobale-py` → ورود: `Client(session_file="bale.session", phone_number="…")` و عبور از OTP (دروازهی انسانی).
|
||||||
|
2. کاوش لایهی کدک: در `site-packages/aiobale/…` ببینید متدها چطور تعریف میشوند (احتمالاً مپینگ متد→پیام Protobuf). **یک متد سفارشی برای RPC ترنسکریپت کشفشده اضافه کنید.**
|
||||||
|
3. **کشف مکانیزم دقیق:** آیا RPC ترنسکرایب `message_id` میخواهد (یعنی صدا باید از قبل «پیامی در بله» باشد) یا `file_id`/بایت خام قبول میکند؟ هر سه فرض را تست کنید:
|
||||||
|
- (الف) آپلود ویس به یک **چت اسکرچ** (چت با خود یا گروه خصوصی اختصاصی) → گرفتن `message_id` → ترنسکرایب → **حذف پیام**.
|
||||||
|
- (ب) `upload_file` (در aiobale موجود است) → ترنسکرایب با فایل.
|
||||||
|
- (ج) اگر RPC ورودی بایت میگیرد → سادهترین (بدون پیام واسط).
|
||||||
|
4. تست صحت: متن برگشتی را با متنی که وب بله برای **همان ویس** نشان میدهد مقایسه کنید (باید یکسان/نزدیک باشد).
|
||||||
|
5. تست پایداری نشست: ریاستارت پروسه و استفادهی مجدد از session file؛ ثبت زمان انقضای توکن.
|
||||||
|
**حالتها:**
|
||||||
|
- **موفق کامل** → مسیر R2 برای ساخت adapter انتخاب میشود.
|
||||||
|
- **نیمهموفق** (login/WS میشود ولی RPC دیکود نمیشود) → T3 را عمیقتر + کپچر بیشتر؛ در غیر این صورت R3/R4.
|
||||||
|
- **ناموفق** (چالش احراز، بن، نبود قابلیت افزودن متد) → R3 و R4.
|
||||||
|
|
||||||
|
### T-extra — تست «AI Chat بهعنوان اوراکل» (نیمروز، موازی با T2/T4)
|
||||||
|
|
||||||
|
**هدف:** اگر چت هوش مصنوعی بله ویس را با متن پیادهشده پاسخ میدهد، **حتی نیازی به RPC جدید نیست**: با متدهای پیامرسانیِ *موجودِ* aiobale، ویس را به چت AI بفرستید و متن را از پاسخ استخراج کنید.
|
||||||
|
**مراحل:** با اکانت سوخته در وب بله، یک ویس به چت AI بفرستید؛ دقیقاً ببینید آیا متن پیادهشده در پاسخ میآید و با چه قالبی. سپس همان را با aiobale بازسازی کنید.
|
||||||
|
**معیار:** اگر متن پیاده با قالب قابلاعتماد (ثابت) برگردد → مسیر R3 فعال میشود (ارزانتر از R2).
|
||||||
|
|
||||||
|
### T5 — اندازهگیری تأخیر/پایداری واقعی (۱–۲ روز)
|
||||||
|
|
||||||
|
**هدف:** جایگزینی اعداد ساختگی گزارش با اعداد واقعی.
|
||||||
|
**مراحل:** ۵۰–۱۰۰ ویس واقعی از مسیر انتخابی (ترتیبی، با فاصلهی انسانی ۱–۳ ثانیه)؛ ثبت p50/p95، نرخ خطا، انواع خطا، و هر نشانهی rate-limit.
|
||||||
|
**معیار پذیرش پیشنهادی:** نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه (برای UX بات). در غیر این صورت G4 قرمز میشود.
|
||||||
|
|
||||||
|
### T7 — بنچمارک دقت (۱ روز) — **دروازهی کسبوکاری، قبل از هر ساخت**
|
||||||
|
|
||||||
|
**هدف:** آیا افزودن بله واقعاً خروجی نهایی را بهتر میکند؟
|
||||||
|
**مراحل:**
|
||||||
|
1. ۲۰–۳۰ ویس **واقعی تدارکاتی** جمع کنید (با اطلاع فرستندگان؛ صداها را بلندمدت نگه ندارید).
|
||||||
|
2. برای هر ویس سه خروجی بگیرید: (الف) وضعیت فعلی (whisper/سایر + consensus فعلی)، (ب) فقط متن بله، (ج) consensus با اضافهشدن کاندید بله.
|
||||||
|
3. سه معیار بسنجید: WER متن، دقت استخراج موجودیتها (نام کالا/برند/تعداد/واحد در JSON نهایی)، و تعداد «موارد سخت» که با بله درست شدهاند.
|
||||||
|
**معیار پیشنهادی:** اگر کاهش خطای استخراج ≥ ~۱۵٪ نسبی **یا** حل ≥ ۲ مورد سختِ لهجهای → پروژه توجیه دارد؛ در غیر اینصورت **STOP** (همان خط لولهی فعلی کافی است).
|
||||||
|
|
||||||
|
### T6 + T9 — سواک بن و ماندگاری نشست (۱–۲ هفته، پسیو)
|
||||||
|
|
||||||
|
استفادهی کمحجم روزانه (مثلاً ≤ ۱۰۰ ترنسکرایب در روز) از IP شرکت؛ ثبت: تعداد ورود مجدد لازم، چالشهای امنیتی، هشدارها، عمر واقعی توکن. خروجی: سقف عملیاتی + runbook ورود مجدد.
|
||||||
|
|
||||||
|
### T8 — بازبینی حقوقی/ToS (موازی — **دروازهی انسانی**)
|
||||||
|
|
||||||
|
ایجنت فقط شواهد را جمع میکند؛ تصمیم Go/No-Go توسط مدیریت مستند میشود. معیار: سند تصمیم مکتوب.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. دروازههای تصمیم (جدولهای وضعیت ← اقدام)
|
||||||
|
|
||||||
|
### G1 (بعد از T1)
|
||||||
|
| وضعیت مشاهدهشده | اقدام |
|
||||||
|
|---|---|
|
||||||
|
| فیلد ترنسکریپشن روی ویس هست | **پایان:** R1 — مستقیم به بات وصل کن (بدون هیچ کرالری) |
|
||||||
|
| نیست | برو T2 |
|
||||||
|
|
||||||
|
### G2 (بعد از T2) — مهمترین دروازهی فنی
|
||||||
|
| وضعیت مشاهدهشده | معنا | اقدام |
|
||||||
|
|---|---|---|
|
||||||
|
| RPC از نوع **unary POST gRPC-Web** با پروتوباف دیکودپذیر | بهترین حالت | برو T3+T4 → R2 |
|
||||||
|
| RPC **فقط روی WebSocket** | حالت مطلوب دوم (aiobale دقیقاً WS است) | برو T3+T4 → R2 (از کانال WS؛ **POST gRPC زیاد نزن**) |
|
||||||
|
| درخواست **رمزنگاری/امضای اختصاصی** دارد (فراتر از پروتوباف) | هزینهی نگهداری بالا | R4 (Playwright) یا R3 بهعنوان مسیر اصلی؛ R2 فقط اگر … شکست |
|
||||||
|
| ترنسکرایب نیازمند **اثر انگشت/رفتار مرورگر** است | R2 غیرممکن | R4 |
|
||||||
|
| ترنسکرایب **message_id روی پیامی موجود** میخواهد | نیازمند «آپلود به چت اسکرچ» | R2 با فلوی «آپلود→ترنسکرایب→حذف» (بخش ۵.۲) |
|
||||||
|
|
||||||
|
### G3 (بعد از T3/T4)
|
||||||
|
| وضعیت | اقدام |
|
||||||
|
|---|---|
|
||||||
|
| ساخت درخواست + خواندن پاسخ ممکن شد | برو T5 → T7 |
|
||||||
|
| فقط یکی از دو جهت ممکن است | کپچر بیشتری بگیر (T2)؛ اگر نشد → R3/R4 |
|
||||||
|
| شکست کامل | R3 (اگر T-extra موفق بود) وگرنه R4؛ اگر هر دو هم نشد → STOP |
|
||||||
|
|
||||||
|
### G4 (بعد از T5)
|
||||||
|
| وضعیت | اقدام |
|
||||||
|
|---|---|
|
||||||
|
| نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸s | ادامه |
|
||||||
|
| rate-limit مشاهده شد | کاهش نرخ + backoff نمایی + سقف روزانه؛ مجدداً بسنج |
|
||||||
|
| بن/چالش امنیتی | توقف فوری؛ رفتن به R4 با اکانت جدید، یا STOP |
|
||||||
|
|
||||||
|
### G5 (بعد از T7) — دروازهی کسبوکار
|
||||||
|
| وضعیت | اقدام |
|
||||||
|
|---|---|
|
||||||
|
| بهبود دقت مطابق آستانه | ادامه به G6 |
|
||||||
|
| بهبود ناکافی | **STOP** — همان پرووایدرها؛ یافتهها را مستند کن |
|
||||||
|
|
||||||
|
### G6 (ToS) / G7 (سواک)
|
||||||
|
| وضعیت | اقدام |
|
||||||
|
|---|---|
|
||||||
|
| ToS سبز + سواک سالم | استقرار کامل (بخش ۶) |
|
||||||
|
| ToS قرمز | STOP یا فقط R4 با حجم محدود (طبق تصمیم مدیریت) |
|
||||||
|
| سواک ناسالم | rollback به پرووایدرهای قبلی (حذف envها) و بازگشت به G4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. فاز B — پیادهسازی (فقط پس از عبور از دروازهها)
|
||||||
|
|
||||||
|
### ۵.۱ — قرارداد مشترک adapter (برای همهی مسیرها یکسان)
|
||||||
|
|
||||||
|
سرویس `bale-stt` (کانتینر داخلی، **بدون پورت عمومی**):
|
||||||
|
- `POST /v1/audio/transcriptions` — multipart، فیلد `file` (فایل MP3)، `model`، `language=fa` → پاسخ `{"text": "..."}` (دقیقاً قرارداد OpenAI که بات انتظار دارد).
|
||||||
|
- هدر `Authorization: Bearer <INTERNAL_KEY>` را بپذیرد (فقط کلید داخلی، نه توکن بله).
|
||||||
|
- `GET /healthz` برای سلامت.
|
||||||
|
- موتور داخلی قابلسوییچ با env: `BALE_STT_ENGINE=grpc|aichat|playwright` → همهی مسیرها همزیستی دارند و تعویض موتور بدون تغییر بات.
|
||||||
|
|
||||||
|
### ۵.۲ — R2: مهندسی معکوس (مسیر پیشنهادی اصلی)
|
||||||
|
|
||||||
|
معماری: `bot → adapter → aiobale-py (اکانت سوخته) → WS/gRPC بله → متن`
|
||||||
|
نکات پیادهسازی:
|
||||||
|
- اکانت: `session_file` روی یک **داکر ولوم**؛ هرگز در گیت. `BALE_PHONE` و سکرتها در env.
|
||||||
|
- اگر RPC به message_id نیاز دارد: فلوی «آپلود ویس به چت اسکرچ → ترنسکرایب → **حذف پیام**» — با `send_voice` و `delete_message` موجود در aiobale.
|
||||||
|
- ترتیبی اجرا کنید (قفل asyncio)، فاصلهی ۱–۳ ثانیه، سقف روزانه قابلتنظیم (`BALE_STT_DAILY_CAP`).
|
||||||
|
- مدیریت انقضای توکن: تشخیص خطای احراز → علامتگذاری «نیازمند OTP» + هشدار به ادمین (دروازهی انسانی).
|
||||||
|
- fail-soft مضاعف: adapter در هر خطا `{"text": ""}` (یا 5xx کنترلشده) برگرداند تا بات فقط این کاندید را حذف کند.
|
||||||
|
|
||||||
|
### ۵.۳ — R3: اوراکل AI Chat
|
||||||
|
همان adapter، اما داخلش: ارسال ویس به چت AI (با aiobale) → استخراج متن از قالب پاسخ → بازگرداندن. سبکترین پیادهسازی؛ وابسته به پایداری قالب پاسخ.
|
||||||
|
|
||||||
|
### ۵.۴ — R4: Playwright (فالبک)
|
||||||
|
کانتینر جدا با Chromium headless + پروفایل پایدار (`userDataDir` روی ولوم)، یک worker تکنمونه با صف؛ مراحل: ورود یکباره (OTP انسانی) → برای هر درخواست: آپلود ویس به یک چت اسکرچ → کلیک «تبدیل به متن» → انتظار روی سلکتور متن → استخراج → حذف پیام. RAM/زمان بیشتر؛ مقاومتر به تغییر پروتکل.
|
||||||
|
|
||||||
|
### ۵.۵ — ادغام با بات (بدون تغییر کد)
|
||||||
|
در `docker-compose.yml` (نسخهی تیم/سرور، **نه** برنچ دیگران) سرویس `bale-stt` اضافه و در سرویس `bot` این envها ست شود:
|
||||||
|
```
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
|
||||||
|
```
|
||||||
|
(یادآوری از کد: پرووایدر فقط با پر بودن همزمان BASE_URL و API_KEY فعال میشود.)
|
||||||
|
|
||||||
|
### ۵.۶ — سکرتها و امنیت
|
||||||
|
| سکرت | محل | نکته |
|
||||||
|
|---|---|---|
|
||||||
|
| توکن بات بله | env سرور | **موجود در compose کامیتشده → rotate** |
|
||||||
|
| کلید DeepSeek | env سرور | **همینطور rotate** |
|
||||||
|
| `session_file` اکانت سوخته | داکر ولوم | هرگز گیت؛ مالک مشخص |
|
||||||
|
| `INTERNAL_KEY` adapter | env | فقط بین bot و adapter |
|
||||||
|
| `DRAFT_TOKEN_SECRET` بات | env | پیشفرض فعلی در کد ضعیف است → ست شود |
|
||||||
|
|
||||||
|
### ۵.۷ — مقیاس
|
||||||
|
فاز اول: تکنمونه، ترتیبی، سقف روزانه. توسعه فقط بعد از G7 و فقط با شواهد پایداری.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. پذیرش، استقرار، پایش، بازگشت
|
||||||
|
|
||||||
|
**تستهای پذیرش (اجباری):**
|
||||||
|
1. `curl -F "file=@voice.mp3" -F model=bale-stt http://localhost:8080/v1/audio/transcriptions` → `{"text": "<متن فارسی درست>"}`.
|
||||||
|
2. بات: ارسال ویس واقعی → پاسخ استخراجشدهی صحیح.
|
||||||
|
3. **fail-soft:** توقف کانتینر `bale-stt` → بات همچنان ویس را (با پرووایدرهای دیگر) پردازش کند ✓.
|
||||||
|
4. حریم خصوصی: بررسی کنید هیچ فایل صوتی روی adapter باقی نمیماند و پیامهای اسکرچ حذف شدهاند.
|
||||||
|
|
||||||
|
**استقرار فازبندیشده:** dev → سرور با حجم کم (۱ هفته) → کامل.
|
||||||
|
**پایش:** شمارش درخواست، نرخ موفقیت، هیستوگرام تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه > ۲۰٪.
|
||||||
|
**بازگشت (Rollback):** حذف `STT_PROVIDER_3_*` از env بات + توقف کانتینر adapter — بات فوراً به وضعیت قبل برمیگردد (بدون deploy مجدد).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. رجیستر ریسک
|
||||||
|
|
||||||
|
| ریسک | احتمال | اثر | مقابله |
|
||||||
|
|---|---|---|---|
|
||||||
|
| بن/rate-limit اکانت | متوسط | توقف مسیر | حجم کم، WS-first، backoff، اکانت اختصاصی قابلتعویض، G4 |
|
||||||
|
| تغییر پروتکل بله | متوسط | قطع سرویس | pایش نسخهی باندل (hash)، آلرت خطا، فالبک R4، سوییچ موتور با env |
|
||||||
|
| انقضای نشست/نیاز OTP | بالا (دورهای) | توقف موقت | ولوم session + آلرت + runbook ورود (انسانی) |
|
||||||
|
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینهی بیفایده | **G5 قبل از ساخت** |
|
||||||
|
| ریسک حقوقی/ToS | نامعلوم | توقف اجباری | **T8/G6 — تصمیم مدیریت** |
|
||||||
|
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیامها/فایلها؛ عدم نگهداری |
|
||||||
|
| وابستگی به فرد نگهدارنده | متوسط | رکود | مستندسازی در همین پلیبوک + مالک رسمی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. دروازههای انسانی (فقط انسان میتواند)
|
||||||
|
|
||||||
|
1. **OTP ورود اکانت** (بار اول و هر بار انقضا).
|
||||||
|
2. **تصمیم ToS/حقوقی (G6).**
|
||||||
|
3. **جمعآوری نمونههای واقعی ویس برای T7** (با رضایت).
|
||||||
|
4. **تأیید نهایی استقرار** و تأیید مرج برنچ توسط مهندس ارشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ضمیمه A — دستورات probe (تکرارپذیر)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="<BOT_TOKEN>"
|
||||||
|
for M in transcribeVoice getTranscription transcribe speechToText voiceToText; do
|
||||||
|
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/$M" -H "Content-Type: application/json" -d '{}'; echo
|
||||||
|
done
|
||||||
|
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/getMe" -H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
خروجی مورد انتظار: پنج 404 «handler not found» و یک getMe سالم. (همین تست را قبل از هر تصمیم مجدداً اجرا کنید — شاید بله متد اضافه کرده باشد.)
|
||||||
|
|
||||||
|
## ضمیمه B — اسکلت adapter (قابل استفاده برای هر سه موتور)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bale_stt/adapter.py — قرارداد OpenAI-compatible برای بات
|
||||||
|
import os
|
||||||
|
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
|
||||||
|
ENGINE = os.environ.get("BALE_STT_ENGINE", "grpc") # grpc | aichat | playwright
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {"status": "ok", "engine": ENGINE}
|
||||||
|
|
||||||
|
@app.post("/v1/audio/transcriptions")
|
||||||
|
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
|
||||||
|
language: str = Form("fa"), authorization: str = Header("")):
|
||||||
|
if authorization != f"Bearer {INTERNAL_KEY}":
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
audio = await file.read()
|
||||||
|
if ENGINE == "grpc":
|
||||||
|
text = await grpc_transcribe(audio) # ← aiobale-py + RPC کشفشده
|
||||||
|
elif ENGINE == "aichat":
|
||||||
|
text = await aichat_transcribe(audio) # ← ارسال به چت AI و استخراج متن
|
||||||
|
else:
|
||||||
|
text = await playwright_transcribe(audio)
|
||||||
|
return {"text": text or ""}
|
||||||
|
# نکته: هرگز استثنا را بیرون نده؛ در خطا {"text": ""} برگردان تا بات fail-soft بماند.
|
||||||
|
```
|
||||||
|
|
||||||
|
## ضمیمه C — منابع
|
||||||
|
|
||||||
|
- aiobale-py: [PyPI](https://pypi.org/project/aiobale-py/) · [GitHub](https://github.com/aminmadaniofficial/aiobale) · [مستندات](https://aminmadaniofficial.github.io/aiobale/)
|
||||||
|
- مستندات رسمی بات بله: [docs.bale.ai](https://docs.bale.ai)
|
||||||
|
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir)
|
||||||
|
- گزارشهای ایجنت شرکت: `bale_stt_integration_report.md` و `bale_stt_integration_report_2.md` (کنار همین فایل)
|
||||||
|
|
||||||
|
## ضمیمه D — چکلیست سریع ایجنت (TL;DR)
|
||||||
|
|
||||||
|
1. [ ] قواعد بخش ۰ را خواندم.
|
||||||
|
2. [ ] T1 (payload ویس) → G1.
|
||||||
|
3. [ ] T2 (کپچر) → G2 → مسیر (R2 / R3 / R4) انتخاب شد.
|
||||||
|
4. [ ] T3+T4 (اسکیما + اسپایک) → G3.
|
||||||
|
5. [ ] T-extra (اوراکل AI Chat) اگر مسیر R3 مدنظر است.
|
||||||
|
6. [ ] T5 (تأخیر واقعی) → G4.
|
||||||
|
7. [ ] T7 (دقت) → G5 — **بدون این، ساخت ممنوع.**
|
||||||
|
8. [ ] T8 (ToS — انسانی) → G6.
|
||||||
|
9. [ ] ساخت adapter (۵.۱) + ادغام env (۵.۵) + تستهای پذیرش (بخش ۶).
|
||||||
|
10. [ ] سواک (T6/T9) → G7 → استقرار کامل. هر شکست ⇒ جدول همان دروازه ⇒ اقدام جایگزین یا STOP.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# گزارش جامع: اعتبارسنجی گزارشها و راهکار کرال موتور STT بله
|
||||||
|
|
||||||
|
> **تاریخ:** ۱۵ مهر ۱۴۰۵
|
||||||
|
> **موضوع:** افزودن متن تولیدشده توسط قابلیت «تبدیل ویس به متن» پیامرسان بله بهعنوان یک کاندید در خطلوله چندمدله STT بات تدارکات
|
||||||
|
> **ورودیها:** سه سند ارائهشده (تحلیل جامع، پلیبوک اجرایی، تحلیل مقایسهای) + راستیآزمایی مستقل وب
|
||||||
|
> **محدودیت:** `integration_report` شماره ۱ و ۲ در دسترس من نبود و فقط از روی نقلقولهای همین اسناد قضاوت شدهاند. دستورهای کشف (بخش ۴) در محیط من اجرا نشدهاند، چون شبکه sandbox بسته بود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. خلاصه مدیریتی
|
||||||
|
|
||||||
|
1. **Bot API رسمی بله STT ندارد.** همه اسناد در این مورد درست میگویند.
|
||||||
|
2. **تناقض اصلی** (مهندسی معکوس «غیرعملی است» در برابر «ساده است») هیچکدام کاملاً درست نیست. مسیر شدنی است، ولی «افزودن یک متد» نیست و نیاز به یک فاز کشف دارد.
|
||||||
|
3. **دو فرض اثباتنشده** کل مسیر را تحت تأثیر قرار میدهد:
|
||||||
|
- دکمه «تبدیل به متن» روی **وب** بله وجود دارد (هر سه سند فرض کردهاند، هیچ منبعی تأیید نکرده).
|
||||||
|
- پکیج `aiobale-py` نسخه 0.3.9 فعال است (پیدا نشد؛ به بخش ۲ مراجعه کنید).
|
||||||
|
4. **کشف جدید:** پکیج `bale-sdk` با ۶۰۷ RPC تایپشده و پروتوهای بازیابیشده. احتمال وجود RPC ترنسکریپشن در آن بالاست. ولی مجوز آن اختصاصی است.
|
||||||
|
5. **بزرگترین صرفهجویی:** آزمون دقت را **قبل از ساخت** و بهصورت **دستی** روی ۳۰ ویس واقعی انجام دهید. اگر بله از خطلوله فعلی بهتر نبود، پروژه لازم نیست.
|
||||||
|
6. **ریسک حقوقی/ToS** (استفاده خودکار از حساب کاربری) تصمیم مدیریتی است و باید قبل از استقرار روشن شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. اعتبارسنجی گزارشها
|
||||||
|
|
||||||
|
### ۲.۱ جدول ادعاها
|
||||||
|
|
||||||
|
| ادعا | نتیجه | توضیح |
|
||||||
|
|---|---|---|
|
||||||
|
| Bot API رسمی STT ندارد | ✅ تأیید | پروبها همگی ۴۰۴ دادهاند و تایپهای SDKها برای Voice فقط شناسه فایل و مدتزمان دارند. **تذکر:** ۴۰۴ برای نامهای حدسی چیزی درباره فیلد مخفی در payload نمیگوید؛ تست T1 (ویس به یک بات تستی و خواندن JSON کامل) هنوز لازم است (۳۰ دقیقه). |
|
||||||
|
| بله قابلیت تبدیل ویس به متن دارد | ✅ با شرط | نسخه جدید بله این قابلیت را برای اندروید و iOS منتشر کرده. وجود دکمه در **وب** تأییدنشده است و ریسک اصلی مسیر Playwright همین است. |
|
||||||
|
| مهندسی معکوس «غیرعملی» است (گزارش ۲) | ⚠️ بدبینانه | کلاینتهای متنباز مهندسیمعکوسشده وجود دارند. اما استدلال «Protobuf خودتوصیف است» نادرست است: wire format بدون `.proto` فقط تا حدی (شماره فیلد و نوع خام، بدون نام و معنا) دیکود میشود. |
|
||||||
|
| مهندسی معکوس «در حد افزودن یک متد» است (پلیبوک) | ⚠️ خوشبینانه | کشف RPC، نگاشت فیلدها و پایداری نشست کار واقعی است. تخمین «نیمروز» برای T2 ریسکی است. |
|
||||||
|
| `aiobale-py` v0.3.9 فعال است و نام `aiobale` را پکیج خالی گرفته | ❌ تأییدنشده / احتمالاً غلط | `aiobale-py` را پیدا نکردم. `aiobale` یک کتابخانه واقعی است (MIT، مستندات `docs.aiobale.ir`، نسخههای 0.1.x). فورکهایی مثل Autobale میگویند نسخه اصلی نگهداری نمیشود و باگ دارد. قبل از وابستگی به هر نام، `pip index versions` را اجرا کنید. |
|
||||||
|
| `bale-sdk` (کشف جدید) | 🔎 مهم | از کلاینت وب بله مهندسی معکوس شده؛ ۶۰۷ RPC در ۵۲ سرویس، پروتوهای بازیابیشده، احراز با JWT یا OTP؛ نسخه 0.1.1 (ژوئن ۲۰۲۶). **مجوز اختصاصی و «همه حقوق محفوظ»**. |
|
||||||
|
| تأخیر «زیر ۱ ثانیه» | ❌ رد | همه موافقند. اعداد ۱–۴ ثانیه (R2) و ۴–۸ ثانیه (Playwright) هم **اندازهگیری نشدهاند**؛ فقط T5 عدد واقعی میدهد. |
|
||||||
|
| AI Chat بله ویس را «پیاده» میکند (R3) | ⚠️ تأییدنشده و نامطلوب | حتی اگر درست باشد، خروجی «متن + تفسیر مدل» است و برای consensus مناسب نیست. با کنار گذاشتن موافقم. |
|
||||||
|
| RAM ۳۵۰–۵۰۰ مگابایت برای Playwright | ✅ معقول | برای بات تککاربره قابلتحمل است. |
|
||||||
|
| Playwright در ۱–۲ روز | ⚠️ خوشبینانه | وابسته به وجود دکمه وب، چالشهای ورود و پایداری سلکتورها. |
|
||||||
|
| ساختار Gate / State-machine پلیبوک | ✅ قوی | بهترین بخش اسناد. ایراد: دروازه دقت (T7) بعد از ساخت آمده، در حالی که دستی و قبل از ساخت شدنی است. |
|
||||||
|
| ریسک بن، ToS و حریم خصوصی | ✅ درست و حیاتی | فقط پلیبوک به آن پرداخته است. |
|
||||||
|
| ترجیح WebSocket به POST gRPC (پلیبوک) | ✅ منطقی | کلاینت رسمی بله POST gRPC را عمدتاً برای چند متد احراز هویت استفاده میکند؛ استفاده زیاد از آن غیرعادی به نظر میرسد. |
|
||||||
|
| «Playwright اول» (سند مقایسهای) در برابر «R2 اول» (پلیبوک) | ⚠️ هر دو مشروط | بدون نتیجه کشف روز اول قابل تصمیم نیست. |
|
||||||
|
|
||||||
|
### ۲.۲ ایرادهای کد adapter در سند اول
|
||||||
|
|
||||||
|
- کلید پیشفرض ضعیف (`bale_secure_token`) و مقایسه کلید با `!=` بهجای مقایسه ثابتزمان.
|
||||||
|
- بلعیدن همه استثناها بدون لاگ.
|
||||||
|
- بازگرداندن ۲۰۰ با متن خالی: ممکن است بات آن را یک کاندید معتبر (خالی) در consensus بشمارد. باید در `bot/app/stt.py` بررسی شود که پاسخ ۵xx و متن خالی چطور مدیریت میشوند. توصیه: در خطا ۵۰۳ برگردد.
|
||||||
|
- نبود قفل ترتیبی، سقف روزانه و تأخیر تصادفی در خود adapter.
|
||||||
|
|
||||||
|
### ۲.۳ نکات فنی اصلاحشده
|
||||||
|
|
||||||
|
- فریم gRPC-Web پیشوند ۵ بایتی دارد (۱ بایت فلگ + ۴ بایت طول). این برای **POST gRPC-Web** درست است؛ اینکه فریمهای WebSocket بله هم همان قالب را دارند فرض اثباتنشدهای است و باید در T2 دیده شود.
|
||||||
|
- هاستها و نام ماژولهایی مثل `maviz-ws.bale.ai` و `SpeechTranscript` از تحلیل باندل آمدهاند و در وب ایندکس نشدهاند. باید با چشم در کپچر تأیید شوند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. راهکارها
|
||||||
|
|
||||||
|
| راهکار | توضیح | رم | خلوص متن | ریسک | حکم |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **A. مسیر رسمی** | درخواست API یا همکاری از بله | — | — | — | هزینه صفر؛ قبل از هر کار غیررسمی یک ایمیل بزنید |
|
||||||
|
| **B. RPC مستقیم** | کلاینت کاربری روی WebSocket با `bale-sdk` یا `aiobale` + RPC ترنسکریپت | ≈ ۲۰ MB | خالص | متوسط (تغییر پروتکل، بن) | **بهترین نتیجه فنی**، مشروط به کشف RPC |
|
||||||
|
| **C. Playwright** | Chromium headless روی `web.bale.ai` با پروفایل پایدار | ۳۵۰–۵۰۰ MB | خالص | کم–متوسط | مقاوم، **فقط اگر دکمه وب موجود باشد** |
|
||||||
|
| **D. AI Chat** | ویس به چت هوش مصنوعی بله و استخراج از پاسخ | ≈ ۲۰ MB | ❌ متن + تفسیر | بالا | کنار گذاشته شود |
|
||||||
|
| **E. سرویس ثالث** | پروکسیهای STT با ادعای بله | — | نامشخص | حریم خصوصی/پایداری | کنار گذاشته شود |
|
||||||
|
| **F. اندروید امولاتور** | اپ بله + uiautomator | زیاد | خالص | بالا (شکننده) | آخرین چاره اگر B و C ممکن نشد |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. مسیر دقیق اجرا
|
||||||
|
|
||||||
|
### گام ۰: آزمون دقت دستی (نیمروز، بدون کد)
|
||||||
|
|
||||||
|
۳۰ ویس واقعی تدارکاتی (با رضایت فرستندگان؛ صداها را بلندمدت نگه ندارید) را در اپ بله دستی به متن تبدیل کنید و سه حالت را مقایسه کنید:
|
||||||
|
|
||||||
|
1. خروجی فعلی بات (whisper/سایرین + consensus)
|
||||||
|
2. فقط متن بله
|
||||||
|
3. consensus با اضافهشدن بله
|
||||||
|
|
||||||
|
سه معیار: WER متن، دقت استخراج اقلام (نام کالا، برند، تعداد، واحد) و تعداد موارد سخت لهجهای که با بله درست شدهاند.
|
||||||
|
|
||||||
|
**معیار پیشنهادی:** کاهش نسبی خطای استخراج حدود ۱۵٪ یا بیشتر، یا حل حداقل ۲ مورد سخت. در غیر این صورت **STOP**.
|
||||||
|
|
||||||
|
### گام ۱: کشف سریع (حدود ۳۰ دقیقه)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .v && . .v/bin/activate
|
||||||
|
pip index versions aiobale-py # وجود یا عدم وجود را بررسی کنید
|
||||||
|
pip download bale-sdk --no-deps --no-binary :all: && tar xzf bale_sdk-*.tar.gz
|
||||||
|
grep -rniE "transcri|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
همچنین در پایتون `from bale import ALL_RPCS` را با همان کلیدواژهها فیلتر کنید.
|
||||||
|
|
||||||
|
همزمان **T1**: یک بات تستی مستقل بسازید، یک ویس بفرستید و JSON کامل آپدیت را بخوانید. اگر فیلد ترنسکریپشن وجود داشت، پروژه کرالر لازم نیست.
|
||||||
|
|
||||||
|
### گام ۲: وب و کپچر (نیمروز تا یک روز)
|
||||||
|
|
||||||
|
1. با **یک حساب اختصاصی شرکت** روی `web.bale.ai` وارد شوید و بررسی کنید دکمه «تبدیل به متن» روی ویس هست یا نه (روی ویس دریافتی و ارسالی).
|
||||||
|
2. DevTools تب Network را با Preserve log و فیلتر WS و Fetch/XHR ضبط کنید و هنگام کلیک روی دکمه ذخیره کنید:
|
||||||
|
- URL کامل و ترابری (unary یا WS)
|
||||||
|
- هدر احراز
|
||||||
|
- `req.bin` و `resp.bin`
|
||||||
|
- زمان پاسخ (همان تأخیر واقعی ASR)
|
||||||
|
3. ۵ تا ۱۰ ویس متنوع (کوتاه، بلند، لهجهدار) را تکرار کنید و در `findings.md` خلاصه کنید.
|
||||||
|
4. دیکود آفلاین: پیشوند ۵ بایتی را بردارید، سپس `protoc --decode_raw` یا `protoscope`.
|
||||||
|
|
||||||
|
### گام ۳: انتخاب مسیر
|
||||||
|
|
||||||
|
| وضعیت | مسیر |
|
||||||
|
|---|---|
|
||||||
|
| RPC در `bale-sdk` یا کپچر پیدا شد | **B** |
|
||||||
|
| RPC پیدا نشد ولی دکمه وب هست | **C** |
|
||||||
|
| هیچکدام | **F** یا توقف |
|
||||||
|
| ترنسکرایب نیاز به امضا یا اثر انگشت مرورگر دارد | **C** |
|
||||||
|
|
||||||
|
**الگوی پیشنهادی برای B:** بات خودتان با `sendVoice(file_id)` ویس را برای حساب کرالر بفرستد (کرالر یکبار بات را `/start` کرده). اینطور نیازی به دانلود و آپلود مجدد نیست و `message_id` بومی در اختیار کرالر است. پس از دریافت متن، پیام حذف شود. اگر RPC به `message_id` نیاز نداشت، مستقیم فایل یا بایت داده شود.
|
||||||
|
|
||||||
|
**مجوز:** `aiobale` مجوز MIT دارد ولی نگهداری نمیشود (فورکها: Autobale و BaleClient). `bale-sdk` مجوز اختصاصی دارد؛ برای استفاده تجاری از نویسنده اجازه بگیرید.
|
||||||
|
|
||||||
|
### گام ۴: ساخت adapter سختشده
|
||||||
|
|
||||||
|
قرارداد OpenAI-compatible (همان `POST /v1/audio/transcriptions` که بات انتظار دارد) و موتور قابلسوییچ با `BALE_STT_ENGINE=grpc|playwright`. پیشنهاد کد:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, secrets, asyncio, logging
|
||||||
|
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||||
|
|
||||||
|
log = logging.getLogger("bale-stt")
|
||||||
|
app = FastAPI()
|
||||||
|
KEY = os.environ["BALE_STT_INTERNAL_KEY"] # بدون مقدار پیشفرض
|
||||||
|
DAILY_CAP = int(os.environ.get("BALE_STT_DAILY_CAP", "100"))
|
||||||
|
lock = asyncio.Lock() # اجرای ترتیبی
|
||||||
|
count_today = 0 # در عمل با ریست روزانه
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/v1/audio/transcriptions")
|
||||||
|
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
|
||||||
|
language: str = Form("fa"), authorization: str = Header("")):
|
||||||
|
if not secrets.compare_digest(authorization, f"Bearer {KEY}"):
|
||||||
|
raise HTTPException(401)
|
||||||
|
if count_today >= DAILY_CAP:
|
||||||
|
raise HTTPException(429)
|
||||||
|
async with lock:
|
||||||
|
try:
|
||||||
|
text = await run_engine(await file.read()) # + تأخیر تصادفی ۱ تا ۳ ثانیه
|
||||||
|
except Exception:
|
||||||
|
log.exception("bale stt failed")
|
||||||
|
raise HTTPException(503) # تا بات فقط همین کاندید را حذف کند
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(503)
|
||||||
|
return {"text": text}
|
||||||
|
```
|
||||||
|
|
||||||
|
اتصال به بات (بدون تغییر کد):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
|
||||||
|
```
|
||||||
|
|
||||||
|
> پرووایدر فقط وقتی فعال میشود که هم `BASE_URL` و هم `API_KEY` پر باشند.
|
||||||
|
|
||||||
|
### گام ۵: عملیات و ایمنی
|
||||||
|
|
||||||
|
- فقط **شماره اختصاصی شرکت**؛ هرگز شماره شخصی.
|
||||||
|
- `session_file` یا پروفایل مرورگر روی ولوم داکر و هرگز در گیت.
|
||||||
|
- **OTP تنها گام انسانی** است. در ابطال نشست: هشدار به ادمین و هدایت ترافیک به سایر پرووایدرها.
|
||||||
|
- اجرای ترتیبی، فاصله تصادفی ۱ تا ۳ ثانیه، سقف روزانه، و ترجیح WebSocket به POST gRPC.
|
||||||
|
- حذف فوری پیام اسکرچ پس از استخراج و عدم نگهداری فایل صوتی روی دیسک.
|
||||||
|
- پایش: نرخ موفقیت، p50 و p95 تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه بیش از ۲۰٪ شد.
|
||||||
|
- **Rollback:** خالی کردن `STT_PROVIDER_3_*` در env بات و توقف کانتینر. بات فوراً به وضعیت قبل برمیگردد.
|
||||||
|
- سکرتهایی که در `docker-compose.yml` کامیت شدهاند (توکن بات، کلید DeepSeek) باید rotate شوند و `DRAFT_TOKEN_SECRET` پیشفرض ضعیف را عوض کنید.
|
||||||
|
- روی برنچهای دیگران (`prod-v7` و غیره) دخالت نکنید و بدون تأیید مالک چیزی پوش نشود.
|
||||||
|
|
||||||
|
### گام ۶: اندازهگیری و سواک
|
||||||
|
|
||||||
|
- **T5:** ۵۰ تا ۱۰۰ ویس ترتیبی با فاصله انسانی؛ پذیرش پیشنهادی: موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه.
|
||||||
|
- **سواک یکهفتهای** با حجم کم (حدود ۱۰۰ ترنسکرایب در روز): ثبت تعداد ورود مجدد، چالشهای امنیتی و عمر واقعی توکن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. دروازههای تصمیم (خلاصه)
|
||||||
|
|
||||||
|
| دروازه | شرط عبور | در صورت شکست |
|
||||||
|
|---|---|---|
|
||||||
|
| G0 دقت دستی | بهبود ≈ ۱۵٪ یا ۲ مورد سخت | **STOP** |
|
||||||
|
| G1 (T1) | فیلد ترنسکریپشن نبود | اگر بود: پایان، مستقیم وصل شود |
|
||||||
|
| G2 کشف | RPC یا دکمه وب پیدا شد | F یا توقف |
|
||||||
|
| G3 پروتوتایپ | درخواست ساخته و پاسخ خوانده شد | سوییچ به C |
|
||||||
|
| G4 نرخ و پایداری | ≥ ۹۵٪ موفقیت، p95 ≤ ۸ ثانیه | کاهش نرخ و backoff؛ در صورت بن: توقف |
|
||||||
|
| G6 ToS | تصمیم مکتوب مدیریت | **STOP** یا حجم بسیار محدود |
|
||||||
|
| G7 سواک | نشست سالم یک هفته | rollback و بازگشت به G4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. زمانبندی واقعبینانه
|
||||||
|
|
||||||
|
| مرحله | زمان |
|
||||||
|
|---|---|
|
||||||
|
| گام ۰ و ۱ (دقت دستی + کشف سریع) | نیمروز |
|
||||||
|
| گام ۲ (وب و کپچر) | ۱ روز |
|
||||||
|
| ساخت B یا C | ۲ تا ۴ روز |
|
||||||
|
| T5 و سواک | ۱ هفته |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. رجیستر ریسک
|
||||||
|
|
||||||
|
| ریسک | احتمال | اثر | مقابله |
|
||||||
|
|---|---|---|---|
|
||||||
|
| بن یا rate-limit حساب | متوسط | توقف مسیر | حجم کم، WS-first، سقف روزانه، حساب اختصاصی قابلتعویض |
|
||||||
|
| تغییر پروتکل یا UI بله | متوسط | قطع سرویس | هشدار خطا، فالبک، سوییچ موتور با env، rollback با env |
|
||||||
|
| انقضای نشست و نیاز به OTP | بالا (دورهای) | توقف موقت | ولوم نشست + هشدار + runbook ورود انسانی |
|
||||||
|
| دکمه وب وجود ندارد | نامعلوم | ناممکنشدن C | بررسی در گام ۲ قبل از ساخت |
|
||||||
|
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینه بیفایده | G0 قبل از ساخت |
|
||||||
|
| ToS / مسائل حقوقی | نامعلوم | توقف اجباری | تصمیم مکتوب مدیریت (G6)؛ ایمیل به بله (A) |
|
||||||
|
| مجوز اختصاصی `bale-sdk` | بالا | ممنوعیت استفاده | اجازه از نویسنده یا استفاده از `aiobale` (MIT) |
|
||||||
|
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیامها، عدم نگهداری فایل |
|
||||||
|
| وابستگی به یک فرد نگهدارنده | متوسط | رکود | مستندسازی و مالک رسمی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. دروازههای انسانی
|
||||||
|
|
||||||
|
1. OTP ورود حساب (بار اول و هر انقضا).
|
||||||
|
2. تصمیم ToS / حقوقی.
|
||||||
|
3. جمعآوری نمونه ویس واقعی با رضایت.
|
||||||
|
4. تأیید نهایی استقرار و مرج توسط مهندس ارشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. توصیه نهایی
|
||||||
|
|
||||||
|
1. **همین هفته:** گام ۰ (دقت دستی) + ایمیل به بله + گام ۱ (کشف سریع و T1).
|
||||||
|
2. **اگر دقت توجیه داشت:** گام ۲ و انتخاب B یا C بر اساس شواهد.
|
||||||
|
3. **معماری:** adapter با قرارداد OpenAI و موتور قابلسوییچ؛ بات دستنخورده میماند.
|
||||||
|
4. **تصمیم مدیریتی** درباره ToS قبل از استقرار.
|
||||||
|
5. اگر هر کدام از G0 یا G6 شکست خورد، **توقف** و ادامه با پرووایدرهای فعلی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منابع
|
||||||
|
|
||||||
|
- aiobale (PyPI): https://pypi.org/project/aiobale
|
||||||
|
- bale-sdk (PyPI): https://pypi.org/project/bale-sdk/
|
||||||
|
- Autobale (فورک aiobale): https://pypi.org/project/autobale/
|
||||||
|
- خبر انتشار نسخه بله با قابلیت تبدیل پیام صوتی به متن (خبرگزاری آنا): https://ana.ir/fa/news/994908
|
||||||
|
- مستندات رسمی بات بله: https://docs.bale.ai
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# گزارش فنی و امکانسنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
|
||||||
|
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیادهسازی اتصال به موتور تبدیل ویس به متن پیامرسان «بله»
|
||||||
|
> **هدف نهایی:** افزودن بله به عنوان یکی از مدلهای کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. خلاصه مدیریتی (Executive Summary)
|
||||||
|
|
||||||
|
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایلهای صوتی دریافتی از کاربران را همزمان به چندین ارائهدهنده (نظیر Whisper، Soniox یا سرویسهای ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
|
||||||
|
|
||||||
|
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجههای محلی، استفاده از **موتور صوتبهمتن بومی پیامرسان بله** به عنوان یکی از مدلهای کاندید مطرح شده است. از آنجا که پلتفرم توسعهدهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمیدهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
|
||||||
|
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیهسازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
|
||||||
|
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیهسازی درخواستهای شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. پسزمینه فنی زیرساخت بله (Technical Context)
|
||||||
|
|
||||||
|
| بخش | وضعیت دسترسی | توضیحات |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم میکند. |
|
||||||
|
| **کلاینتهای کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل میشود. |
|
||||||
|
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیادهشده به همراه پاسخ تحلیلی را دریافت میکند. |
|
||||||
|
|
||||||
|
**نتیجه:** برای بهرهبرداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
|
||||||
|
|
||||||
|
در این رویکرد، یک مرورگر کنترلشده بدون واسط گرافیکی (مانند Chromium تحت فریمورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته میشود.
|
||||||
|
|
||||||
|
### معماری جریان داده (Architecture Flow)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Bot as Procurement Bot
|
||||||
|
participant Worker as Browser Worker (Playwright)
|
||||||
|
participant BaleWeb as web.bale.ai (Chromium)
|
||||||
|
participant BaleServer as سرورهای پردازش صوت بله
|
||||||
|
|
||||||
|
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
|
||||||
|
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
|
||||||
|
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
|
||||||
|
BaleServer-->>BaleWeb: بازگرداندن متن پیادهشده به صفحه
|
||||||
|
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
|
||||||
|
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
|
||||||
|
```
|
||||||
|
|
||||||
|
### مؤلفههای پیادهسازی:
|
||||||
|
1. **مدیریت نشست پایدار (Persistent Profile):**
|
||||||
|
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار میگیرد.
|
||||||
|
- ورود اولیه فقط یکبار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام میشود و کوکیها و توکنهای LocalStorage/IndexedDB برای همیشه حفظ میگردند.
|
||||||
|
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
|
||||||
|
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
|
||||||
|
3. **مزایا و معایب:**
|
||||||
|
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچگونه ریسک ناسازگاری پکتهای شبکه وجود ندارد.
|
||||||
|
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریمورک پکتهای وبسوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار میکند.
|
||||||
|
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال میکند.
|
||||||
|
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیهسازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان میبرد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client API)
|
||||||
|
|
||||||
|
در این روش، بدون استفاده از مرورگر سنگین، کلاینت پایتونی سبک ما مستقیماً همان درخواستهای سوکت یا HTTP کلاینت رسمی بله را بازسازی و فراخوانی میکند.
|
||||||
|
|
||||||
|
### معماری جریان داده (Architecture Flow)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Bot as Procurement Bot
|
||||||
|
participant Adapter as Bale Client Protocol Adapter (Python)
|
||||||
|
participant BaleCore as هسته سرور بله (WebSocket / RPC)
|
||||||
|
|
||||||
|
Bot->>Adapter: ارسال بایتهای فایل صوتی
|
||||||
|
Adapter->>BaleCore: فراخوانی مستقیم متد Transcribe (از طریق سوکت یا HTTP)
|
||||||
|
BaleCore-->>Adapter: پاسخ مستقیم باینری/JSON متن
|
||||||
|
Adapter-->>Bot: متن نهایی در کمتر از ۱ ثانیه
|
||||||
|
```
|
||||||
|
|
||||||
|
### مؤلفههای پیادهسازی:
|
||||||
|
1. **آنالیز ترافیک شبکه (Traffic Inspection):**
|
||||||
|
- تحلیل تب Network مرورگر در `web.bale.ai`:
|
||||||
|
- استخراج مسیر اتصال (غالباً `wss://api.bale.ai/v1/users/websocket` یا اندپوینتهای REST).
|
||||||
|
- استخراج هدرهای نشست از `localStorage` کلاینت وب (مانند `access_token`, `device_id`, `session_key`).
|
||||||
|
- کشف پکت یا درخواست RPC مسئول تبدیل صوت (شامل شناسه پیام `message_id` و شناسه فایل صوتی `file_id`).
|
||||||
|
2. **کلاینت ناهمگام پایتون (`httpx` / `websockets`):**
|
||||||
|
- اتصال دائمی و سبک به زیرساخت پیامرسانی بله.
|
||||||
|
- ارسال مستقیم درخواست بدون بارگذاری فایلهای گرافیکی، جاوااسکریپت و CSS مرورگر.
|
||||||
|
3. **مزایا و معایب:**
|
||||||
|
- 🟢 **سرعت خیرهکننده (Ultra-low Latency):** پاسخگویی معمولاً در **کمتر از ۱ ثانیه** انجام میشود.
|
||||||
|
- 🟢 **مصرف ناچیز منابع (Lightweight):** مصرف حافظه رم زیر ۱۰ مگابایت.
|
||||||
|
- 🟢 **ظرفیت پردازش همزمان بالا (High Concurrency):** امکان ارسال همزمان دهها درخواست بدون افت پرفورمنس.
|
||||||
|
- 🔴 **حساسیت به پروتکل:** در صورت بازنویسی پروتکل یا فیلدهای باینری کلاینت بله، ممکن است نیاز به بهروزرسانی پکتها باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
|
||||||
|
|
||||||
|
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
|
||||||
|
| :--- | :---: | :---: | :---: |
|
||||||
|
| **تاخیر پاسخدهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
|
||||||
|
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
|
||||||
|
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
|
||||||
|
| **پیچیدگی پیادهسازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
|
||||||
|
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
|
||||||
|
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخههای ماژور | متوسط |
|
||||||
|
| **قابلیت مقیاسپذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. نحوه اتصال به پایپلاین STT چندمدله فعلی
|
||||||
|
|
||||||
|
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار میگیرد:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یکباره به MP3]
|
||||||
|
FFMPEG --> S1[مدل ۱: Whisper-Large]
|
||||||
|
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
|
||||||
|
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
|
||||||
|
|
||||||
|
S1 --> Aggregator[تجمیع نتایج کاندیداها]
|
||||||
|
S2 --> Aggregator
|
||||||
|
S3 --> Aggregator
|
||||||
|
|
||||||
|
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
|
||||||
|
DeepSeek --> Final[متن اصلاحشده Consensus Text + استخراج اقلام استعلام JSON]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. نقشه راه پیشنهادی دو مرحلهای برای تیم توسعه (Recommended Roadmap)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **استراتژی برد-برد (Fast Win + High Performance):**
|
||||||
|
> برای اینکه از سریعترین حالت ممکن با کمترین ریسک بهرهمند شویم، فرآیند اجرای فازبندیشده زیر پیشنهاد میشود:
|
||||||
|
|
||||||
|
### فاز ۱: ممیزی و اکتشاف ترافیک شبکه (کمتر از ۱ روز کاری)
|
||||||
|
1. باز کردن `web.bale.ai` با تب Network و کلیک روی دکمه تبدیل ویس.
|
||||||
|
2. تعیین ماهیت درخواست:
|
||||||
|
- اگر درخواست یک **HTTP POST ساده** یا یک فریم متنی وبسوکت با Authorization Header باشد:
|
||||||
|
👉 **روش دوم بلافاصله پیادهسازی میشود** و به بالاترین سرعت و بهرهوری ممکن میرسیم.
|
||||||
|
- اگر پروتکل شامل امضاها و فریمهای رمزگذاریشده پیچیده باشد:
|
||||||
|
👉 به سراغ اجرای فاز ۲ (روش اول) میرویم.
|
||||||
|
|
||||||
|
### فاز ۲: استقرار سرویس اتوماسیون (Fallback تضمینشده)
|
||||||
|
1. ساخت کانتینر داکر سبک `playwright-worker` در کنار کانتینر `procurement-bot`.
|
||||||
|
2. اتصال امن این کانتینر از طریق یک اندپوینت داخلی ساده:
|
||||||
|
`POST http://bale-stt-worker:8080/transcribe`
|
||||||
|
3. قرار گرفتن این سرویس به عنوان مدل سوم در متغیر محیطی:
|
||||||
|
`PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt-worker:8080/v1`
|
||||||
|
|
||||||
|
---
|
||||||
|
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# گزارش فنی و امکانسنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
|
||||||
|
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیادهسازی اتصال به موتور تبدیل ویس به متن پیامرسان «بله»
|
||||||
|
> **هدف نهایی:** افزودن بله به عنوان یکی از مدلهای کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. خلاصه مدیریتی (Executive Summary)
|
||||||
|
|
||||||
|
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایلهای صوتی دریافتی از کاربران را همزمان به چندین ارائهدهنده (نظیر Whisper، Soniox یا سرویسهای ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
|
||||||
|
|
||||||
|
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجههای محلی، استفاده از **موتور صوتبهمتن بومی پیامرسان بله** به عنوان یکی از مدلهای کاندید مطرح شده است. از آنجا که پلتفرم توسعهدهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمیدهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
|
||||||
|
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیهسازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
|
||||||
|
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیهسازی درخواستهای شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. پسزمینه فنی زیرساخت بله (Technical Context)
|
||||||
|
|
||||||
|
| بخش | وضعیت دسترسی | توضیحات |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم میکند. |
|
||||||
|
| **کلاینتهای کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل میشود. |
|
||||||
|
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیادهشده به همراه پاسخ تحلیلی را دریافت میکند. |
|
||||||
|
|
||||||
|
**نتیجه:** برای بهرهبرداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
|
||||||
|
|
||||||
|
در این رویکرد، یک مرورگر کنترلشده بدون واسط گرافیکی (مانند Chromium تحت فریمورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته میشود.
|
||||||
|
|
||||||
|
### معماری جریان داده (Architecture Flow)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Bot as Procurement Bot
|
||||||
|
participant Worker as Browser Worker (Playwright)
|
||||||
|
participant BaleWeb as web.bale.ai (Chromium)
|
||||||
|
participant BaleServer as سرورهای پردازش صوت بله
|
||||||
|
|
||||||
|
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
|
||||||
|
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
|
||||||
|
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
|
||||||
|
BaleServer-->>BaleWeb: بازگرداندن متن پیادهشده به صفحه
|
||||||
|
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
|
||||||
|
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
|
||||||
|
```
|
||||||
|
|
||||||
|
### مؤلفههای پیادهسازی:
|
||||||
|
1. **مدیریت نشست پایدار (Persistent Profile):**
|
||||||
|
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار میگیرد.
|
||||||
|
- ورود اولیه فقط یکبار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام میشود و کوکیها و توکنهای LocalStorage/IndexedDB برای همیشه حفظ میگردند.
|
||||||
|
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
|
||||||
|
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
|
||||||
|
3. **مزایا و معایب:**
|
||||||
|
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچگونه ریسک ناسازگاری پکتهای شبکه وجود ندارد.
|
||||||
|
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریمورک پکتهای وبسوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار میکند.
|
||||||
|
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال میکند.
|
||||||
|
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیهسازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان میبرد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client Protocol / gRPC-Web)
|
||||||
|
|
||||||
|
در این روش، بدون استفاده از مرورگر سنگین، تلاش میشود تا کلاینت پایتونی مستقیماً همان درخواستهای شبکه کلاینت رسمی بله را شبیهسازی و فراخوانی کند.
|
||||||
|
|
||||||
|
### یافتههای واقعی حاصل از بازرسی و تحلیل سورسکد لایو وب بله (`web.bale.ai`):
|
||||||
|
تیم ما اسکریپتهای باندل تولیدی بله (`/static/js/index.4aefbadad3.js` و بستههای وابسته) را مستقیماً تحلیل کرد که نتایج فنی مهم زیر حاصل شد:
|
||||||
|
1. **پروتکل زیرساختی:** بله از APIهای متنی ساده (مانند REST یا JSON) استفاده **نمیکند**؛ بلکه کل ارتباطات کلاینت وب بر پایه **gRPC-Web همراه با Protocol Buffers دودویی (`@bufbuild/protobuf`)** پیادهسازی شده است.
|
||||||
|
2. **آدرسهای Gateway سرورهای بله:**
|
||||||
|
- سرور gRPC پروداکشن: `https://maviz-ws.bale.ai`
|
||||||
|
- اتصال وبسوکت پروداکشن: `wss://maviz-ws.bale.ai/ws/?uid={user_id}`
|
||||||
|
- محیطهای آزمایشی: `beta-ws.bale.ai` و `next-ws.bale.ai`
|
||||||
|
3. **شناسهها و فیچرهای کشفشده در کد:**
|
||||||
|
- شناسه ماژول پیادهسازی صوت: `u.SpeechTranscript = "SpeechTranscript"`
|
||||||
|
- ماژول چت هوش مصنوعی: `u.AIChat = "AIChat"` با روت صفحه `/${L.AI_CHAT}`
|
||||||
|
- دامنههای مربوطه: `domain_chat_ai: "app.all.domain_chat_ai"` و `chat_ai_ttl`
|
||||||
|
|
||||||
|
### ارزیابی فنی و چالشهای روش دوم:
|
||||||
|
- **پیچیدگی بالای پیادهسازی:** به دلیل استفاده از پکتهای فشرده دودویی Protobuf، برای فراخوانی مستقیم در پایتون باید فایلهای تعاریف اینترفیس (`.proto`) بله به صورت کامل مهندسی معکوس و دیکود شوند و فریمهای ۵ بایتی استاندارد gRPC-Web در کلاینت پایتون کدگذاری گردند.
|
||||||
|
- **ریسک شکنندگی در تغییر اسکیما:** با کوچکترین تغییر در شماره فیلدهای Protobuf یا متدهای سرور بله در نسخههای جدید، کلاینت مهندسی معکوسشده از کار میافتد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
|
||||||
|
|
||||||
|
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
|
||||||
|
| :--- | :---: | :---: | :---: |
|
||||||
|
| **تاخیر پاسخدهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
|
||||||
|
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
|
||||||
|
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
|
||||||
|
| **پیچیدگی پیادهسازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
|
||||||
|
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
|
||||||
|
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخههای ماژور | متوسط |
|
||||||
|
| **قابلیت مقیاسپذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. نحوه اتصال به پایپلاین STT چندمدله فعلی
|
||||||
|
|
||||||
|
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار میگیرد:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یکباره به MP3]
|
||||||
|
FFMPEG --> S1[مدل ۱: Whisper-Large]
|
||||||
|
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
|
||||||
|
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
|
||||||
|
|
||||||
|
S1 --> Aggregator[تجمیع نتایج کاندیداها]
|
||||||
|
S2 --> Aggregator
|
||||||
|
S3 --> Aggregator
|
||||||
|
|
||||||
|
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
|
||||||
|
DeepSeek --> Final[متن اصلاحشده Consensus Text + استخراج اقلام استعلام JSON]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. نقشه راه پیشنهادی دو مرحلهای برای تیم توسعه (Recommended Roadmap)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **نتیجه تست و تحلیل تجربی ترافیک:**
|
||||||
|
> بر اساس آنالیز دقیق سورسکد لایو `web.bale.ai` که انجام شد، ماهیت درخواستهای بله مشخص گردید: ارتباطات به صورت فریمهای دودویی **Protobuf + gRPC-Web** است. بنابراین مهندسی معکوس پکتها نیازمند استخراج و بازسازی مستمر فایلهای `.proto` اختصاصی بله است که نگهداری آن را بسیار شکننده میکند.
|
||||||
|
>
|
||||||
|
> **نتیجهگیری و پیشنهاد نهایی:**
|
||||||
|
> مناسبترین، مطمئنترین و پایدارترین گزینه برای استقرار در محصول، **روش اول (اتوماسیون مرورگر با Playwright Worker)** است:
|
||||||
|
> ۱. مرورگر به صورت طبیعی از وبسوکت و پروتکل Protobuf بله پشتیبانی میکند و نیازی به رمزگشایی پکتها نیست.
|
||||||
|
> ۲. با یک سشن پایدار در داکر، وویس در صفحه پیاده شده و متن خروجی به شکل مطمئن به خط لوله Multi-STT تحویل داده میشود.
|
||||||
|
> ۳. پیادهسازی آن در قالب یک کانتینر ایزوله (`bale-stt-worker`) با یک اندپوینت سبک `POST /transcribe` سریعاً انجام میپذیرد.
|
||||||
|
|
||||||
|
---
|
||||||
|
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
# سند جامع، تحلیلی و عملیاتی کرالر تبدیل صوتبهمتن (STT) بله
|
||||||
|
> **نسخه:** ۳.۰ (جامع، تجمیعی و نهایی)
|
||||||
|
> **مخاطب اول:** مدیر پروژه (تصمیمگیری کلان، ماتریسهای مقایسهای، جریانهای داده و فلوهای اجرایی)
|
||||||
|
> **مخاطب دوم:** ایجنت هوش مصنوعی مجری (شناخت پروژه، معماری فنی، ساختار پروتکل، کدهای مرجع و چکلیست راهاندازی)
|
||||||
|
> **وضعیت نیازمندیها:** محدودیت حریم خصوصی موقتاً برداشته شده؛ صحت و کیفیت بالای موتور بله در استعلامهای تدارکاتی توسط کاربر اثبات شده؛ وجود دکمه تبدیل متن در وب (`web.bale.ai`) تأیید شده؛ رویکرد Playwright به عنوان **فالبک آخر (Last Resort)** و رویکرد RPC به عنوان **اولویت اول** تعیین شده است؛ هیچ تخمین زمانی تقویمی وجود ندارد چون تمامی پیادهسازیها توسط ایجنت انجام میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# بخش اول: برای مدیر پروژه (دید کلان، مقایسهها و فلوهای تصویری)
|
||||||
|
|
||||||
|
## ۱. صورتمسئله و دلایل توجیهی پروژه
|
||||||
|
|
||||||
|
در سامانه استعلام هوشمند تدارکات، کاربران پیامهای صوتی (ویس) حاوی درخواستهای پیچیده خرید (شامل نام کالا، برند، مشخصات فنی، مقادیر، واحدها و اصطلاحات بازار) ارسال میکنند.
|
||||||
|
بات تدارکات برای رونویسی این پیامها از یک **خطلوله چندمدله (Multi-Provider STT Pipeline)** استفاده میکند؛ به این معنا که ویس همزمان به چند سرویس تبدیل صوت ارسال شده و متنهای دریافتشده در اختیار یک مدل تحلیلی هوش مصنوعی (DeepSeek NLU) قرار میگیرد تا با ایجاد اجماع (Consensus)، خطاهای واژگانی برطرف و ساختار استاندارد استعلام استخراج گردد.
|
||||||
|
|
||||||
|
### چرا کرال کردن موتور بله ارزش بالایی دارد؟
|
||||||
|
1. **کیفیت اثباتشده در آزمون واقعی:** تستهای تجربی روی ویسهای واقعی استعلام تدارکات نشان داد که موتور تشخیص گفتار بله تسلط بسیار بالایی بر محاورات عامیانه فارسی، اصطلاحات بازاری و لهجهها دارد.
|
||||||
|
2. **فقدان متد در رابط رسمی (Bot API):** در مستندات رسمی بله (`docs.bale.ai`)، شیء `Voice` ارسالی به رباتها فاقد فیلد ترنسکریپت بوده و تنها مشخصاتی نظیر `file_id` را برمیگرداند؛ بنابراین دسترسی به این موتور صرفاً از طریق شبیهسازی نشست کاربری امکانپذیر است.
|
||||||
|
3. **تأیید وجود دکمه در وب بله:** بررسی محیط `web.bale.ai` اثبات کرد که قابلیت تبدیل ویس به متن در کلاینت تحت وب بله فعال است؛ این یافته امکان کپچر ترافیک و مهندسی معکوس و نیز استفاده از وب به عنوان پشتیبان را تضمین میکند.
|
||||||
|
4. **دستور مدیریت پروژه در چینش اولویتها:**
|
||||||
|
* **اولویت اول:** ارتباط مستقیم در لایه شبکه و پروتکل کاربری (RPC / WebSocket) به دلیل مصرف ناچیز منابع (کمتر از ۲۰ مگابایت رم) و سرعت استخراج بالا.
|
||||||
|
* **فالبک آخر (Last Resort):** کرال کردن بصری صفحه چت با Playwright صرفاً در شرایطی که ارتباط شبکهای به بنبست برسد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. ماتریس مقایسهای جامع ۸ راهکار موجود
|
||||||
|
|
||||||
|
برای ایجاد دید همهجانبه، تمامی راهکارهای مطرح در اکوسیستم فنی در جدول زیر کالبدشکافی شدهاند (بدون ستونهای زائد زمانی؛ با تمرکز بر رفتار سیستمی):
|
||||||
|
|
||||||
|
| # | راهکار فنی | نحوه عملکرد | مصرف حافظه RAM | خلوص متن خروجی | پایداری در برابر تغییرات | ریسک عملیاتی | اولویت در پروژه ما |
|
||||||
|
| :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: |
|
||||||
|
| **۱** | **کلاینت شبکه و RPC بله (Web RPC / WS)** | فراخوانی مستقیم متد کشفشده `GetTranscript` در کلاینت کاربری پایتون | کمتر از ۲۰ مگابایت | متن خالص گفتار | متوسط (وابسته به ثبات متد) | احتمال نیاز به تمدید نشست (OTP) | ⭐⭐⭐ **اولویت اول و اصلی** |
|
||||||
|
| **۲** | **اتوماسیون Playwright در `web.bale.ai`** | مرورگر بدون سربرگ (Headless)، کلیک روی دکمه و خواندن متن از DOM | ۳۵۰ تا ۵۰۰ مگابایت | متن خالص گفتار | بالا (مقاوم به پکتهای شبکه) | مصرف رم و سربار رندر المانها | ⚠️ **فالبک نهایی (Last Resort)** |
|
||||||
|
| **۳** | **وبسرویسهای تجاری ایرانی (فارسآوا / آوانگار)** | اتصال رسمی به API شرکتهای پردازش صوت بومی (عامراندیش، پارتآی و...) | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا (SLA تجاری) | ایجاد هزینه اشتراک ریالی برای دقایق صوت | ⭐⭐ **پیشنهاد به عنوان کاندید موازی** |
|
||||||
|
| **۴** | **مدل متنباز خودمیزبان (Faster-Whisper)** | استقرار مدل `whisper-v3-turbo` با واژگان تدارکات روی سرور داخلی | ۱ تا ۳ گیگابایت | متن خالص گفتار | پایدار مطلق (بدون وابستگی) | نیاز به پردازنده گرافیکی (GPU) مناسب | ⭐⭐ **پرووایدر پشتیبان و مستقل** |
|
||||||
|
| **۵** | **وبسرویسهای واسط غیررسمی (MajidAPI و...)** | ارسال لینک ویس به پراکسیهای عمومی رایگان | ناچیز (سمت سرور ثالث) | وابسته به موتور واسط | بسیار پایین و ناپایدار | فقدان SLA، احتمال قطعی ناگهانی و نشت صوت | ❌ **رد شده (غیرقابل اتکا)** |
|
||||||
|
| **۶** | **چت هوش مصنوعی بله (اوراکل)** | ارسال ویس به ربات رسمی هوش مصنوعی درون بله | کمتر از ۲۰ مگابایت | **ناخالص** (متن + تحلیل LLM) | بسیار پایین | پاسخ تفسیری، تاخیر ۵ تا ۱۵ ثانیهای | ❌ **رد شده (تخریب اجماع)** |
|
||||||
|
| **۷** | **شبیهساز اندروید (Appium / UIAutomator)** | اجرای اپلیکیشن اندروید بله روی ماشین مجازی و اسکرپ متن | بیش از ۲ گیگابایت | متن خالص گفتار | شکننده | سربار بسیار سنگین CPU/RAM سرور | ❌ **رد شده** |
|
||||||
|
| **۸** | **سرویسهای ابری خارجی (OpenAI / Chirp 2)** | ارسال فایل به API رسمی Whisper یا Google Chirp | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا | وابستگی به ارز و ریسک تحریم/اختلال شبکه | ⭐ **پرووایدر کمکی در صورت دسترسی** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. فلوهای جامع تصویری (Architecture & Sequence Flows)
|
||||||
|
|
||||||
|
### فلو ۱: جریان کلان پردازش ویس در سیستم تدارکات (End-to-End System Flow)
|
||||||
|
این دیاگرام نشان میدهد ویس ارسالی کاربر چگونه در خطلوله موازی پردازش شده و کاندید بله بدون ایجاد وقفه در سیستم ثبت استعلام مینشیند:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as کاربر تدارکات
|
||||||
|
participant MainBot as بات اصلی تدارکات (Bale Bot API)
|
||||||
|
participant STTPipeline as موتور چندمدله (bot/app/stt.py)
|
||||||
|
participant BaleAdapter as میکروسرویس آداپتور بله (bale-stt:8080)
|
||||||
|
participant OtherProviders as سایر پرووایدرها (OpenAI / محلی)
|
||||||
|
participant ConsensusNLU as مدل استخراج هوشمند (DeepSeek NLU)
|
||||||
|
|
||||||
|
User->>MainBot: ارسال پیام صوتی استعلام خرید
|
||||||
|
MainBot->>STTPipeline: دریافت ویس و تبدیل فرمت به MP3 (16kHz Mono)
|
||||||
|
|
||||||
|
par استعلام موازی از پرووایدرهای فعال
|
||||||
|
STTPipeline->>BaleAdapter: POST /v1/audio/transcriptions (بایتهای ویس)
|
||||||
|
STTPipeline->>OtherProviders: POST /v1/audio/transcriptions
|
||||||
|
end
|
||||||
|
|
||||||
|
critical پردازش در کانتینر ایزوله کرالر بله
|
||||||
|
BaleAdapter->>BaleAdapter: کنترل صف ترتیبی و اعمال تاخیر رفتاری (Jitter)
|
||||||
|
BaleAdapter->>BaleAdapter: ارسال صوت به اکانت کرالر و فراخوانی متد GetTranscript
|
||||||
|
BaleAdapter-->>BaleAdapter: دریافت متن پیادهشده و حذف پیام اسکرچ
|
||||||
|
end
|
||||||
|
|
||||||
|
BaleAdapter-->>STTPipeline: بازگشت متن کاندید بله {"text": "..."}
|
||||||
|
OtherProviders-->>STTPipeline: بازگشت متن سایر کاندیدها
|
||||||
|
|
||||||
|
STTPipeline->>ConsensusNLU: ارسال تمام کاندیدها به مدل اجماعگیر
|
||||||
|
ConsensusNLU-->>MainBot: استخراج اقلام کالا، برند، مشخصات و تعداد
|
||||||
|
MainBot-->>User: ارسال پیشفاکتور استعلام یا تاییدیه به کاربر
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فلو ۲: جریان رله صوتی در کرالر (Relay Bot Pattern Flow)
|
||||||
|
برای جلوگیری از فرآیند پرریسک و پیچیده «آپلود مستقیم فایل از اکانت شخصی»، از الگوی **بات رله** استفاده میشود:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant Adapter as هسته آداپتور FastAPI
|
||||||
|
participant RelayBot as بات رله اختصاصی (Bale Bot API)
|
||||||
|
participant BaleServer as سرورهای مرکزی بله
|
||||||
|
participant CrawlerUser as اکانت کاربری کرالر (WebSocket Client)
|
||||||
|
|
||||||
|
Adapter->>Adapter: دریافت فایل صوتی از خطلوله بات
|
||||||
|
Adapter->>RelayBot: فراخوانی sendVoice(chat_id=کرالر, voice=audio, caption=nonce)
|
||||||
|
RelayBot->>BaleServer: ارسال ویس با مجوز رسمی بات
|
||||||
|
BaleServer-->>CrawlerUser: دریافت رویداد پیام صوتی جدید (دارای Media و MessageId و OutPeer)
|
||||||
|
|
||||||
|
CrawlerUser->>BaleServer: فراخوانی مستقیم RPC: GetTranscript(Media, OutPeer, MessageId)
|
||||||
|
BaleServer-->>CrawlerUser: بازگشت پاسخ (mustWait / downloadSource)
|
||||||
|
|
||||||
|
opt در صورت فعال بودن mustWait
|
||||||
|
CrawlerUser->>CrawlerUser: پولینگ سبک یا انتظار دریافت رویداد تکمیلی
|
||||||
|
end
|
||||||
|
|
||||||
|
CrawlerUser->>BaleServer: فراخوانی DeleteMessage (حذف پیام برای حفظ بهداشت اکانت)
|
||||||
|
CrawlerUser-->>Adapter: تحویل متن خالص استخراجشده
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فلو ۳: فلو تابآوری و عدم توقف بات (Fail-Safe & Graceful Degradation)
|
||||||
|
این فلو تضمین میکند که حتی در بدترین سناریوها (تغییر پروتکل بله، بن شدن اکانت یا قطعی سرور بله)، سیستم اصلی تدارکات دچار حتی یک ثانیه قطعی نخواهد شد:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start["کاربر ویس میفرستد"] --> Multi["اجرای همزمان transcribe_multi() در stt.py"]
|
||||||
|
|
||||||
|
Multi --> CallBale["ارسال درخواست به آداپتور بله"]
|
||||||
|
Multi --> CallOthers["ارسال درخواست به سایر پرووایدرها"]
|
||||||
|
|
||||||
|
CallBale --> CheckBale{"آیا خطایی رخ داد؟ (بن/تغییر پروتکل/تایماوت)"}
|
||||||
|
|
||||||
|
CheckBale -- "بله" --> Resp503["آداپتور کد HTTP 503 برمیگرداند"]
|
||||||
|
Resp503 --> LogWarn["تابع _call_single_stt هشدار ثبت کرده و None بازمیگرداند"]
|
||||||
|
LogWarn --> IgnoreBale["کاندید بله بدون اختلال از لیست اجماع حذف میشود"]
|
||||||
|
|
||||||
|
CheckBale -- "خیر" --> Resp200["آداپتور متن رونویسیشده را تحویل میدهد"]
|
||||||
|
Resp200 --> AddBale["متن بله به لیست کاندیدها اضافه میشود"]
|
||||||
|
|
||||||
|
CallOthers --> RespOthers["متنهای سایر پرووایدرها دریافت میشوند"]
|
||||||
|
|
||||||
|
IgnoreBale --> NLU["اجماع NLU روی سایر متنهای موجود انجام میشود"]
|
||||||
|
AddBale --> NLU
|
||||||
|
RespOthers --> NLU
|
||||||
|
|
||||||
|
NLU --> Done["استعلام با موفقیت ثبت و پاسخ به کاربر داده میشود"]
|
||||||
|
|
||||||
|
subgraph Rollback["عملیات قطع دائمی (در صورت لزوم)"]
|
||||||
|
Env["خالی کردن متغیرهای PROCUREMENT_BOT_STT_PROVIDER_3_*"]
|
||||||
|
ZeroDowntime["سیستم بدون ریاستارت یا دیپلوی مجدد به کار عادی ادامه میدهد"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فلو ۴: سلسلهمراتب گزینش موتور در آداپتور (Engine Fallback Hierarchy)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
StartEngine["درخواست STT وارد آداپتور میشود"] --> ModeCheck{"بررسی تنظیم متغیر BALE_STT_ENGINE"}
|
||||||
|
|
||||||
|
ModeCheck -- "rpc (پیشفرض)" --> TryRPC["اجرای موتور E1: متد باینری GetTranscript"]
|
||||||
|
TryRPC --> CheckRPC{"آیا رونویسی با موفقیت انجام شد؟"}
|
||||||
|
|
||||||
|
CheckRPC -- "بله" --> ReturnText["بازگرداندن متن (HTTP 200)"]
|
||||||
|
CheckRPC -- "شکست در پروتکل شبکه" --> CheckFallback{"آیا فالبک خودکار مجاز است؟"}
|
||||||
|
|
||||||
|
CheckFallback -- "بله" --> TryPW["سوئیچ به موتور E2: اتوماسیون Playwright (فالبک آخر)"]
|
||||||
|
TryPW --> CheckPW{"آیا متن از وب استخراج شد؟"}
|
||||||
|
CheckPW -- "بله" --> ReturnText
|
||||||
|
CheckPW -- "خیر" --> Fail503["بازگرداندن خطای HTTP 503 (Fail-Soft)"]
|
||||||
|
|
||||||
|
CheckFallback -- "خیر" --> Fail503
|
||||||
|
ModeCheck -- "playwright" --> TryPW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# بخش دوم: برای ایجنت هوش مصنوعی مجری (Technical Execution Specification)
|
||||||
|
|
||||||
|
## ۱. شناخت ساختار کدهای موجود در پروژه
|
||||||
|
|
||||||
|
ایجنت باید بداند بات تدارکات فعلی در فایلهای زیر پیادهسازی شده و قراردادهای آن به چه صورت است:
|
||||||
|
|
||||||
|
### ۱.۱ فایل [bot/app/stt.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/stt.py)
|
||||||
|
* **قرارداد ارتباطی:** تابع `_call_single_stt` ریکوئست `POST` به اندپوینت `{base_url}/audio/transcriptions` با هدر `Authorization: Bearer {api_key}` ارسال میکند.
|
||||||
|
* **فایل ارسالی:** فرم چندبخشی (Multipart) شامل کلید `file` با باینری صوتی MP3 (16kHz Mono)، `model` و `language="fa"`.
|
||||||
|
* **مدیریت پاسخ:**
|
||||||
|
* در صورت دریافت کد `200` و وجود کلید `text` غیرخالی، مقدار به عنوان کاندید معتبر ثبت میشود.
|
||||||
|
* در صورت دریافت هر کد خطایی (مانند `503`) یا بروز استثنا، صرفاً لاگ هشدار ثبت شده و مقدار `None` بازمیگردد.
|
||||||
|
* **تابع `transcribe_multi`:** تمام تسکها را با `asyncio.gather(..., return_exceptions=True)` اجرا میکند؛ یعنی شکست یک پرووایدر هرگز کل فرآیند را متوقف نمیکند.
|
||||||
|
|
||||||
|
### ۱.۲ فایل [bot/app/config.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/config.py)
|
||||||
|
* متغیرهای محیطی با پیشوند `PROCUREMENT_BOT_STT_PROVIDER_N_*` (که N اعداد ۱ تا ۳ است) خوانده میشوند.
|
||||||
|
* یک پرووایدر تنها زمانی فعال میشود که **هر دو فیلد `BASE_URL` و `API_KEY` مقداردهی شده باشند**. بنابراین با خالی کردن این دو فیلد، پرووایدر بله فوراً از مدار خارج میشود (Rollback آنی).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. مشخصات قطعی کشفشده از پروتکل بله
|
||||||
|
|
||||||
|
در بررسی و راستیآزمایی مخازن PyPI روی پکیج `bale-sdk` (نسخه 0.1.1)، متد و ساختار فیلدهای ترنسکریپشن دقیقاً استخراج شده است:
|
||||||
|
|
||||||
|
* **فضای نام و سرویس:** `bale.turing.v1.AI`
|
||||||
|
* **متد باینری:** `GetTranscript`
|
||||||
|
* **اسکیمای درخواست (`GetTranscriptRequest`):**
|
||||||
|
```protobuf
|
||||||
|
message GetTranscriptRequest {
|
||||||
|
Media voice = 1; // آبجکت فایل صدا (شناسه و خصوصیات باینری)
|
||||||
|
OutPeer outPeer = 2; // مقصد چت (پیر چت شخصی یا بات رله)
|
||||||
|
MessageId messageId = 3;// شناسه پیام ثبتشده در چت
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **اسکیمای پاسخ (`GetTranscriptResponse`):**
|
||||||
|
```protobuf
|
||||||
|
message GetTranscriptResponse {
|
||||||
|
bool mustWait = 1; // فلگ بولی نشاندهنده در صف بودن پردازش سرور
|
||||||
|
DownloadSource downloadSource = 2; // منبع یا لینک بافر متنی خروجی
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. ارزیابی کتابخانههای پایتون و استراتژی Clean-Room
|
||||||
|
|
||||||
|
| نام پکیج | نسخه در PyPI | مجوز حقوقی | وضعیت استفاده فنی در پروژه ما |
|
||||||
|
| :--- | :---: | :---: | :--- |
|
||||||
|
| **`bale-sdk`** | 0.1.1 | اختصاصی (All Rights Reserved) | **استفاده به عنوان مرجع پروتکل؛** به دلیل مجوز اختصاصی، کدهای آن کپی مستقیم نمیشوند بلکه ساختار پیامها بر اساس کپچر بازسازی میگردد. |
|
||||||
|
| **`autobale`** | 1.0.0 | **MIT** (متنباز) | **مناسب برای هسته ارتباطی؛** فورک فعال و بازنویسیشده از `aiobale` که بر بستر WebSocket و Dispatcher ناهمگام کار میکند. |
|
||||||
|
| **`aiobale-py`** | 0.3.9 | عمومی | دارای لایه وبسوکت و هندلرهای باینری مبتنی بر `blackboxprotobuf` (`bbpb`). |
|
||||||
|
| **`bbpb` / `blackboxprotobuf`** | آخرین نسخه | MIT | ابزار دیکود و انکود فریمهای باینری پروتوباف بدون نیاز به کامپایل فایلهای `.proto`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. ورودیهای انسانی مورد نیاز ایجنت (Agent Inputs)
|
||||||
|
|
||||||
|
ایجنت قبل از راهاندازی، این ۵ مورد را از انسان دریافت میکند:
|
||||||
|
1. `BALE_PHONE`: شماره سیمکارت شرکتی اختصاصی جهت ورود به اکانت کاربری.
|
||||||
|
2. `BALE_OTP_CODE`: کد پیامکی ۶ رقمی یکبارمصرف در زمان اجرای اسکریپت ورود اولیه.
|
||||||
|
3. `BALE_RELAY_BOT_TOKEN`: توکن بات رله اختصاصی که از BotFather بله دریافت شده است.
|
||||||
|
4. `BALE_STT_INTERNAL_KEY`: کلید امنیتی تصادفی برای هدر `Authorization: Bearer <KEY>`.
|
||||||
|
5. `CRAWLER_USER_ID`: شناسه عددی کاربری اکانت کرالر (جهت ارسال پیام توسط بات رله).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. کدهای مرجع میکروسرویس آداپتور (`bale-stt`)
|
||||||
|
|
||||||
|
### ۵.۱ ساختار پروژه ایجنت
|
||||||
|
```
|
||||||
|
bale-stt/
|
||||||
|
├── adapter/
|
||||||
|
│ ├── main.py # میکروسرویس FastAPI سازگار با قرارداد OpenAI
|
||||||
|
│ ├── counters.py # مدیریت سقف روزانه با دیتابیس پایدار SQLite
|
||||||
|
│ ├── config.py # تنظیمات محیطی بدون مقدار پیشفرض خطرناک
|
||||||
|
│ ├── engines/
|
||||||
|
│ │ ├── base.py # اینترفیس استاندارد موتورها
|
||||||
|
│ │ ├── rpc.py # موتور اولویت ۱: ارتباط RPC و الگوی بات رله
|
||||||
|
│ │ └── playwright.py # موتور فالبک آخر: اتوماسیون صفحه وب بله
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── docker-compose.stt.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۲ مدیریت سقف روزانه (`adapter/counters.py`)
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
DB_PATH = os.getenv("BALE_STT_DB", "/app/state/usage.db")
|
||||||
|
|
||||||
|
def _init_db() -> sqlite3.Connection:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
con = sqlite3.connect(DB_PATH, timeout=5)
|
||||||
|
con.execute("CREATE TABLE IF NOT EXISTS usage (day TEXT PRIMARY KEY, count INTEGER NOT NULL)")
|
||||||
|
return con
|
||||||
|
|
||||||
|
def get_today_count() -> int:
|
||||||
|
with closing(_init_db()) as con:
|
||||||
|
row = con.execute("SELECT count FROM usage WHERE day = ?", (date.today().isoformat(),)).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
def increment_today_count() -> None:
|
||||||
|
with closing(_init_db()) as con, con:
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO usage(day, count) VALUES (?, 1) "
|
||||||
|
"ON CONFLICT(day) DO UPDATE SET count = count + 1",
|
||||||
|
(date.today().isoformat(),),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۳ کد اصلی آداپتور (`adapter/main.py`)
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||||
|
|
||||||
|
from counters import get_today_count, increment_today_count
|
||||||
|
from engines.base import get_engine
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger("bale-stt-adapter")
|
||||||
|
|
||||||
|
app = FastAPI(title="Bale STT OpenAI Adapter")
|
||||||
|
|
||||||
|
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
|
||||||
|
DAILY_CAP = int(os.getenv("BALE_STT_DAILY_CAP", "200"))
|
||||||
|
TIMEOUT_SECONDS = float(os.getenv("BALE_STT_TIMEOUT", "25.0"))
|
||||||
|
|
||||||
|
# قفل همزمانی برای پردازش تکاکانت کاربری
|
||||||
|
account_lock = asyncio.Lock()
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def health_check():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"engine": engine.name,
|
||||||
|
"used_today": get_today_count(),
|
||||||
|
"cap": DAILY_CAP
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/v1/audio/transcriptions")
|
||||||
|
async def transcribe(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
model: str = Form("bale-stt"),
|
||||||
|
language: str = Form("fa"),
|
||||||
|
authorization: str = Header("")
|
||||||
|
):
|
||||||
|
# ۱. اعتبارسنجی امن کلید احراز هویت (مقاوم به Timing Attack)
|
||||||
|
expected_header = f"Bearer {INTERNAL_KEY}"
|
||||||
|
if not secrets.compare_digest(authorization.encode(), expected_header.encode()):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
# ۲. کنترل سقف مصرف مجاز روزانه
|
||||||
|
if get_today_count() >= DAILY_CAP:
|
||||||
|
log.warning("Daily STT capacity reached (%d)", DAILY_CAP)
|
||||||
|
raise HTTPException(status_code=429, detail="Daily quota exhausted")
|
||||||
|
|
||||||
|
audio_bytes = await file.read()
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty audio payload")
|
||||||
|
|
||||||
|
# ۳. ورود به صف ترتیبی با فاصله زمانی تصادفی انسانی
|
||||||
|
async with account_lock:
|
||||||
|
try:
|
||||||
|
# اعمال Jitter بین ۱ تا ۲.۵ ثانیه
|
||||||
|
await asyncio.sleep(1.0 + (secrets.randbelow(15) / 10.0))
|
||||||
|
|
||||||
|
# فراخوانی موتور فعال با سقف زمانی مجاز
|
||||||
|
text = await asyncio.wait_for(
|
||||||
|
engine.transcribe(audio_bytes, filename=file.filename or "voice.mp3"),
|
||||||
|
timeout=TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
cleaned_text = (text or "").strip()
|
||||||
|
if not cleaned_text:
|
||||||
|
raise RuntimeError("Received empty transcript from Bale")
|
||||||
|
|
||||||
|
increment_today_count()
|
||||||
|
log.info("Transcription successful (length=%d)", len(cleaned_text))
|
||||||
|
return {"text": cleaned_text}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Transcription failed in engine %s: %s", engine.name, exc)
|
||||||
|
# پرتاب 503 حیاتی است: باعث میشود bot/app/stt.py کاندید را نادیده بگیرد
|
||||||
|
raise HTTPException(status_code=503, detail="Bale STT Service Temporarily Unavailable")
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۴ موتور RPC با الگوی بات رله (`adapter/engines/rpc.py`)
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from engines.base import BaseEngine
|
||||||
|
|
||||||
|
class RpcRelayEngine(BaseEngine):
|
||||||
|
name = "rpc"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bot_token = os.environ["BALE_RELAY_BOT_TOKEN"]
|
||||||
|
self.crawler_user_id = int(os.environ["BALE_CRAWLER_USER_ID"])
|
||||||
|
self.api_url = f"https://tapi.bale.ai/bot{self.bot_token}"
|
||||||
|
|
||||||
|
async def transcribe(self, audio: bytes, filename: str) -> str:
|
||||||
|
nonce = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# ۱. ارسال ویس با Bot API رسمی به چت اکانت کرالر
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.api_url}/sendVoice",
|
||||||
|
data={"chat_id": self.crawler_user_id, "caption": nonce},
|
||||||
|
files={"voice": ("voice.ogg", audio, "audio/ogg")}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Relay bot sendVoice failed: {resp.text}")
|
||||||
|
|
||||||
|
sent_message_id = resp.json()["result"]["message_id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ۲. دریافت رویداد پیام در کلاینت کاربری و فراخوانی GetTranscript
|
||||||
|
from driver.bale_client import get_client
|
||||||
|
bale_client = await get_client()
|
||||||
|
|
||||||
|
transcript_text = await bale_client.call_get_transcript(
|
||||||
|
message_id=sent_message_id,
|
||||||
|
nonce=nonce,
|
||||||
|
timeout=18.0
|
||||||
|
)
|
||||||
|
return transcript_text
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# ۳. پاکسازی بهداشتی: حذف پیام از چت
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{self.api_url}/deleteMessage",
|
||||||
|
json={"chat_id": self.crawler_user_id, "message_id": sent_message_id}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. تنظیمات داکر و یکپارچهسازی با بات اصلی
|
||||||
|
|
||||||
|
### ۶.۱ فایل `adapter/Dockerfile`
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV TZ=Asia/Tehran PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶.۲ فایل `docker-compose.stt.yml`
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bale-stt:
|
||||||
|
build:
|
||||||
|
context: ./adapter
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bale-stt-adapter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Tehran
|
||||||
|
- BALE_STT_ENGINE=rpc
|
||||||
|
- BALE_STT_INTERNAL_KEY=${BALE_STT_INTERNAL_KEY}
|
||||||
|
- BALE_STT_DAILY_CAP=200
|
||||||
|
- BALE_RELAY_BOT_TOKEN=${BALE_RELAY_BOT_TOKEN}
|
||||||
|
- BALE_CRAWLER_USER_ID=${BALE_CRAWLER_USER_ID}
|
||||||
|
volumes:
|
||||||
|
- bale_session_data:/app/session
|
||||||
|
- bale_usage_data:/app/state
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bale_session_data:
|
||||||
|
bale_usage_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bot_network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۶.۳ تنظیمات متغیرهای محیطی در فایل `.env` بات اصلی
|
||||||
|
اتصال به بات اصلی **بدون دستکاری حتی ۱ خط از کدهای پایتون بات** انجام میپذیرد:
|
||||||
|
```env
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=${BALE_STT_INTERNAL_KEY}
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
|
||||||
|
PROCUREMENT_BOT_STT_PROVIDER_3_TIMEOUT=30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. چکلیست گامبهگام پیادهسازی خودکار توسط ایجنت
|
||||||
|
|
||||||
|
ایجنت مجری برای به سرانجام رساندن پروژه، این چکلیست عملیاتی را بدون معطلی و به ترتیب اجرا میکند:
|
||||||
|
|
||||||
|
* [ ] **گام ۱ (لاگین کاربری و ذخیره سشن):** اجرای اسکریپت CLI برای ثبت شماره `BALE_PHONE`، دریافت کد `BALE_OTP_CODE` از انسان، صدور توکن پایدار JWT و ذخیره در مسیر `/app/session/session.json`.
|
||||||
|
* [ ] **گام ۲ (تست اتصال بات رله):** تست ارسال یک صوت آزمایشی با `sendVoice` از بات رله به `BALE_CRAWLER_USER_ID` و اطمینان از دریافت وبسوکت در کلاینت کرالر.
|
||||||
|
* [ ] **گام ۳ (اعتبارسنجی فراخوانی `GetTranscript`):** پاس دادن `Media` و `MessageId` به متد باینری و دریافت رشته متن فارسی.
|
||||||
|
* [ ] **گام ۴ (بیلد کانتینر آداپتور FastAPI):** بیلد ایمیج داکر و اجرای محلی روی پورت 8080.
|
||||||
|
* [ ] **گام ۵ (تست تطابق با قرارداد OpenAI):** ارسال ریکوئست تستی با `curl` به `/v1/audio/transcriptions` و اعتبارسنجی پاسخ `{"text": "..."}`.
|
||||||
|
* [ ] **گام ۶ (تست سناریوی خطا):** قطع عمدی اتصال بله و حصول اطمینان از بازگشت کد `HTTP 503` (جهت تضمین عدم توقف بات اصلی).
|
||||||
|
* [ ] **گام ۷ (فعالسازی در بات تدارکات):** قرار دادن مقادیر در `.env` بات اصلی.
|
||||||
|
* [ ] **گام ۸ (تست سراسری E2E):** ارسال پیام صوتی استعلام قیمت در پیامرسان بله و مشاهده لاگهای `bot.stt` مبنی بر رونویسی موفق کاندید بله و اجماع در مدل DeepSeek NLU.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
|||||||
|
# گزارش جامع: ارزیابی پروژههای متنباز و سرویسهای شخص ثالث، و ترکیب بهینه برای کرالر STT بله
|
||||||
|
|
||||||
|
> **تاریخ:** ۱۶ مهر ۱۴۰۵ (8 Oct 2026)
|
||||||
|
> **ورودیها:** دو گزارش مدیر پروژه
|
||||||
|
> - **گزارش A:** «ارزیابی تحلیلی راهکارهای استخراج صوتبهمتن در بله: سرویسهای شخص ثالث، کتابخانهها، پروژههای متنباز و جایگزینها»
|
||||||
|
> - **گزارش B:** `bale_stt_executive_roadmap_report3.md` (سند عملیاتی برای مدیر پروژه و ایجنت مجری)
|
||||||
|
>
|
||||||
|
> **دامنه (طبق درخواست):** حریم خصوصی و امنیت در این گزارش بررسی نشده و بعداً تصمیمگیری میشود. فقط فهرست کامل راهکارها، ارزیابی، الگوبرداری از متنباز و بهترین ترکیب ارائه شده است.
|
||||||
|
>
|
||||||
|
> **روش کار:** هر دو گزارش را خواندم و ادعاهای قابل راستیآزمایی را با منابع عمومی (PyPI، مستندات رسمی بله، arXiv، Hugging Face، GitHub) تطبیق دادم. هر ادعا یکی از سه وضعیت دارد:
|
||||||
|
> ✅ **تأیید شد** | 🟡 **تأییدنشده / فقط استنباط** | ❌ **با شواهد در تناقض است**
|
||||||
|
>
|
||||||
|
> **محدودیت مهم:** شبکه sandbox من بسته است؛ پس نتوانستم پکیجها را نصب کنم یا پروتوها را بخوانم. هر جا بگویم «تأییدنشده»، یعنی در منابع عمومی قابل مشاهده برای من نبود، نه لزوماً اینکه غلط باشد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. خلاصه اجرایی
|
||||||
|
|
||||||
|
### پاسخ کوتاه به چهار سؤال شما
|
||||||
|
|
||||||
|
| سؤال | پاسخ کوتاه |
|
||||||
|
|---|---|
|
||||||
|
| **نظر درباره گزارش A (متنباز و شخص ثالث)** | تحلیل خوبی است و دستهبندی ۸ راهکار درست است. ولی چند ادعای مهم آن تأییدنشده یا غلط است: نام و وضعیت پکیج `aiobale-py`، وجود متد `GetTranscript` (فقط ادعا شده، من نتوانستم ببینم)، و برتری «مدل متنباز» بر سرویسهای تجاری. دادههای مستقل خلاف این برتری را نشان میدهد (بخش ۶). |
|
||||||
|
| **آیا دیگران همین مشکل را داشتهاند و چطور حل کردهاند؟** | **هیچ پروژه عمومیای که دقیقاً خروجی STT خود بله را استخراج کند پیدا نکردم.** همان الگوهای نزدیک وجود دارد: کلاینتهای کاربری مهندسیمعکوسشده (`aiobale`، `bale-sdk`، فورکها)، سلفباتها و اسکرپرهای مبتنی بر Selenium/Playwright، و کسبوکارهایی که سراغ ASR مستقل رفتهاند (بخش ۳). |
|
||||||
|
| **اگر الگوبرداری یا شخص ثالث بخواهیم، چه کنیم؟** | از متنباز، لایههای انتقال و نشست و ابزار دیکود پروتوباف را بردارید، نه خود «ترنسکرایب» را. از شخص ثالث، چند ASR را بهعنوان کاندید موازی کنار بله بگذارید (بخش ۵ و ۶). |
|
||||||
|
| **بهترین ترکیب؟** | **«بله + یک ASR تجاری فارسی + یک مدل خودمیزبان با واژگان تخصصی + LLM اجماعگیر با کاتالوگ کالا»**، با کرالر بله بهصورت adapter قابلسوییچ. جزئیات در بخش ۷. |
|
||||||
|
|
||||||
|
### سه یافتهای که تصمیم شما را تغییر میدهد
|
||||||
|
|
||||||
|
1. **«نیازی به آپلود ویس نیست.»** بات خودتان با `sendVoice(file_id)` میتواند همان ویس کاربر را برای حساب کرالر بفرستد. اینطور فایل را کرالر دانلود و آپلود نمیکند. این طراحی از گزارش B سادهتر و کمریسکتر است (بخش ۷.۲).
|
||||||
|
2. **داده بنچمارک مستقل نشان میدهد ASR تجاری فارسی حدود ۱۴ امتیاز WER از بهترین متنباز جلوتر است** (مقاله PSRB، بخش ۶.۲). این با ادعای گزارش A که «متنباز پایدارترین راهکار» است، همخوان نیست. البته روی داده عمومی بوده، نه واژگان تدارکات شما.
|
||||||
|
3. **کد مرجع گزارش B شبهکد است، نه کد اجراشدنی.** توابعی مثل `client.send_voice` و `client.raw_call` در مستندات عمومی `bale-sdk` وجود ندارند (بخش ۲.۲).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. ارزیابی دو گزارش مدیر پروژه
|
||||||
|
|
||||||
|
### ۲.۱ گزارش A: متنباز و شخص ثالث
|
||||||
|
|
||||||
|
**نقاط قوت**
|
||||||
|
- پوشش گسترده: ۸ راهکار در یک ماتریس مقایسه (تأخیر، رم، خلوص متن، پایداری، هزینه).
|
||||||
|
- تفکیک درست «چه چیزی را از بله بگیریم» و «چه چیزی را مستقل بسازیم».
|
||||||
|
- پیشنهاد معماری دومسیره (بله + مسیر مستقل) درست و عملی است.
|
||||||
|
- معرفی `bale-sdk` و توجه به مجوز اختصاصی آن (نکتهای که گزارشهای قبلی کمرنگ داشتند).
|
||||||
|
- اشاره درست به اینکه Playwright روی وب بله الگوی جاافتادهای است.
|
||||||
|
|
||||||
|
**نقاط ضعف**
|
||||||
|
- چند ادعای کلیدی بدون منبع است و در بررسی من تأیید نشد (جدول زیر).
|
||||||
|
- مقایسه دقت متنباز با تجاری فقط بر اساس WER خودگزارشی مدلها روی Common Voice است. این عدد روی ویسهای واقعی و محاورهای معنی ندارد.
|
||||||
|
- ارائهدهندگان تجاری مهم فارسی (Avanegar/Partai، Aipa) و Google Chirp 2 اصلاً در گزارش نیستند.
|
||||||
|
- تحلیل خطای شخص ثالث ناقص است (مثلاً قیمتها بدون تاریخ و منبع).
|
||||||
|
|
||||||
|
### جدول راستیآزمایی ادعاهای گزارش A
|
||||||
|
|
||||||
|
| ادعا | وضعیت | یافته من |
|
||||||
|
|---|---|---|
|
||||||
|
| Bot API رسمی STT ندارد | ✅ | مستندات رسمی `docs.bale.ai`: شیء `Voice` فقط `file_id` و `file_unique_id` دارد و هیچ متدی برای ترنسکریپت نیست. **اصلاح:** گزارش میگوید «حجم و مدتزمان» هم هست؛ در مستندات رسمی نیست (SDKهای شخص ثالث فیلد `duration` را اضافه کردهاند). |
|
||||||
|
| `bale-sdk` از `mtp1376`، نسخه 0.1.1، ۶۰۷ RPC در ۵۲ سرویس، پوشه `proto/` | ✅ | صفحه PyPI (27 Jun 2026) دقیقاً همین را میگوید. |
|
||||||
|
| `bale-sdk` مجوز اختصاصی «All Rights Reserved» دارد | ✅ | تأیید شد. خود README هم میگوید استفاده فقط با حسابها و دادههایی که مجاز هستید و مطابق ToS بله باشد. |
|
||||||
|
| سرویس `bale.turing.v1.AI` و متد `GetTranscript` با فیلدهای ۱ تا ۳ | 🟡 | **نتوانستم از منبع عمومی تأیید کنم.** فایلهای `docs/API_COVERAGE.md` و `docs/API_FA.md` در صفحه PyPI لینک شدهاند ولی برای من باز نشد. این مهمترین ادعای هر دو گزارش است و باید در ۳۰ دقیقه اول خودتان تأیید شود (بخش ۹). |
|
||||||
|
| `aiobale-py` توسط امین مدنی، نسخه 0.3.x، متکی بر `blackboxprotobuf` | 🟡 | `aiobale-py` را پیدا نکردم. آنچه پیدا کردم: `aiobale` (Enalite، MIT، آخرین نسخه 0.1.5 در Aug 2025). توضیح خودش میگوید «از صفر و بدون فایل proto ساخته شده». |
|
||||||
|
| `aiobale` اولیه رها شده | ✅ | آخرین انتشار Aug 2025؛ فورکها (`autobale`، `BaleClient`) میگویند نگهداری نمیشود و باگ دارد. |
|
||||||
|
| `autobale` توسط علی عمادی، نسخه 1.0.0، MIT | ✅ | تأیید شد (مخزن `AliEmadii/autobale`). |
|
||||||
|
| `BaleClient` | — | در گزارش نیست، ولی وجود دارد: فورک دیگری از aiobale (`Foad-KhAb/baleclient`، Python 3.11+). |
|
||||||
|
| `bale-mcp-server` روی `aiobale-py` | 🟡/❌ | پروژهای به نام `bale-mcp` پیدا کردم که **با توکن بات رسمی** و `tapi.bale.ai` کار میکند، نه کلاینت کاربری. شاید پروژه دیگری باشد. |
|
||||||
|
| `bale-chat-exporter` با نشست کلاینت | 🟡 | اسکرپر Playwright + BeautifulSoup برای Bale Web پیدا کردم (خروجی اکسل). همپروژهبودن با آنچه گزارش میگوید تأییدنشده است، ولی **الگوی Playwright روی وب بله اثباتشده است.** |
|
||||||
|
| `Bale-Self-Bot` در GitLab چابکان | 🟡 | آن را پیدا نکردم. ولی `baleself` (PyPI، `zellias/bale-self-bot`) یک سلفبات مبتنی بر **Selenium** است و الگو را تأیید میکند. |
|
||||||
|
| MajidAPI یک پراکسی روی موتورهای عمومی است | 🟡 | endpoint را در منابع قابل جستوجو پیدا نکردم. «پراکسی روی موتور عمومی» استنباط گزارش است، نه چیزی که ثابت شده باشد. هرچه باشد، SLA ندارد. |
|
||||||
|
| فارسآوا (عامراندیش): ۱۰ هزار ساعت داده، نصب On-Premise، ساعتی ۴۵–۶۰ هزار تومان | 🟡 | قیمت و جزئیات را مستقل تأیید نکردم؛ قیمتها زماندارند. قبل از تصمیم، استعلام رسمی بگیرید. |
|
||||||
|
| آیاو تایپ دقت «بالای ۹۸٪» | 🟡 | ادعای بازاریابی است. روی ویس تدارکات خودتان اندازه بگیرید. |
|
||||||
|
| `hezarai/whisper-small-fa` روی CPU زیر ۲ ثانیه | 🟡 | محتمل برای ویس کوتاه، ولی مدل small روی فارسی محاورهای ضعیف است (بخش ۶). |
|
||||||
|
| `nezamisafa/whisper-v3-turbo-persian-v1.0` با WER ۲۹.۹٪ | 🟡 | این مدل را نتوانستم باز کنم. مدل مشابه `vhdm/whisper-v3-turbo-persian-v1.1` WER 14.07٪ میدهد ولی روی **دادگان خودش**؛ اعداد WER بین دادگانهای مختلف قابل مقایسه نیستند. |
|
||||||
|
| Faster-Whisper تا ۴ برابر سریعتر | ✅ (تا حد دانستههای من) | ادعای رسمی خود پروژه است. در مقاله PSRB هم Faster-Whisper هالوسینیشن کمتری از Whisper خام داشت. |
|
||||||
|
| «مدلهای متنباز پایدارترین راهکار فنیاند» | ❌ | با داده PSRB سازگار نیست: ASR تجاری فارسی و Chirp 2 بهطور محسوس دقیقترند (بخش ۶.۲). متنباز «مستقلترین» است، نه لزوماً «دقیقترین». |
|
||||||
|
|
||||||
|
### ۲.۲ گزارش B: نقشه راه اجرایی
|
||||||
|
|
||||||
|
**نقاط قوت**
|
||||||
|
- دیاگرامهای توالی و جریان تابآوری خوب و قابلتحویل به مدیر پروژهاند.
|
||||||
|
- اصلاحات قبلی اعمال شده است: `compare_digest`، سقف روزانه، قفل ترتیبی، ۵۰۳ در خطا و متن خالی.
|
||||||
|
- ورودیهای انسانی روشناند و «تأیید دکمه روی وب» بهعنوان ورودی گذاشته شده است.
|
||||||
|
- Rollback با خالیکردن env درست است.
|
||||||
|
|
||||||
|
**ایرادها و ریسکهای اجرایی**
|
||||||
|
|
||||||
|
| مورد | توضیح |
|
||||||
|
|---|---|
|
||||||
|
| **کد مرجع شبهکد است** | `client.send_voice(...)`، `client.raw_call(service=..., method=...)` و `sent_msg.voice.to_media()` در README عمومی `bale-sdk` نیست. جدول متدهای سطحبالای آن ارسال رسانه ندارد. مدل دسترسی RPC خام آن اینطور است: «namespace برابر snake_case بخش آخر نام سرویس». یعنی `bale.messaging.v2.Messaging` → `client.messaging`. پس فراخوانی احتمالی `bale.turing.v1.AI` باید چیزی شبیه `client.ai.GetTranscript(...)` باشد (**استنباط من**). |
|
||||||
|
| **آپلود ویس** | کارِ آپلود فایل و ساخت پیام صوتی (Media) را سند فرض گرفته ولی طراحی نکرده. این بخش از خود RPC ترنسکریپت سختتر است. راه سادهتر در بخش ۷.۲ آمده. |
|
||||||
|
| **پاسخ `GetTranscript`** | طبق خود سند، پاسخ `bool mustWait` و یک `DownloadSource` دارد، نه رشته متن. یعنی احتمالاً متن باید از یک لینک/منبع دانلود خوانده شود. مثال کد این را مدیریت نمیکند. |
|
||||||
|
| **کلید پیشفرض** | `os.environ.get("BALE_STT_INTERNAL_KEY", "bale_internal_secret")` هنوز مقدار پیشفرض دارد؛ بدون env هم بالا میآید. با `os.environ[...]` خطا بدهد. |
|
||||||
|
| **شمارنده روزانه** | متغیر درونحافظه است: با ریست کانتینر صفر میشود و ریست روزانه هم ندارد (پس بعد از رسیدن به سقف تا ریاستارت بعدی میماند). |
|
||||||
|
| **`.env` نمونه** | `API_KEY=bale_internal_secret` یعنی همان مقدار پیشفرض ضعیف. |
|
||||||
|
| **مسیرهای محلی** | لینکها به `file:///c:/Users/Reza/Desktop/...` اشاره دارند. در اشتراک سند باید پاک شوند. |
|
||||||
|
| **«بدون وقفه»** | ادعای «بدون حتی ۱ ثانیه قطعی» فقط وقتی درست است که `stt.py` در مواجهه با ۵۰۳ همان پرووایدر را بیخطا رد کند. این را باید در کد بات بررسی کرد، نه از روی سند. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. آیا دیگران هم همین مشکل را داشتهاند؟ و چطور حل کردهاند؟
|
||||||
|
|
||||||
|
### ۳.۱ نتیجه کلی
|
||||||
|
|
||||||
|
**پروژه عمومیِ مشخصی که خروجی STT خود بله را برای یک بات استخراج کند پیدا نکردم** (گزارش A هم همین را میگوید). آنچه وجود دارد الگوهای مجاور است:
|
||||||
|
|
||||||
|
| الگو | نمونههای تأییدشده | درسی که برای ما دارد |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. بات رسمی + ASR مستقل** | باتهای بله روی Webhook (مثلاً `hamedcode/bale-ai-bot` روی Cloudflare Workers)، باتهای مشتریمداری با LLM | رایجترین راه. بله فقط کانال پیام است؛ صوت دانلود و به موتور دیگر داده میشود. |
|
||||||
|
| **B. کلاینت کاربری مهندسیمعکوسشده** | `aiobale` (MIT)، فورکهای `autobale` و `BaleClient`، `bale-sdk` (اختصاصی، ۶۰۷ RPC) | نشان میدهد نگهداری نشست کاربری و تماس با RPCهای داخلی شدنی است، ولی پروژهها تازهاند و بعضی رها میشوند. |
|
||||||
|
| **C. اتوماسیون مرورگر** | `baleself` (Selenium)، اسکرپر چت Bale Web با Playwright | روی وب بله جاافتاده است، ولی معمولاً برای کارهای ساده مثل پاسخ خودکار و بکآپ. |
|
||||||
|
| **D. سوءاستفاده خلاقانه از زیرساخت** | `BaleVPN` (تونل روی تماس صوتی بله) | نشان میدهد جامعه تا کجا از لایههای داخلی استفاده میکند؛ برای STT به کار ما نمیآید. |
|
||||||
|
| **E. ابزارهای مدیریتی روی Bot API** | `bale-mcp`، SDKهای PHP/Node/Python/Swift/C# | فقط Bot API؛ بدون هیچ راهی برای رسیدن به ترنسکریپت. |
|
||||||
|
|
||||||
|
### ۳.۲ برداشت از همین شواهد
|
||||||
|
|
||||||
|
1. **راه مشترک جامعه این است که STT را بیرون از بله انجام میدهند.** هیچکس STT بله را «کرال» نکرده یا اگر کرده منتشر نشده است. پس در هر حال ریسک بالاتر و شواهد کمتری برای مسیر شماست.
|
||||||
|
2. **زیرساخت لازم برای کرالر وجود دارد** (انتقال WebSocket، OTP، نشست، ارسال و دانلود فایل)؛ پس این پروژه «ساخت از صفر» نیست.
|
||||||
|
3. **پایداری کتابخانهها ضعیف است** (aiobale رها شد، `bale-sdk` نسخه 0.1.1 است). هر معماری باید فرض کند اجزای متنباز ممکن است از کار بیفتند.
|
||||||
|
4. **تنها چیزی که کل شرطبندی را توجیه میکند، دقت بله روی ویسهای شماست.** بدون آزمون دستی (بخش ۸، گام ۰) نباید ساخت شروع شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. کاتالوگ کامل راهکارها
|
||||||
|
|
||||||
|
برای هر راهکار: چه کار میکند، مزیت، عیب، تلاش (تخمین من)، وضعیت تأیید.
|
||||||
|
|
||||||
|
### گروه ۱: استفاده از STT خود بله
|
||||||
|
|
||||||
|
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **S1** | **مسیر رسمی/تجاری با بله** | ایمیل یا تماس با تیم توسعهدهندگان/بیزینس بله برای API رسمی STT یا قرارداد | پایدار، بدون ریسک بن | احتمال موافقت کم؛ زمانبر | ۱ ایمیل | هیچ مستند عمومیای برای وجود چنین API نیست |
|
||||||
|
| **S2** | **RPC مستقیم با `bale-sdk`** | استفاده از پروتوها و کلاینت آماده، فراخوانی `GetTranscript` | سریعترین مسیر فنی؛ پروتو آماده | **مجوز اختصاصی**؛ نسخه 0.1.1؛ ادعای RPC تأییدنشده | ۱–۲ روز | 🟡 |
|
||||||
|
| **S3** | **RPC مستقیم با `aiobale` / `autobale` / `BaleClient` (MIT) + پروتوی خودساخته** | انتقال و نشست و آپلود از کتابخانه MIT؛ تعریف `GetTranscript` را از کپچر ترافیک خودتان در `web.bale.ai` بسازید | بدون مشکل مجوز؛ قابلنگهداری | کشف RPC و تعریف پروتو کار میبرد؛ کتابخانهها کمنگهداریاند | ۳–۵ روز | ✅ ابزارها؛ 🟡 RPC |
|
||||||
|
| **S4** | **Playwright روی `web.bale.ai`** | کرومیوم headless با پروفایل پایدار؛ ارسال ویس، کلیک دکمه، خواندن DOM | مقاوم در برابر تغییر پروتکل؛ الگوی اثباتشده | ۳۵۰–۵۰۰MB رم؛ شکننده در برابر تغییر UI؛ وابسته به وجود دکمه در وب | ۱–۲ روز | ✅ الگو؛ 🟡 دکمه وب |
|
||||||
|
| **S5** | **ترکیب «مرورگر برای ورود، پروتکل برای کار»** | Playwright فقط ورود/تمدید توکن (JWT) را انجام میدهد؛ کار اصلی را کلاینت WebSocket سبک میکند | از سبکبودن S3 و پایداری ورود S4 بهره میبرد | پیچیدگی دو لایه | ۴–۶ روز | پیشنهاد من |
|
||||||
|
| **S6** | **شبیهساز اندروید + uiautomator** | اپ بله در شبیهساز، خواندن متن از رابط | مستقل از وب | رم >2GB؛ شکننده؛ سختترین نگهداری | ۵+ روز | آخرین چاره |
|
||||||
|
| **S7** | **چت هوش مصنوعی بله (Oracle)** | ارسال ویس به بات هوش مصنوعی بله و گرفتن پاسخ | بدون کشف RPC | خروجی «متن + تفسیر»؛ تأخیر ۵–۱۵ ثانیه؛ اینکه ویس را پیاده میکند هم تأییدنشده | ۱ روز | 🟡 |
|
||||||
|
|
||||||
|
### گروه ۲: سرویسهای شخص ثالث
|
||||||
|
|
||||||
|
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **T1** | **پراکسیهای غیررسمی (MajidAPI و مشابه)** | فراخوانی REST با لینک ویس | چند ساعت | بدون SLA؛ موتور نامشخص؛ ورودی URL (ویس باید لینک عمومی شود) | ساعتی | 🟡 |
|
||||||
|
| **T2** | **ASR تجاری فارسی** | **Avanegar/Partai** (API: `api.ivira.ai/partai/speechRecognition`)، **Aipa**، عامراندیش (فارسآوا)، IOType | دقت بالا روی فارسی؛ پشتیبانی؛ گاهی On-Premise | هزینه ریالی؛ قرارداد | ۱ روز (adapter) | ✅ Avanegar/Aipa در بنچمارک؛ 🟡 قیمتها |
|
||||||
|
| **T3** | **ASR ابری بینالمللی** | Google **Chirp 2**، Azure Speech، OpenAI Whisper API، Groq Whisper | Chirp 2 تقریباً همسطح بهترین تجاری فارسی | هزینه ارزی؛ محدودیت دسترسی؛ بستهبه شرایط شبکه | ۱ روز | ✅ Chirp/Azure در بنچمارک |
|
||||||
|
|
||||||
|
### گروه ۳: مدلهای متنباز خودمیزبان
|
||||||
|
|
||||||
|
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **O1** | **Faster-Whisper large-v3 (+ `initial_prompt`/hotwords واژگان تدارکات)** | سرور OpenAI-compatible داخلی | مستقل؛ قابلشخصیسازی با واژگان؛ بهترین متنباز در بنچمارک | WER ≈ 34٪ روی فارسی واقعی؛ نیاز به GPU برای سرعت | ۱ روز | ✅ |
|
||||||
|
| **O2** | **Whisper large-v3-turbo فاینتیون فارسی** (مثل `vhdm/whisper-v3-turbo-persian-v1.1`) | مدل فاینتیونشده روی دادگان فارسی | سریعتر؛ احتمالاً دقیقتر روی گفتار تمیز | ارقام خودگزارشی و روی دادگان متفاوت؛ باید روی داده شما سنجید | ۱ روز | 🟡 |
|
||||||
|
| **O3** | **NeMo FastConformer فارسی (NVIDIA)** | `stt_fa_fastconformer_hybrid_large` | مدل مستقل از Whisper؛ دیکودر CTC | در بنچمارک WER ≈ 45٪ (ضعیفتر از Whisper) | ۲ روز | ✅ |
|
||||||
|
| **O4** | **Vosk فارسی (`vosk-model-fa-0.5`)** | Kaldi/DNN-HMM سبک، CPU | بسیار سبک؛ آفلاین | WER ≈ 45٪ | ساعتی | ✅ |
|
||||||
|
| **O5** | **SLPL wav2vec2 فارسی (دانشگاه شریف)** | XLS-R فاینتیونشده | مناسب برای تحقیق و ensemble | WER ≈ 47٪ | ۲ روز | ✅ |
|
||||||
|
| **O6** | **Seamless M4T v2** | مدل چندزبانه | — | احتمال بازنویسی/هالوسینیشن (Interpretation Error در مقاله PSRB) | ۲ روز | ✅ مشکل |
|
||||||
|
|
||||||
|
### گروه ۴: لایههای بالاتر (نه جایگزین، بلکه تقویتکننده)
|
||||||
|
|
||||||
|
| # | راهکار | توضیح |
|
||||||
|
|---|---|---|
|
||||||
|
| **L1** | **LLM اجماعگیر با کاتالوگ** | کاندیدها + فهرست کالا/برند/واحد دیتابیس تدارکات را به LLM بدهید تا خروجی ساختاریافته و تصحیحشده بسازد. (مقاله PSRB هم میگوید LLMها خطاهای جزئی ASR را خوب اصلاح میکنند.) |
|
||||||
|
| **L2** | **نرمالسازی فارسی** | یکسانسازی «ی/ک»، ارقام، نیمفاصله (ZWNJ) و اعداد حرفی. PSRB نشان میدهد مرزبندی کلمه و نیمفاصله WER را بالا میبرد. |
|
||||||
|
| **L3** | **فاینتیون روی داده خودتان** | ویسهای واقعی + متن نهایی تأییدشده توسط کاربر → LoRA روی Whisper. بلندمدتترین سرمایهگذاری برای استقلال از بله. |
|
||||||
|
| **L4** | **مسیریابی بر اساس اعتماد** | اگر کاندیدها در اعداد/واحدها اختلاف دارند، از کاربر تأیید بگیرید (UX)، نه حدس بزنید. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. اگر از پروژههای متنباز الگوبرداری کنیم: چه چیزی را میتوان برداشت؟
|
||||||
|
|
||||||
|
| مؤلفه | منبع | مجوز | چه چیزی بردارید | ملاحظات |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| انتقال WebSocket، ورود با OTP، ذخیره نشست، Dispatcher | `aiobale` / `autobale` / `BaleClient` | MIT ✅ | همه چیز؛ حتی میتوانید فورک کنید | `aiobale` رها شده؛ `autobale` فعالتر ادعا میشود. نسخه را قفل کنید |
|
||||||
|
| ارسال/دانلود فایل و رسانه | همان کتابخانهها (گزارش A: حدود ۸۰ متد) | MIT | آپلود و ساخت پیام صوتی | اگر ایده بخش ۷.۲ را بپذیرید (بات `sendVoice` بفرستد) اصلاً لازم نیست |
|
||||||
|
| **تعریف RPC و پروتوها** | `bale-sdk` (`proto/`) | **اختصاصی ❌** | **فقط بهعنوان مرجع و با اجازه نویسنده**؛ کپی مستقیم ممنوع | راه تمیز: از کپی خود کپچر `web.bale.ai` پروتو بسازید. ابزارها: `bbpb`/`blackboxprotobuf`، `protoscope`، `protoc --decode_raw` |
|
||||||
|
| دیکود بدون `.proto` | `blackboxprotobuf` (NCC Group؛ پکیج رسمی PyPI با نام `bbpb`) | MIT | تحلیل payloadهای ناشناخته | wire format خودتوصیف نیست؛ نام فیلد را باید خودتان حدس بزنید |
|
||||||
|
| الگوی DOM و صفحهخوانی بله | اسکرپر Playwright چت (GitHub) و `baleself` (Selenium) | بررسی مجوز هر مخزن | سلکتورها، انتظار برای بارگذاری، اسکرول | سلکتورها با هر آپدیت UI میشکنند |
|
||||||
|
| سرور OpenAI-compatible برای Whisper | Faster-Whisper، پروژههای سرور مبتنی بر آن | معمولاً MIT/Apache | سرور داخلی با همان `/v1/audio/transcriptions` | همان قرارداد adapter بات؛ تغییر کد لازم نیست |
|
||||||
|
| نرمالسازی و ارزیابی فارسی | مقاله و دادگان PSRB (`PartAI/PSRB` در Hugging Face)، `hazm` | بررسی | معیار SW-WER، فهرست خطاهای رایج فارسی | دادگان عمومی فقط زیرمجموعه است |
|
||||||
|
|
||||||
|
### قواعد پیشنهادی الگوبرداری
|
||||||
|
1. **تمیزکاری حقوقی:** منطق را از متنباز MIT بردارید و تعریف RPC را از کپچر خودتان تولید کنید (clean-room).
|
||||||
|
2. **لایهبندی:** adapter فقط قرارداد OpenAI را بشناسد؛ هیچ import از `bale*` بیرون از ماژول `driver/` نباشد.
|
||||||
|
3. **قفل نسخه و آینه:** نسخه دقیق کتابخانهها را pin و کپی داخلی نگه دارید (رهاشدن مخزن، مثل `aiobale`، اتفاق افتاده است).
|
||||||
|
4. **تست قرارداد:** با یک «ویس طلایی» شبانه بررسی کنید پاسخ هنوز ساختار قبلی را دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. اگر شخص ثالث بخواهیم: چه کار کنیم؟
|
||||||
|
|
||||||
|
### ۶.۱ سادهترین کار: هر سرویس یک «پرووایدر» در `stt.py`
|
||||||
|
|
||||||
|
بات شما اکنون پرووایدرهای شمارهدار با قرارداد OpenAI دارد. پس هر شخص ثالث را یا مستقیم (اگر OpenAI-compatible است) یا با یک adapter کوچک اضافه کنید. مزیت: چند ASR موازی، اجماع LLM، و حذف خودکار پرووایدر خراب (۵۰۳).
|
||||||
|
|
||||||
|
### ۶.۲ داده مستقل برای انتخاب: بنچمارک PSRB (مقاله arXiv:2505.21230)
|
||||||
|
|
||||||
|
مقاله «PSRB: A Comprehensive Benchmark for Evaluating Persian ASR Systems» (مرکز تحقیقات Part AI، May 2025) ده سیستم را روی گفتار واقعی (۵۸٪ خودانگیخته، ۷۱٪ غیررسمی، ۵۳٪ نویزی/تلفنی) سنجیده است.
|
||||||
|
|
||||||
|
| مدل | نوع | CER | WER | SW-WER |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Avanegar** | تجاری فارسی | **8.75** | **19.30** | **15.68** |
|
||||||
|
| **Google Chirp v2** | ابری چندزبانه | 9.05 | 19.92 | 15.99 |
|
||||||
|
| **Aipa** | تجاری فارسی | 10.43 | 24.64 | 19.83 |
|
||||||
|
| Faster-Whisper large-v3 | متنباز | 13.72 | 33.93 | 26.77 |
|
||||||
|
| Azure | ابری چندزبانه | 15.78 | 33.94 | 28.06 |
|
||||||
|
| Seamless M4T v2 | متنباز | 22.30 | 38.85 | 34.76 |
|
||||||
|
| Whisper large-v3 | متنباز | 18.92 | 41.49 | 36.97 |
|
||||||
|
| Vosk fa 0.5 | متنباز | 23.96 | 44.62 | 39.41 |
|
||||||
|
| NeMo FastConformer fa | متنباز | 19.42 | 44.85 | 37.58 |
|
||||||
|
| SLPL wav2vec2 | متنباز | 19.06 | 46.74 | 39.16 |
|
||||||
|
|
||||||
|
**نکات خواندن این جدول:**
|
||||||
|
- **ASR تجاری فارسی (و Chirp 2) تقریباً ۱۴ امتیاز WER از بهترین متنباز جلوتر است.** این یعنی ادعای «متنباز پایدارترین راهکار» در گزارش A دقیق نیست. متنباز مستقلتر است، نه دقیقتر.
|
||||||
|
- **سوگیری احتمالی:** نویسندگان از Part AI هستند و از آدرس API در پاورقی مقاله، Avanegar ظاهراً محصول همان شرکت است. پس عدد Avanegar را «بالاترین حد خوشبینانه» بدانید.
|
||||||
|
- **دادهها عمومیاند، نه واژگان تدارکات.** نام کالا، برند، واحد و اعداد در محاوره سختترین بخشاند و فقط آزمون روی ویسهای خودتان تصمیمگیر است.
|
||||||
|
- مقاله میگوید خطا در **گفتار غیررسمی و لهجههای محلی** (مثلاً مشهدی، یزدی) بیشتر است و **Whisper/Seamless هالوسینیشن و بازنویسی** دارند. هر دو برای متن تدارکات خطرناکاند، چون عدد یا واحد اشتباه معنی سفارش را عوض میکند.
|
||||||
|
- ترکیب «مرتبه اول: دو سیستم متفاوت» معمولاً از «یک سیستم قوی» پایدارتر است. اجماع LLM هم برای همین است.
|
||||||
|
|
||||||
|
### ۶.۳ ارزیابی شخص ثالث: روش پیشنهادی (بدون وابستگی به ادعاهای فروشنده)
|
||||||
|
|
||||||
|
1. **مجموعه طلایی:** ۱۰۰ ویس واقعی (حداقل ۳۰) با رونوشت انسانی و جدول کالا/تعداد/واحد.
|
||||||
|
2. **اجرای همه کاندیدها** روی همان ویسها: بله (دستی در گام ۰)، Avanegar، Aipa، Chirp 2، Faster-Whisper، مدل فاینتیون فارسی.
|
||||||
|
3. **سه معیار:** CER (برای فارسی مناسبتر از WER)، WER و مهمتر **F1 استخراج اقلام** (نام کالا، تعداد، واحد).
|
||||||
|
4. **انتخاب بر اساس «بهبود اجماع»،** نه دقت تکی: هر کاندید را به مجموعه فعلی اضافه کنید و ببینید خطای نهایی استخراج اقلام چقدر کم میشود.
|
||||||
|
5. **هزینه:** هزینه به ازای هر ویس ۱۰–۳۰ثانیهای در ماه برای حجم واقعی شما.
|
||||||
|
|
||||||
|
### ۶.۴ چه سرویسی را کجا بگذاریم؟ (پیشنهاد مقدماتی)
|
||||||
|
|
||||||
|
| نقش | کاندید | چرا |
|
||||||
|
|---|---|---|
|
||||||
|
| پرووایدر ۱ (تجاری فارسی) | Avanegar یا Aipa | بهترین در بنچمارک فارسی و لهجهها |
|
||||||
|
| پرووایدر ۲ (ابری) | Chirp 2 (اگر دسترسی ممکن است) | تقریباً همسطح؛ مکمل متفاوت |
|
||||||
|
| پرووایدر ۳ (خودمیزبان) | Faster-Whisper large-v3 + واژگان تدارکات | مستقل؛ قابل بهبود با داده خودتان |
|
||||||
|
| پرووایدر ۴ (بله) | کرالر بله (S3/S4/S5) | تنها کاندیدی که تفاوت واقعی (آموزشدیده روی دادههای متفاوت) دارد؛ ارزش آن را گام ۰ تعیین میکند |
|
||||||
|
| پشتیبان سبک | Vosk | فقط برای اینکه همیشه یک خروجی داشته باشیم |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. بهترین ترکیب برای کرالر شما
|
||||||
|
|
||||||
|
### ۷.۱ اصول طراحی
|
||||||
|
|
||||||
|
1. **کرالر بله «یک کاندید» است، نه ستون فقرات.** اگر خراب شد، سیستم باید بدون آن ادامه دهد.
|
||||||
|
2. **حداقل دو کاندید غیر بله همیشه فعال** باشند تا اجماع بدون بله هم معنی داشته باشد.
|
||||||
|
3. **تصمیم بر اساس داده خودتان**، نه ادعای مقاله و فروشنده.
|
||||||
|
4. **هر چیز بله پشت یک قرارداد ثابت (OpenAI-compatible)** باشد تا موتور داخلی عوض شود و بات دست نخورد.
|
||||||
|
5. **داده را جمع کنید** تا در بلندمدت از بله مستقل شوید (L3).
|
||||||
|
|
||||||
|
### ۷.۲ معماری پیشنهادی
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
U[کاربر: ویس] --> BOT[بات تدارکات - Bot API رسمی]
|
||||||
|
BOT --> PRE[پیشپردازش: 16kHz mono، VAD، مدت]
|
||||||
|
|
||||||
|
PRE --> P1[پرووایدر 1: ASR تجاری فارسی]
|
||||||
|
PRE --> P2[پرووایدر 2: Chirp 2 / ابری]
|
||||||
|
PRE --> P3[پرووایدر 3: Faster-Whisper + واژگان]
|
||||||
|
PRE --> RELAY[رله ویس به حساب کرالر: sendVoice با file_id]
|
||||||
|
|
||||||
|
RELAY --> CR[کرالر بله: adapter]
|
||||||
|
CR -->|انتخاب موتور با env| E1[موتور RPC: GetTranscript]
|
||||||
|
CR -->|فالبک| E2[موتور Playwright]
|
||||||
|
CR --> DEL[حذف پیام رله]
|
||||||
|
|
||||||
|
P1 --> NORM[نرمالسازی فارسی: ZWNJ، ارقام، یی/کاف]
|
||||||
|
P2 --> NORM
|
||||||
|
P3 --> NORM
|
||||||
|
E1 --> NORM
|
||||||
|
E2 --> NORM
|
||||||
|
|
||||||
|
NORM --> LLM[LLM اجماعگیر + کاتالوگ کالا/برند/واحد]
|
||||||
|
LLM --> CONF{اختلاف در عدد/واحد؟}
|
||||||
|
CONF -- خیر --> OUT[ثبت استعلام]
|
||||||
|
CONF -- بله --> ASK[از کاربر تأیید بگیر]
|
||||||
|
ASK --> OUT
|
||||||
|
OUT --> DATA[(ذخیره ویس + متن تأییدشده برای فاینتیون)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۷.۳ ایده کلیدی: «رله» بهجای آپلود
|
||||||
|
|
||||||
|
گزارش B فرض میکند کرالر ویس را به **Saved Messages** خودش آپلود میکند. راه سادهتر و سالمتر:
|
||||||
|
|
||||||
|
1. حساب کرالر یکبار به بات شما `/start` میزند (گفتگوی خصوصی بات↔کرالر).
|
||||||
|
2. بات با Bot API رسمی `sendVoice(chat_id=<id حساب کرالر>, voice=<file_id همان ویس کاربر>)` میفرستد. طبق مستندات رسمی، ارسال با `file_id` بدون آپلود مجدد و بدون محدودیت است. (`file_id` مخصوص همان بات است؛ اینجا همان بات میفرستد، پس مشکلی نیست.)
|
||||||
|
3. حساب کرالر (کلاینت کاربری) آپدیت پیام جدید را میگیرد؛ **شیء Media ویس، `OutPeer` و `message_id` همه همانجاست.** دقیقاً سه ورودی `GetTranscript` (Media voice، OutPeer، MessageId).
|
||||||
|
4. کرالر `GetTranscript` را صدا میزند، متن را میگیرد.
|
||||||
|
5. پیام با `deleteMessage` رسمی بات (برای پیام خروجی در گفتگوی خصوصی مجاز است، تا ۴۸ ساعت) یا از سمت کرالر حذف میشود.
|
||||||
|
|
||||||
|
**مزایا:** بدون RPC آپلود؛ بدون ساخت `Media` از صفر؛ بدون الگوی شبهانسانی «آپلود فایل» روی حساب؛ ID پیام و peer واقعیاند.
|
||||||
|
**ریسک:** اینکه RPC روی ویس **دریافتی** هم کار کند (در اپ، دکمه روی ویس دریافتی هم هست؛ ولی تأییدش با آزمون واقعی لازم است).
|
||||||
|
|
||||||
|
### ۷.۴ لایهبندی موتور کرالر (به ترتیب اولویت)
|
||||||
|
|
||||||
|
| اولویت | موتور | شرط | یادداشت |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ۱ | **E1: RPC مستقیم** (transport از `aiobale`/`autobale`، پروتوی خودساخته) | RPC تأیید شد | سبک (<۲۰MB)، متن خالص |
|
||||||
|
| ۲ | **E1+: ورود با مرورگر، کار با پروتکل (S5)** | ورود/تمدید JWT ناپایدار باشد | Playwright فقط برای توکن |
|
||||||
|
| ۳ | **E2: Playwright کامل** | RPC تأیید نشد ولی دکمه وب هست | رم ۳۵۰–۵۰۰MB |
|
||||||
|
| ۴ | شبیهساز اندروید | هیچکدام ممکن نشد | فقط در صورت ارزش بالای بله |
|
||||||
|
|
||||||
|
اگر RPC در باندل وب بله وجود دارد، **به احتمال زیاد دکمه هم در وب هست** (استنباط من: گزارشها میگویند پروتوها از کلاینت وب بازیابی شدهاند). ولی تا خودتان نبینید فرض نکنید.
|
||||||
|
|
||||||
|
### ۷.۵ تقویت اجماع (جایی که بیشترین دقت را میگیرید)
|
||||||
|
|
||||||
|
| اقدام | اثر مورد انتظار | هزینه |
|
||||||
|
|---|---|---|
|
||||||
|
| تزریق **کاتالوگ کالا/برند/واحد** به LLM اجماعگیر | بیشترین کاهش خطای استخراج اقلام | کم |
|
||||||
|
| `initial_prompt`/hotwords واژگان تدارکات در Faster-Whisper | بهبود نام برندها و اصطلاحها | کم |
|
||||||
|
| نرمالسازی فارسی (ZWNJ، ارقام) پیش از اجماع | کاهش اختلافهای ساختگی بین کاندیدها | کم |
|
||||||
|
| قاعده «اختلاف عدد/واحد ⇒ تأیید از کاربر» | حذف سفارش اشتباه | متوسط (UX) |
|
||||||
|
| ثبت ویس + متن تأییدشده → LoRA روی Whisper | استقلال بلندمدت؛ بهبود روی لهجه و واژگان شما | بالا (ولی ارزشمندترین) |
|
||||||
|
| VAD پیش از ASR | کاهش هالوسینیشن Whisper روی سکوت/نویز (پیشنهاد مقاله PSRB) | کم |
|
||||||
|
|
||||||
|
### ۷.۶ سناریوهای تصمیم
|
||||||
|
|
||||||
|
| اگر… | آنگاه… |
|
||||||
|
|---|---|
|
||||||
|
| گام ۰: بله بهتر از مجموعه فعلی نیست | **کرالر را نسازید.** سرمایه را روی ASR تجاری + فاینتیون بگذارید. |
|
||||||
|
| بله کمی بهتر است (< ۱۰٪) | فقط مسیر سبک E1 را بسازید؛ Playwright و شبیهساز را رها کنید. |
|
||||||
|
| بله بهطور چشمگیر بهتر است (> ۱۵٪) | E1 + فالبک E2 + مسیر رسمی S1 را موازی دنبال کنید. |
|
||||||
|
| RPC پیدا نشد و دکمه وب نیست | S6 فقط اگر ارزش بله زیاد باشد؛ در غیر این صورت منصرف شوید. |
|
||||||
|
| بله خوب است ولی ناپایدار | از خروجی بله بهعنوان **برچسبگذار** برای ساخت دادگان فاینتیون استفاده کنید (L3) تا وابستگی کم شود. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. نقشه راه پیشنهادی
|
||||||
|
|
||||||
|
| روز | کار | خروجی | دروازه |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ۰ | **آزمون دستی دقت:** ۳۰ ویس واقعی را در اپ بله به متن تبدیل کنید و با کاندیدهای فعلی مقایسه کنید | جدول CER/WER و F1 اقلام | G0: بهبود معنیدار یا توقف |
|
||||||
|
| ۰ | جستوجوی `GetTranscript` در `bale-sdk`؛ تأیید دکمه روی `web.bale.ai`؛ ایمیل به بله (S1) | یک صفحه یافته | G1: RPC یا دکمه وجود دارد |
|
||||||
|
| ۱ | کپچر ترافیک وب و ساخت پروتوی خودتان | `req.bin`/`resp.bin`، `findings.md` | G2 |
|
||||||
|
| ۲–۳ | پروتوتایپ E1 با رله بات→کرالر | یک ویس → یک متن | G3 |
|
||||||
|
| ۳–۴ | adapter سختشده (۵۰۳، سقف روزانه پایدار، قفل، لاگ) | اتصال به `stt.py` | — |
|
||||||
|
| ۴–۵ | **موازی:** پرووایدرهای شخص ثالث (Avanegar/Aipa/Chirp، Faster-Whisper) | سنجش روی مجموعه طلایی | G4: بهبود اجماع |
|
||||||
|
| ۶–۱۰ | سواک ۵۰–۱۰۰ ویس + مانیتورینگ + مجموعه طلایی شبانه | گزارش پایداری | G5: استقرار |
|
||||||
|
| ماه ۲+ | جمعآوری داده و LoRA | مدل داخلی | کاهش وابستگی |
|
||||||
|
|
||||||
|
**KPIها:** F1 استخراج اقلام، CER، نرخ اختلاف عدد/واحد، نرخ موفقیت کرالر، p95 تأخیر، هزینه به ازای ویس.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. ضمیمه: دستورها و نمونهها
|
||||||
|
|
||||||
|
### ۹.۱ کشف سریع (۳۰ دقیقه)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .v && . .v/bin/activate
|
||||||
|
pip index versions aiobale-py # آیا واقعاً وجود دارد؟
|
||||||
|
pip index versions aiobale autobale baleclient bale-sdk
|
||||||
|
|
||||||
|
pip download bale-sdk --no-deps --no-binary :all:
|
||||||
|
tar xzf bale_sdk-*.tar.gz
|
||||||
|
grep -rniE "transcri|turing|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
|
||||||
|
```
|
||||||
|
|
||||||
|
در پایتون:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bale import ALL_RPCS
|
||||||
|
for r in ALL_RPCS:
|
||||||
|
s = str(r).lower()
|
||||||
|
if any(k in s for k in ("transcri", "turing", "speech")):
|
||||||
|
print(r)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۹.۲ ساخت پروتو از کپچر خودتان
|
||||||
|
|
||||||
|
1. `web.bale.ai` را با حساب اختصاصی باز کنید، DevTools → Network (فیلتر WS و Fetch).
|
||||||
|
2. روی ویس «تبدیل به متن» را بزنید؛ درخواست/پاسخ را ذخیره کنید.
|
||||||
|
3. پیشوند ۵ بایتی gRPC-Web را بردارید (برای POST gRPC-Web؛ در WebSocket بله قالب را باید ببینید).
|
||||||
|
4. `protoc --decode_raw < req.bin` یا `protoscope`.
|
||||||
|
|
||||||
|
### ۹.۳ اسکلت رله بات→کرالر (شبهکد؛ نام متدها را با کتابخانه واقعی تطبیق دهید)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# سمت بات (Bot API رسمی)
|
||||||
|
async def relay_voice_to_crawler(voice_file_id: str, crawler_user_id: int) -> int:
|
||||||
|
msg = await bot_api("sendVoice", chat_id=crawler_user_id, voice=voice_file_id)
|
||||||
|
return msg["message_id"]
|
||||||
|
|
||||||
|
# سمت کرالر (کلاینت کاربری) - شبهکد
|
||||||
|
@client.on(NewMessage) # فقط از بات خودمان
|
||||||
|
async def on_voice(event):
|
||||||
|
media, peer, mid = event.media, event.peer, event.id
|
||||||
|
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid) # نامها = استنباط
|
||||||
|
while resp.mustWait:
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid)
|
||||||
|
text = await read_download_source(resp.downloadSource)
|
||||||
|
await client.delete_messages(peer, [mid])
|
||||||
|
return text
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۹.۴ قرارداد adapter (بدون تغییر در بات)
|
||||||
|
|
||||||
|
- ورودی: `POST /v1/audio/transcriptions` (multipart: `file`, `model`, `language`).
|
||||||
|
- خروجی موفق: `{"text": "..."}`.
|
||||||
|
- هر خطا یا متن خالی: **HTTP 503** (نه ۲۰۰ با متن خالی).
|
||||||
|
- کلید داخلی: از env الزامی، بدون مقدار پیشفرض؛ مقایسه با `secrets.compare_digest`.
|
||||||
|
- سقف روزانه و صف ترتیبی؛ شمارنده را پایدار (Redis/فایل) و روزانه ریستشونده بگیرید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۰. پرسشهای باز (قبل از شروع ساخت پاسخ دهید)
|
||||||
|
|
||||||
|
1. `GetTranscript` واقعاً در `bale-sdk` وجود دارد؟ (۳۰ دقیقه، بخش ۹.۱)
|
||||||
|
2. دکمه «تبدیل به متن» روی `web.bale.ai` برای ویس **دریافتی** هست؟
|
||||||
|
3. خروجی پاسخ: متن مستقیم است یا `DownloadSource` که باید دانلود شود؟
|
||||||
|
4. `stt.py` با ۵۰۳ و متن خالی چه میکند؟
|
||||||
|
5. حجم واقعی ویسهای شما در روز و طول متوسط آنها چقدر است؟ (برای هزینه و سقف)
|
||||||
|
6. آیا میتوانید ۳۰–۱۰۰ ویس واقعی با رونوشت انسانی تهیه کنید؟ (بدون آن هیچ مقایسهای معتبر نیست.)
|
||||||
|
7. آیا دسترسی شبکه به Chirp 2 / ارائهدهندگان خارجی از سرورهای شما ممکن است؟
|
||||||
|
8. نگهداری: چه کسی مالک کرالر است اگر بله پروتکل را عوض کرد؟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۱. منابع
|
||||||
|
|
||||||
|
- مستندات رسمی بات بله: https://docs.bale.ai/
|
||||||
|
- `bale-sdk` (PyPI): https://pypi.org/project/bale-sdk/ و https://pypi.org/project/bale-sdk/0.1.1/
|
||||||
|
- `aiobale` (PyPI): https://pypi.org/project/aiobale ، مستندات: https://docs.aiobale.ir
|
||||||
|
- `autobale` (PyPI): https://pypi.org/project/autobale/
|
||||||
|
- `BaleClient` (PyPI): https://pypi.org/project/BaleClient/
|
||||||
|
- `baleself` (Selenium): https://pypi.org/project/baleself/
|
||||||
|
- `bale-mcp` (PyPI): https://pypi.org/project/bale-mcp/
|
||||||
|
- بات هوش مصنوعی بله روی Cloudflare Workers: https://github.com/hamedcode/bale-ai-bot
|
||||||
|
- BaleVPN: https://github.com/2077DevWave/BaleVPN
|
||||||
|
- Blackbox Protobuf (NCC Group): https://www.github.com/nccgroup/blackboxprotobuf
|
||||||
|
- PSRB (Persian ASR benchmark): https://arxiv.org/pdf/2505.21230
|
||||||
|
- مدل فارسی Whisper (نمونه): https://huggingface.co/vhdm/whisper-large-fa-v1
|
||||||
|
- خبر انتشار نسخه بله با قابلیت تبدیل ویس به متن (آنا): https://ana.ir/fa/news/994908
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ---- Env / secrets ----
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ---- Python ----
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# ---- OS junk ----
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
|
||||||
|
|
||||||
|
> این سند وضعیت نهایی یکپارچهسازی بات تلگرام/بله (procurement AI agent) با پروژهی ERP را
|
||||||
|
> توضیح میدهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
|
||||||
|
> نکات امنیتی و نحوهی استفاده. تاریخ آخرین بهروزرسانی: برنچ `prod-v7`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. مشکل چی بود؟
|
||||||
|
|
||||||
|
سرویس `bot` در `docker-compose.yml` اینطور تعریف شده بود:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: ./bot # ← پوشهای که در گیت وجود نداشت!
|
||||||
|
volumes:
|
||||||
|
- ./bot/app:/srv/bot/app
|
||||||
|
```
|
||||||
|
|
||||||
|
اما:
|
||||||
|
|
||||||
|
1. **پوشهی `bot/` هیچوقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
|
||||||
|
2. کد ایجنت در پوشهای به نام `ai procurement/` (با فاصله در نام) زندگی میکرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمیکرد.
|
||||||
|
3. روی دستگاه reza.g یک **Windows Junction** (میانبر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره میکرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب میداد.
|
||||||
|
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
|
||||||
|
|
||||||
|
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست میخورد —
|
||||||
|
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
|
||||||
|
|
||||||
|
## ۲. راهحل اعمالشده (راهحل شماره ۲)
|
||||||
|
|
||||||
|
1. Junction ویندوزی `bot` حذف شد (فقط میانبر؛ فایلی از دست نرفت).
|
||||||
|
2. پوشهی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
|
||||||
|
**تاریخچهی گیت هر ۴۱ فایل بهصورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
|
||||||
|
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشهی بات دیگر ignore نشود.
|
||||||
|
(`voice/` سر جایش ماند — نتهای صوتی رuntimes هستند و نباید کامیت شوند.)
|
||||||
|
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار میکند:
|
||||||
|
`context: ./bot` و `./bot/app` دقیقاً به فایلهای کامیتشده اشاره میکنند.
|
||||||
|
5. همهچیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایلها دسترسی داشته باشد.
|
||||||
|
|
||||||
|
### قواعد ignore فعلی (عمدی)
|
||||||
|
|
||||||
|
| مسیر | وضعیت | دلیل |
|
||||||
|
|---|---|---|
|
||||||
|
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکنها — هرگز کامیت نمیشوند |
|
||||||
|
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
|
||||||
|
| `voice/` | ignore (ریشه) | فایلهای صوتی دریافتی از بله در زمان اجرا |
|
||||||
|
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیتشده** | بکاپ تاریخچهی اصلی ایجنت (~۲۰۰KB) |
|
||||||
|
|
||||||
|
## ۳. وضعیت فعلی گیت (تاریخ این سند)
|
||||||
|
|
||||||
|
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` همSHA).
|
||||||
|
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **بهعلاوهی** `bot/`
|
||||||
|
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
|
||||||
|
(اندپوینتهای همگامسازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
|
||||||
|
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
|
||||||
|
تا «feat(bot,wizard): smart message filtering…».
|
||||||
|
- بقیهی برنچهای تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایلهای بات را ندارند** —
|
||||||
|
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
|
||||||
|
|
||||||
|
## ۴. ⚠️ نکتهی امنیتی (مهم — اقدام لازم)
|
||||||
|
|
||||||
|
در همان `docker-compose.yml`، مقادیر **واقعی** بهعنوان پیشفرض env کامیت و پوش شدهاند:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||||
|
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچهی مخزن مشترک است و **هر کسی که به ریپو
|
||||||
|
دسترسی دارد میتواند آنها را ببیند** (`.env` کامیت نشده، ولی این پیشفرضها عملاً همان لو رفتن راز است).
|
||||||
|
|
||||||
|
اقدامات توصیهشده (هنوز انجام نشده — نیاز به تصمیم مالک توکنها):
|
||||||
|
|
||||||
|
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
|
||||||
|
2. مقادیر پیشفرض را از compose حذف کنید و بهجای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
|
||||||
|
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
|
||||||
|
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
|
||||||
|
|
||||||
|
## ۵. نحوهی استفاده
|
||||||
|
|
||||||
|
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
|
||||||
|
cd ERP
|
||||||
|
git checkout prod-v7
|
||||||
|
|
||||||
|
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
|
||||||
|
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
|
||||||
|
# BALE_BOT_TOKEN=... توکن بات از بله
|
||||||
|
# AI_API_KEY=... کلید سرویس LLM
|
||||||
|
# BALE_ADMIN_CHAT_IDS=... چتآیدی مدیران
|
||||||
|
|
||||||
|
# ۲) کل استک + بات:
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
سرویس `bot` (کانتینر `procurement-bot`) بهصورت long-polling به `tapi.bale.ai` وصل میشود،
|
||||||
|
به `api:8000/api/v1` (همان ERP) و پایگاهدادهی `procurement` وصل است و
|
||||||
|
draft های گفتگو را در جدول `bot_drafts` مینویسد.
|
||||||
|
|
||||||
|
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose build bot
|
||||||
|
docker compose up -d bot
|
||||||
|
docker compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
|
||||||
|
|
||||||
|
1. کاربر در بله به بات پیام میدهد (متن یا وویس — وویس با STT به متن تبدیل میشود).
|
||||||
|
2. بات با NLU موجودی/تأمینکننده/کالا را استخراج میکند و یک **draft** میسازد
|
||||||
|
(وضعیت `active` در جدول `bot_drafts`) و کارت پیشنمایش با فیلدهای خالی میفرستد.
|
||||||
|
3. کاربر با دکمهی ✅ تأیید میکند؛ بات draft را به ERP کامیت میکند
|
||||||
|
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
|
||||||
|
4. ویزارد فرانتاند همان draft را از `GET /api/v1/drafts` میخواند و پیشپر میکند —
|
||||||
|
همگامسازی دوطرفه با ذخیرهی خودکار (debounced) بین مرورگر و بات.
|
||||||
|
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی میشود (reply-based edit، بدون کارت تکراری).
|
||||||
|
|
||||||
|
### ۵.۴ تست سریع سلامت
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose ps bot # باید Up باشد
|
||||||
|
docker compose logs bot | Select-Object -Last 20
|
||||||
|
# داخل کانتینر:
|
||||||
|
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۵ مسیرهای مهم
|
||||||
|
|
||||||
|
| مسیر | محتوا |
|
||||||
|
|---|---|
|
||||||
|
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
|
||||||
|
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
|
||||||
|
| `bot/docs/SETUP.md` | راهاندازی جزئیتر ایجنت |
|
||||||
|
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
|
||||||
|
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
|
||||||
|
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچهی مستقل قبلی ایجنت |
|
||||||
|
|
||||||
|
## ۶. نکات باقیمانده برای تیم
|
||||||
|
|
||||||
|
- **ادغام با برنچهای جدید تیم:** فایلهای بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
|
||||||
|
آنها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
|
||||||
|
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
|
||||||
|
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
|
||||||
|
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
|
||||||
|
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
|
||||||
+120
@@ -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
|
||||||
|
```
|
||||||
@@ -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."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""ERP API client — the only way the bot touches procurement data."""
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""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 create_project(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).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 {}
|
||||||
|
|
||||||
|
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 {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""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]}
|
||||||
|
if reply_markup:
|
||||||
|
payload[REPLY_MARKUP] = reply_markup
|
||||||
|
if reply_to:
|
||||||
|
payload["reply_to_message_id"] = reply_to
|
||||||
|
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]}
|
||||||
|
if reply_markup:
|
||||||
|
payload[REPLY_MARKUP] = reply_markup
|
||||||
|
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)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Bot configuration.
|
||||||
|
|
||||||
|
All settings can be overridden with environment variables prefixed with
|
||||||
|
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
|
||||||
|
``.env`` file. Docker/compose passes them in directly (same convention as
|
||||||
|
the api service's ``PROCUREMENT_*`` prefix).
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="PROCUREMENT_BOT_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
|
||||||
|
BALE_API_BASE: str = "https://tapi.bale.ai"
|
||||||
|
BOT_TOKEN: str = "" # from Bale's BotFather
|
||||||
|
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
|
||||||
|
POLL_ERROR_SLEEP_SECONDS: float = 3.0
|
||||||
|
|
||||||
|
# ERP API (in-network service URL inside Docker).
|
||||||
|
API_BASE_URL: str = "http://localhost:8000/api/v1"
|
||||||
|
API_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
# Optional bearer for the day procurement endpoints start enforcing auth.
|
||||||
|
SERVICE_TOKEN: str = ""
|
||||||
|
|
||||||
|
# Frontend URL (for opening Wizard from Bale bot)
|
||||||
|
FRONTEND_URL: str = "http://localhost:8081"
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
|
||||||
|
|
||||||
|
# Persistent storage directory for draft attachments (proformas)
|
||||||
|
ATTACHMENTS_DIR: str = "data/attachments/proformas"
|
||||||
|
|
||||||
|
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
||||||
|
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
||||||
|
AI_API_KEY: str = ""
|
||||||
|
LLM_MODEL: str = "gpt-4o-mini"
|
||||||
|
LLM_TIMEOUT_SECONDS: float = 60.0
|
||||||
|
|
||||||
|
# STT providers — support multiple models/APIs in parallel.
|
||||||
|
# 1. JSON list: [{"name": "m1", "base_url": "...", "api_key": "...", "model": "..."}, ...]
|
||||||
|
STT_PROVIDERS_JSON: str = ""
|
||||||
|
|
||||||
|
# 2. Individual provider variables (up to 3 distinct STT models)
|
||||||
|
STT_PROVIDER_1_NAME: str = "model-1"
|
||||||
|
STT_PROVIDER_1_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_1_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_1_MODEL: str = ""
|
||||||
|
STT_PROVIDER_1_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
STT_PROVIDER_2_NAME: str = "model-2"
|
||||||
|
STT_PROVIDER_2_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_2_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_2_MODEL: str = ""
|
||||||
|
STT_PROVIDER_2_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
STT_PROVIDER_3_NAME: str = "model-3"
|
||||||
|
STT_PROVIDER_3_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_3_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_3_MODEL: str = ""
|
||||||
|
STT_PROVIDER_3_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
# Legacy single STT provider fallback:
|
||||||
|
STT_BASE_URL: str = ""
|
||||||
|
STT_API_KEY: str = ""
|
||||||
|
STT_MODEL: str = "whisper-1"
|
||||||
|
STT_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_base_url(self) -> str:
|
||||||
|
return self.STT_BASE_URL or self.AI_BASE_URL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_api_key(self) -> str:
|
||||||
|
return self.STT_API_KEY or self.AI_API_KEY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_providers(self) -> list[dict]:
|
||||||
|
import json
|
||||||
|
if self.STT_PROVIDERS_JSON:
|
||||||
|
try:
|
||||||
|
data = json.loads(self.STT_PROVIDERS_JSON)
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check explicit STT_PROVIDER_1 / 2 / 3
|
||||||
|
custom_list: list[dict] = []
|
||||||
|
for idx in (1, 2, 3):
|
||||||
|
b_url = getattr(self, f"STT_PROVIDER_{idx}_BASE_URL")
|
||||||
|
key = getattr(self, f"STT_PROVIDER_{idx}_API_KEY")
|
||||||
|
model = getattr(self, f"STT_PROVIDER_{idx}_MODEL")
|
||||||
|
name = getattr(self, f"STT_PROVIDER_{idx}_NAME")
|
||||||
|
lang = getattr(self, f"STT_PROVIDER_{idx}_LANGUAGE") or "fa"
|
||||||
|
if b_url and key:
|
||||||
|
custom_list.append({
|
||||||
|
"name": name or f"model-{idx}",
|
||||||
|
"base_url": b_url,
|
||||||
|
"api_key": key,
|
||||||
|
"model": model or self.STT_MODEL,
|
||||||
|
"language": lang,
|
||||||
|
"timeout": 30.0,
|
||||||
|
})
|
||||||
|
if custom_list:
|
||||||
|
return custom_list
|
||||||
|
|
||||||
|
# Fallback to single legacy STT provider if set
|
||||||
|
if self.stt_base_url and self.stt_api_key:
|
||||||
|
return [{
|
||||||
|
"name": "default",
|
||||||
|
"base_url": self.stt_base_url,
|
||||||
|
"api_key": self.stt_api_key,
|
||||||
|
"model": self.STT_MODEL,
|
||||||
|
"language": self.STT_LANGUAGE,
|
||||||
|
"timeout": 30.0,
|
||||||
|
}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
# PostgreSQL — same instance/database as the api service.
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
||||||
|
|
||||||
|
# Chats that may use the bot without Casdoor linking (comma separated).
|
||||||
|
ADMIN_CHAT_IDS: str = ""
|
||||||
|
|
||||||
|
# Drafts older than this are marked expired by the sweeper loop.
|
||||||
|
DRAFT_TTL_HOURS: int = 24
|
||||||
|
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_chat_ids(self) -> set[int]:
|
||||||
|
out: set[int] = set()
|
||||||
|
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
out.add(int(part))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Centralized market conventions & domain defaults for procurement bot.
|
||||||
|
|
||||||
|
This module isolates all procurement business heuristics and defaults (equal
|
||||||
|
installments calculation, end-of-solar-month due dates, validity rules per
|
||||||
|
material, project assignment logic, official vs unofficial defaults).
|
||||||
|
|
||||||
|
drafts.py and nlu.py consume this module directly.
|
||||||
|
"""
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jalali Calendar math (Pure Python, zero-dependency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
JALALI_MONTH_NAMES = [
|
||||||
|
"", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||||
|
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
|
||||||
|
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
||||||
|
gy2 = gy if gm > 2 else gy - 1
|
||||||
|
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
|
||||||
|
jy = -1595 + (33 * (days // 12053))
|
||||||
|
days %= 12053
|
||||||
|
jy += 4 * (days // 1461)
|
||||||
|
days %= 1461
|
||||||
|
if days > 365:
|
||||||
|
jy += (days - 1) // 365
|
||||||
|
days = (days - 1) % 365
|
||||||
|
if days < 186:
|
||||||
|
jm = 1 + (days // 31)
|
||||||
|
jd = 1 + (days % 31)
|
||||||
|
else:
|
||||||
|
jm = 7 + ((days - 186) // 30)
|
||||||
|
jd = 1 + ((days - 186) % 30)
|
||||||
|
return jy, jm, jd
|
||||||
|
|
||||||
|
|
||||||
|
def j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]:
|
||||||
|
jy2 = jy + 1595
|
||||||
|
days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd
|
||||||
|
if jm < 7:
|
||||||
|
days += (jm - 1) * 31
|
||||||
|
else:
|
||||||
|
days += ((jm - 7) * 30) + 186
|
||||||
|
gy = 400 * (days // 146097)
|
||||||
|
days %= 146097
|
||||||
|
if days > 36524:
|
||||||
|
days -= 1
|
||||||
|
gy += 100 * (days // 36524)
|
||||||
|
days %= 36524
|
||||||
|
if days >= 365:
|
||||||
|
days += 1
|
||||||
|
gy += 4 * (days // 1461)
|
||||||
|
days %= 1461
|
||||||
|
if days > 365:
|
||||||
|
gy += (days - 1) // 365
|
||||||
|
days = (days - 1) % 365
|
||||||
|
gd = days + 1
|
||||||
|
sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
gm = 0
|
||||||
|
while gm < 13 and gd > sal_a[gm]:
|
||||||
|
gd -= sal_a[gm]
|
||||||
|
gm += 1
|
||||||
|
return gy, gm, gd
|
||||||
|
|
||||||
|
|
||||||
|
def is_jalali_leap(jy: int) -> bool:
|
||||||
|
return (((jy + 38) * 31) % 128) <= 31
|
||||||
|
|
||||||
|
|
||||||
|
def jalali_month_days(jy: int, jm: int) -> int:
|
||||||
|
if 1 <= jm <= 6:
|
||||||
|
return 31
|
||||||
|
elif 7 <= jm <= 11:
|
||||||
|
return 30
|
||||||
|
elif jm == 12:
|
||||||
|
return 30 if is_jalali_leap(jy) else 29
|
||||||
|
return 30
|
||||||
|
|
||||||
|
|
||||||
|
def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]:
|
||||||
|
d = ref_date or date.today()
|
||||||
|
return g2j(d.year, d.month, d.day)
|
||||||
|
|
||||||
|
|
||||||
|
def jalali_to_iso(jy: int, jm: int, jd: int) -> str:
|
||||||
|
gy, gm, gd = j2g(jy, jm, jd)
|
||||||
|
return f"{gy:04d}-{gm:02d}-{gd:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def jalali_format(jy: int, jm: int, jd: int) -> str:
|
||||||
|
return f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def jalali_month_name(jm: int) -> str:
|
||||||
|
return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Market Conventions & Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_IS_OFFICIAL = True
|
||||||
|
|
||||||
|
# Standard validity days in volatile markets (materials)
|
||||||
|
DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = {
|
||||||
|
"آهن": 1,
|
||||||
|
"میلگرد": 1,
|
||||||
|
"تیرآهن": 1,
|
||||||
|
"ورق": 1,
|
||||||
|
"پروفیل": 2,
|
||||||
|
"سیمان": 3,
|
||||||
|
"گچ": 7,
|
||||||
|
"لوله": 7,
|
||||||
|
"اتصالات": 7,
|
||||||
|
"شیرآلات": 7,
|
||||||
|
"شن": 7,
|
||||||
|
"ماسه": 7,
|
||||||
|
}
|
||||||
|
FALLBACK_VALIDITY_DAYS: int = 7
|
||||||
|
|
||||||
|
|
||||||
|
def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None,
|
||||||
|
ref_date: date | None = None) -> tuple[str, str]:
|
||||||
|
"""Compute (iso_date, jalali_date_str) based on material or explicit days."""
|
||||||
|
base = ref_date or date.today()
|
||||||
|
if explicit_days is not None and explicit_days > 0:
|
||||||
|
days = explicit_days
|
||||||
|
else:
|
||||||
|
days = FALLBACK_VALIDITY_DAYS
|
||||||
|
if materials:
|
||||||
|
for m in materials:
|
||||||
|
m_str = (m or "").lower()
|
||||||
|
for k, v in DEFAULT_VALIDITY_DAYS_MAP.items():
|
||||||
|
if k in m_str:
|
||||||
|
days = min(days, v)
|
||||||
|
target = base + timedelta(days=days)
|
||||||
|
jy, jm, jd = g2j(target.year, target.month, target.day)
|
||||||
|
return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payment Terms Generators (Heuristics)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_equal_installments(
|
||||||
|
total_amount: float,
|
||||||
|
count: int,
|
||||||
|
ref_date: date | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build equal installments milestones.
|
||||||
|
|
||||||
|
- 1st milestone: today (or ref_date)
|
||||||
|
- 2nd..nth milestones: end of subsequent Jalali months
|
||||||
|
Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone.
|
||||||
|
"""
|
||||||
|
if count < 1:
|
||||||
|
count = 1
|
||||||
|
base = ref_date or date.today()
|
||||||
|
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
|
||||||
|
|
||||||
|
base_per_installment = int(total_amount // count) if total_amount > 0 else 0
|
||||||
|
remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0
|
||||||
|
|
||||||
|
milestones: list[dict] = []
|
||||||
|
|
||||||
|
# Milestone 1: today
|
||||||
|
m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
|
||||||
|
m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
|
||||||
|
milestones.append({
|
||||||
|
"due_date": m1_iso,
|
||||||
|
"due_date_jalali": m1_jalali,
|
||||||
|
"amount": base_per_installment,
|
||||||
|
"note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Milestone 2..N: end of consecutive Jalali months
|
||||||
|
target_jy = curr_jy
|
||||||
|
target_jm = curr_jm
|
||||||
|
|
||||||
|
for i in range(2, count + 1):
|
||||||
|
target_jm += 1
|
||||||
|
if target_jm > 12:
|
||||||
|
target_jm = 1
|
||||||
|
target_jy += 1
|
||||||
|
end_day = jalali_month_days(target_jy, target_jm)
|
||||||
|
iso_str = jalali_to_iso(target_jy, target_jm, end_day)
|
||||||
|
jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}"
|
||||||
|
amt = base_per_installment + (remainder if i == count else 0)
|
||||||
|
m_name = jalali_month_name(target_jm)
|
||||||
|
milestones.append({
|
||||||
|
"due_date": iso_str,
|
||||||
|
"due_date_jalali": jalali_str,
|
||||||
|
"amount": amt,
|
||||||
|
"note": f"قسط {i} (پایان {m_name})",
|
||||||
|
})
|
||||||
|
|
||||||
|
return milestones
|
||||||
|
|
||||||
|
|
||||||
|
def build_cash_today_termsheet(
|
||||||
|
total_amount: float,
|
||||||
|
discount_percent: float = 0.0,
|
||||||
|
ref_date: date | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build cash today payment termsheet."""
|
||||||
|
base = ref_date or date.today()
|
||||||
|
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
|
||||||
|
iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
|
||||||
|
jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
|
||||||
|
|
||||||
|
discount_val = 0.0
|
||||||
|
payable = total_amount
|
||||||
|
if discount_percent > 0 and total_amount > 0:
|
||||||
|
discount_val = round((total_amount * discount_percent) / 100.0)
|
||||||
|
payable = total_amount - discount_val
|
||||||
|
|
||||||
|
disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else ""
|
||||||
|
return {
|
||||||
|
"label": f"پرداخت یکباره امروز{disc_label}",
|
||||||
|
"discount_type": "percent" if discount_percent > 0 else "none",
|
||||||
|
"discount_value": discount_percent if discount_percent > 0 else 0.0,
|
||||||
|
"milestones": [
|
||||||
|
{
|
||||||
|
"due_date": iso_date,
|
||||||
|
"due_date_jalali": jalali_str,
|
||||||
|
"amount": payable,
|
||||||
|
"note": "تسویه نقدی یکباره (امروز)",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]:
|
||||||
|
"""Propose assignment of items to projects when multiple projects exist.
|
||||||
|
|
||||||
|
Returns list of (item_material_name, assigned_project_name).
|
||||||
|
"""
|
||||||
|
clean_projects = [p.strip() for p in project_names if p and p.strip()]
|
||||||
|
if not clean_projects or not items:
|
||||||
|
return []
|
||||||
|
proposals: list[tuple[str, str]] = []
|
||||||
|
p_len = len(clean_projects)
|
||||||
|
for idx, it in enumerate(items):
|
||||||
|
assigned_p = clean_projects[idx % p_len]
|
||||||
|
mat_name = it.get("material_name") or f"قلم {idx + 1}"
|
||||||
|
proposals.append((mat_name, assigned_p))
|
||||||
|
return proposals
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authorized Whitelist Users (Static Role Configuration)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTHORIZED_USERS: dict[int, dict] = {
|
||||||
|
1461166742: {
|
||||||
|
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
|
||||||
|
"display_name": "مدیر تدارکات",
|
||||||
|
"role": "procurement_manager",
|
||||||
|
"is_admin": True,
|
||||||
|
},
|
||||||
|
1839839922: {
|
||||||
|
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
|
||||||
|
"display_name": "همکار / تست",
|
||||||
|
"role": "procurement_manager",
|
||||||
|
"is_admin": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
"""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 import defaults
|
||||||
|
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"
|
||||||
|
KIND_PROJECT = "project"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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_ambiguity_message_id(chat_id: int, message_id: int) -> tuple[BotDraft | None, dict | None]:
|
||||||
|
"""Find the active draft and specific ambiguity dict associated with message_id."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(BotDraft)
|
||||||
|
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
||||||
|
.order_by(BotDraft.updated_at.desc())
|
||||||
|
)
|
||||||
|
drafts = (await session.execute(stmt)).scalars().all()
|
||||||
|
for d in drafts:
|
||||||
|
for amb in (d.payload or {}).get("ambiguities") or []:
|
||||||
|
if amb.get("message_id") == message_id:
|
||||||
|
return d, amb
|
||||||
|
return None, 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": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None,
|
||||||
|
"items": [], "termsheets": [],
|
||||||
|
"options": {}, "proforma_file": None,
|
||||||
|
"ambiguities": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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", defaults.DEFAULT_IS_OFFICIAL))
|
||||||
|
if patch.get("notes"):
|
||||||
|
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
||||||
|
changed = True
|
||||||
|
if patch.get("expiry_days"):
|
||||||
|
m_names = [it.get("material_name") for it in payload.get("items") or [] if it.get("material_name")]
|
||||||
|
iso_val, _ = defaults.compute_validity_date(materials=m_names, explicit_days=int(patch["expiry_days"]))
|
||||||
|
set_field("expiry_date", iso_val)
|
||||||
|
elif 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 raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
||||||
|
continue
|
||||||
|
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", "project_name", "notes"):
|
||||||
|
if raw.get(key):
|
||||||
|
val = str(raw[key]).strip()
|
||||||
|
if target.get(key) != val:
|
||||||
|
target[key] = val
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Calculate total amount of items
|
||||||
|
total_amount = sum(
|
||||||
|
(float(it.get("quantity") or 0) * float(it.get("unit_price") or 0))
|
||||||
|
for it in payload.get("items") or []
|
||||||
|
)
|
||||||
|
|
||||||
|
for raw in patch.get("payment_terms") or []:
|
||||||
|
p_type = raw.get("type") or ""
|
||||||
|
label = (raw.get("label") or "شرایط پرداخت").strip()
|
||||||
|
|
||||||
|
# 1. Cash today with discount
|
||||||
|
if p_type == "cash_today" or (raw.get("is_today") and not raw.get("installments_count")):
|
||||||
|
disc_pct = float(raw.get("discount_percent") or raw.get("discount_value") or 0.0)
|
||||||
|
entry = defaults.build_cash_today_termsheet(total_amount, discount_percent=disc_pct)
|
||||||
|
if label and "یکباره" not in label and "نقد" not in label:
|
||||||
|
entry["label"] = label
|
||||||
|
existing = [t for t in payload["termsheets"] if "نقدی" in t.get("label", "") or "امروز" in t.get("label", "")]
|
||||||
|
if existing:
|
||||||
|
existing[0].update(entry)
|
||||||
|
else:
|
||||||
|
payload["termsheets"].append(entry)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 2. Equal installments -> Generate Ambiguity sub-message and record termsheet
|
||||||
|
elif p_type == "installments" or raw.get("installments_count"):
|
||||||
|
count = int(raw.get("installments_count") or 3)
|
||||||
|
milestones = defaults.build_equal_installments(total_amount, count=count)
|
||||||
|
lines = [f"• {m['note']}: {int(m['amount']):,} تومان" for m in milestones]
|
||||||
|
amb_id = f"terms_{count}_split"
|
||||||
|
|
||||||
|
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
||||||
|
payload["termsheets"].append({
|
||||||
|
"label": label or f"{count} قسط در طول زمان",
|
||||||
|
"discount_type": "none",
|
||||||
|
"discount_value": 0,
|
||||||
|
"milestones": [],
|
||||||
|
"description": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
ambs = payload.setdefault("ambiguities", [])
|
||||||
|
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
|
||||||
|
proposed = {
|
||||||
|
"payment_terms": [{
|
||||||
|
"label": label or f"{count} قسط در طول زمان",
|
||||||
|
"milestones": milestones,
|
||||||
|
"discount_type": "none",
|
||||||
|
"discount_value": 0,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
if existing_amb:
|
||||||
|
existing_amb["details"] = "\n".join(lines)
|
||||||
|
existing_amb["proposed_patch"] = proposed
|
||||||
|
else:
|
||||||
|
ambs.append({
|
||||||
|
"id": amb_id,
|
||||||
|
"kind": "payment_schedule",
|
||||||
|
"title": f"زمانبندی {count} قسط پرداخت",
|
||||||
|
"details": "\n".join(lines),
|
||||||
|
"status": "pending",
|
||||||
|
"proposed_patch": proposed,
|
||||||
|
})
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 3. Explicit milestones from user or general label
|
||||||
|
else:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
existing = [t for t in payload["termsheets"] if normalize_text(t.get("label", "")) == normalize_text(label)]
|
||||||
|
if existing:
|
||||||
|
if milestones:
|
||||||
|
existing[0]["milestones"] = milestones
|
||||||
|
else:
|
||||||
|
payload["termsheets"].append(entry)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 4. Multi-project ambiguity generation
|
||||||
|
proj_names = payload.get("project_names") or []
|
||||||
|
items = payload.get("items") or []
|
||||||
|
if len(proj_names) > 1 and len(items) > 1:
|
||||||
|
unassigned = any(not it.get("project_name") for it in items)
|
||||||
|
if unassigned:
|
||||||
|
proposals = defaults.propose_project_distribution(proj_names, items)
|
||||||
|
if proposals:
|
||||||
|
lines = [f"• {mat} 👈 پروژه {prj}" for mat, prj in proposals]
|
||||||
|
amb_id = "proj_split"
|
||||||
|
ambs = payload.setdefault("ambiguities", [])
|
||||||
|
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
|
||||||
|
proposed = {
|
||||||
|
"items": [{"material_name": mat, "project_name": prj} for mat, prj in proposals]
|
||||||
|
}
|
||||||
|
if existing_amb:
|
||||||
|
existing_amb["details"] = "\n".join(lines)
|
||||||
|
existing_amb["proposed_patch"] = proposed
|
||||||
|
else:
|
||||||
|
ambs.append({
|
||||||
|
"id": amb_id,
|
||||||
|
"kind": "project_assignment",
|
||||||
|
"title": f"تخصیص اقلام به پروژههای {' و '.join(proj_names)}",
|
||||||
|
"details": "\n".join(lines),
|
||||||
|
"status": "pending",
|
||||||
|
"proposed_patch": proposed,
|
||||||
|
})
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def compute_quote_missing(payload: dict) -> list[dict]:
|
||||||
|
"""What is still empty — strictly aligned with DB schema requirements."""
|
||||||
|
out: list[dict] = []
|
||||||
|
|
||||||
|
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
|
||||||
|
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
|
||||||
|
|
||||||
|
# In DB: supplier_id is NOT NULL in quotes table.
|
||||||
|
sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name"))
|
||||||
|
add("supplier", "تأمینکننده", sup_ok, True)
|
||||||
|
|
||||||
|
# In DB: project_id is nullable (optional)
|
||||||
|
has_proj = bool(
|
||||||
|
payload.get("project_id")
|
||||||
|
or payload.get("project_ids")
|
||||||
|
or payload.get("project_name")
|
||||||
|
or payload.get("project_names")
|
||||||
|
)
|
||||||
|
add("project", "پروژه", has_proj, False)
|
||||||
|
|
||||||
|
# In DB: expiry_date is nullable (optional)
|
||||||
|
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}"
|
||||||
|
has_mat = bool(item.get("material_id") or item.get("material_name"))
|
||||||
|
if not has_mat:
|
||||||
|
add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True)
|
||||||
|
|
||||||
|
if not item.get("quantity"):
|
||||||
|
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
|
||||||
|
if not item.get("unit_id") and not 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 new_project_payload() -> dict:
|
||||||
|
return {"name": None, "description": None, "options": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_project_patch(payload: dict, patch: dict) -> bool:
|
||||||
|
src = patch.get("project") 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("description") and payload.get("description") != str(src["description"]).strip():
|
||||||
|
payload["description"] = str(src["description"]).strip()
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def project_missing(payload: dict) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"key": "name", "label": "نام پروژه", "ok": bool(payload.get("name")), "blocking": True},
|
||||||
|
{"key": "description", "label": "توضیحات پروژه", "ok": bool(payload.get("description")), "blocking": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def short_id() -> str:
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""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.storage import save_proforma_file
|
||||||
|
from app.stt import transcribe, transcribe_multi
|
||||||
|
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
||||||
|
|
||||||
|
from . import commands, callbacks, quote, supplier, material, project, 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:
|
||||||
|
user = await _gate(ctx, chat_id, message)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
|
source = "text"
|
||||||
|
voice_transcripts: list[dict] | None = None
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
|
||||||
|
if text.startswith("/"):
|
||||||
|
await commands.handle_command(ctx, chat_id, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
reply_to = message.get("reply_to_message")
|
||||||
|
reply_to_msg_id = reply_to.get("message_id") if reply_to else 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:
|
||||||
|
# Check if replying to an ambiguity message (Requirement 2.b)
|
||||||
|
amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id)
|
||||||
|
if amb_draft is not None and target_amb is not None:
|
||||||
|
await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
input_payload = voice_transcripts if voice_transcripts else text
|
||||||
|
result = await understand(settings, input_payload, draft_summary)
|
||||||
|
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||||
|
consensus_text = result.get("consensus_text") or text
|
||||||
|
|
||||||
|
if source == "voice":
|
||||||
|
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||||
|
|
||||||
|
logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)",
|
||||||
|
chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None)
|
||||||
|
|
||||||
|
if intent in ("register_quote", "update_draft"):
|
||||||
|
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 active.kind == draft_store.KIND_PROJECT:
|
||||||
|
await project.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 active.kind == draft_store.KIND_PROJECT:
|
||||||
|
await project.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 == "add_project":
|
||||||
|
await project.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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UNREGISTERED_FILE = Path(__file__).resolve().parent.parent / "unregistered_users.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _record_unregistered_user(chat_id: int, message: dict) -> None:
|
||||||
|
from_user = message.get("from") or {}
|
||||||
|
record = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"username": from_user.get("username"),
|
||||||
|
"first_name": from_user.get("first_name"),
|
||||||
|
"last_name": from_user.get("last_name"),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"text_preview": (message.get("text") or "")[:100],
|
||||||
|
}
|
||||||
|
records = []
|
||||||
|
try:
|
||||||
|
if UNREGISTERED_FILE.exists():
|
||||||
|
content = UNREGISTERED_FILE.read_text(encoding="utf-8").strip()
|
||||||
|
if content:
|
||||||
|
records = json.loads(content)
|
||||||
|
if not isinstance(records, list):
|
||||||
|
records = []
|
||||||
|
except Exception:
|
||||||
|
records = []
|
||||||
|
records.append(record)
|
||||||
|
try:
|
||||||
|
UNREGISTERED_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not write to unregistered_users.json: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
|
||||||
|
"""Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged."""
|
||||||
|
from app.defaults import AUTHORIZED_USERS
|
||||||
|
|
||||||
|
# 1. Whitelist check (only registered whitelist IDs)
|
||||||
|
if chat_id in AUTHORIZED_USERS:
|
||||||
|
u = AUTHORIZED_USERS[chat_id]
|
||||||
|
return LinkedUser(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=u.get("user_id"),
|
||||||
|
display_name=u.get("display_name"),
|
||||||
|
role=u.get("role", "procurement_manager"),
|
||||||
|
is_admin=u.get("is_admin", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Block unregistered user and record log
|
||||||
|
_record_unregistered_user(chat_id, message)
|
||||||
|
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ambiguity_user_reply(
|
||||||
|
ctx: BotContext, chat_id: int, user: LinkedUser,
|
||||||
|
draft, amb: dict, message: dict, incoming_msg_id: int | None
|
||||||
|
) -> None:
|
||||||
|
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
|
||||||
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
|
if not text and (message.get("voice") or message.get("audio")):
|
||||||
|
try:
|
||||||
|
audio_media = message.get("voice") or message.get("audio")
|
||||||
|
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
|
||||||
|
text = await transcribe(settings, audio_bytes)
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
if text:
|
||||||
|
draft_summary = _draft_summary(draft)
|
||||||
|
result = await understand(settings, text, draft_summary)
|
||||||
|
patch = result.get("patch") or {}
|
||||||
|
|
||||||
|
# Apply user correction specifically to the target field
|
||||||
|
if amb.get("kind") == "payment_schedule":
|
||||||
|
if patch.get("payment_terms"):
|
||||||
|
draft_store.merge_quote_patch(draft.payload, patch)
|
||||||
|
else:
|
||||||
|
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||||
|
if not draft.payload.get("notes"):
|
||||||
|
draft.payload["notes"] = f"نحوه پرداخت: {text}"
|
||||||
|
else:
|
||||||
|
draft.payload["notes"] += f"\nنحوه پرداخت: {text}"
|
||||||
|
elif amb.get("kind") == "project_assignment":
|
||||||
|
if patch.get("items") or patch.get("project_name") or patch.get("project_names"):
|
||||||
|
draft_store.merge_quote_patch(draft.payload, patch)
|
||||||
|
else:
|
||||||
|
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||||
|
else:
|
||||||
|
draft_store.merge_quote_patch(draft.payload, patch)
|
||||||
|
|
||||||
|
amb["status"] = "applied"
|
||||||
|
await quote.resolve_payload(ctx, draft.payload)
|
||||||
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||||
|
|
||||||
|
# In-place edit main review card (Requirement 2.b)
|
||||||
|
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||||
|
|
||||||
|
# Delete the ambiguity message (Requirement 2.b)
|
||||||
|
if amb.get("message_id"):
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, amb["message_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Delete user's message to keep chat clean (Requirement 2.b)
|
||||||
|
if incoming_msg_id:
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, incoming_msg_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""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, project, 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:
|
||||||
|
from app.defaults import AUTHORIZED_USERS
|
||||||
|
|
||||||
|
if chat_id in AUTHORIZED_USERS:
|
||||||
|
u = AUTHORIZED_USERS[chat_id]
|
||||||
|
return LinkedUser(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=u.get("user_id"),
|
||||||
|
display_name=u.get("display_name"),
|
||||||
|
role=u.get("role", "procurement_manager"),
|
||||||
|
is_admin=u.get("is_admin", True),
|
||||||
|
)
|
||||||
|
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---- ambiguity sub-messages (Requirement 2.a) -------------------------
|
||||||
|
if parts[0] == "amb" and len(parts) >= 4:
|
||||||
|
action, prefix, amb_id = parts[1], parts[2], parts[3]
|
||||||
|
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||||
|
if draft is None:
|
||||||
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = draft.payload or {}
|
||||||
|
ambiguities = payload.get("ambiguities") or []
|
||||||
|
target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None)
|
||||||
|
if not target_amb:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "ok":
|
||||||
|
proposed = target_amb.get("proposed_patch") or {}
|
||||||
|
draft_store.merge_quote_patch(payload, proposed)
|
||||||
|
target_amb["status"] = "applied"
|
||||||
|
await quote.resolve_payload(ctx, payload)
|
||||||
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||||
|
# In-place edit main review card
|
||||||
|
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||||
|
# Delete ONLY this ambiguity message
|
||||||
|
if target_amb.get("message_id"):
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, target_amb["message_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif action == "no":
|
||||||
|
target_amb["status"] = "rejected"
|
||||||
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||||
|
# Delete ONLY this ambiguity message
|
||||||
|
if target_amb.get("message_id"):
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, target_amb["message_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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", "p") and arg in ("ok", "ok2", "no"):
|
||||||
|
kind = {
|
||||||
|
"q": draft_store.KIND_QUOTE,
|
||||||
|
"s": draft_store.KIND_SUPPLIER,
|
||||||
|
"m": draft_store.KIND_MATERIAL,
|
||||||
|
"p": draft_store.KIND_PROJECT,
|
||||||
|
}[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)
|
||||||
|
elif kind == draft_store.KIND_MATERIAL:
|
||||||
|
await material.commit(ctx, chat_id, user, draft)
|
||||||
|
elif kind == draft_store.KIND_PROJECT:
|
||||||
|
await project.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)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""/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:
|
||||||
|
from app.defaults import AUTHORIZED_USERS
|
||||||
|
|
||||||
|
await ctx.send(chat_id, WELCOME)
|
||||||
|
if chat_id in AUTHORIZED_USERS or chat_id in ctx.admin_chat_ids:
|
||||||
|
return
|
||||||
|
link = await ctx.links.get(chat_id)
|
||||||
|
if not link or not link.user_id:
|
||||||
|
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:
|
||||||
|
from app.defaults import AUTHORIZED_USERS
|
||||||
|
|
||||||
|
if chat_id in AUTHORIZED_USERS:
|
||||||
|
u = AUTHORIZED_USERS[chat_id]
|
||||||
|
role = ROLE_FA.get(u.get("role") or "", u.get("role") or "مدیر تدارکات")
|
||||||
|
await ctx.send(chat_id, f"اتصال (لیست سفید): {esc(u.get('display_name'))} — {role}")
|
||||||
|
return
|
||||||
|
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"اتصال: {esc(link.display_name)} — {role}")
|
||||||
@@ -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"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
|
||||||
|
"",
|
||||||
|
"اقلام:",
|
||||||
|
]
|
||||||
|
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("شرایط پرداخت:")
|
||||||
|
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 = ["🚚 اعلام رسیدن اقلام", ""]
|
||||||
|
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" → الان: {esc(quantities[li['id']])} {unit}"
|
||||||
|
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 "—",
|
||||||
|
))
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Add-project flow: name + optional description."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.drafts import (
|
||||||
|
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
|
||||||
|
)
|
||||||
|
from app.keyboards import simple_review_keyboard
|
||||||
|
from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.project")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||||
|
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
|
||||||
|
if draft is not None:
|
||||||
|
payload = draft.payload
|
||||||
|
merge_project_patch(payload, patch)
|
||||||
|
else:
|
||||||
|
payload = new_project_payload()
|
||||||
|
merge_project_patch(payload, patch)
|
||||||
|
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||||
|
|
||||||
|
|
||||||
|
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||||
|
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
|
||||||
|
|
||||||
|
|
||||||
|
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
missing = project_missing(payload)
|
||||||
|
text = project_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
|
||||||
|
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
|
||||||
|
if not missing.get("name"):
|
||||||
|
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
|
||||||
|
return
|
||||||
|
body = {"name": payload["name"]}
|
||||||
|
if payload.get("description"):
|
||||||
|
body["description"] = payload["description"]
|
||||||
|
try:
|
||||||
|
created = await ctx.api.create_project(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_project failed")
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
|
||||||
|
return
|
||||||
|
await draft_store.set_status(draft.id, "committed")
|
||||||
|
ctx.resolver.invalidate("projects")
|
||||||
|
await ctx.send(chat_id, project_committed_report(created))
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
# Clean up old ambiguity sub-messages (Requirement 2.c)
|
||||||
|
old_ambs = (draft.payload or {}).get("ambiguities") or []
|
||||||
|
for a in old_ambs:
|
||||||
|
if a.get("message_id"):
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, a["message_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
draft.payload["ambiguities"] = []
|
||||||
|
|
||||||
|
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:
|
||||||
|
c = res.candidates[0]
|
||||||
|
payload["supplier_id"] = c.id
|
||||||
|
payload["supplier_name"] = c.name
|
||||||
|
extra = c.extra or {}
|
||||||
|
for k in ("location", "phone", "contact_person", "category_name"):
|
||||||
|
if extra.get(k):
|
||||||
|
payload[f"supplier_{k}"] = extra[k]
|
||||||
|
options.pop("sup", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop("sup", None)
|
||||||
|
|
||||||
|
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||||
|
|
||||||
|
if not payload.get("project_id") and payload.get("project_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||||
|
if res.resolved:
|
||||||
|
c = res.candidates[0]
|
||||||
|
payload["project_id"] = c.id
|
||||||
|
payload["project_name"] = c.name
|
||||||
|
resolved_projects[c.name] = c.id
|
||||||
|
resolved_projects[payload["project_name"]] = c.id
|
||||||
|
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:
|
||||||
|
c = res.candidates[0]
|
||||||
|
p_id = c.id
|
||||||
|
resolved_projects[c.name] = p_id
|
||||||
|
resolved_projects[p_name] = p_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
|
||||||
|
|
||||||
|
from app.security import create_draft_token
|
||||||
|
|
||||||
|
payload = draft.payload
|
||||||
|
missing = compute_quote_missing(payload)
|
||||||
|
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 += 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send pending ambiguity sub-messages as replies to the main review card
|
||||||
|
await send_pending_ambiguities(ctx, chat_id, draft)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
|
||||||
|
from app.keyboards import ambiguity_keyboard
|
||||||
|
from app.texts import ambiguity_message_text
|
||||||
|
|
||||||
|
if not draft.review_message_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix = str(draft.id)[:8]
|
||||||
|
payload = draft.payload or {}
|
||||||
|
ambiguities = payload.get("ambiguities") or []
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
for amb in ambiguities:
|
||||||
|
if amb.get("status") == "pending" and not amb.get("message_id"):
|
||||||
|
msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", ""))
|
||||||
|
kb = ambiguity_keyboard(prefix, amb.get("id", ""))
|
||||||
|
sent = await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
msg_text,
|
||||||
|
reply_markup=kb,
|
||||||
|
reply_to=draft.review_message_id,
|
||||||
|
)
|
||||||
|
if sent and sent.get("message_id"):
|
||||||
|
amb["message_id"] = sent["message_id"]
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# commit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
|
||||||
|
newly_created: dict = {}
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
newly_created["supplier"] = payload["supplier_name"]
|
||||||
|
ctx.resolver.invalidate("suppliers")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not auto-create supplier: %s", e)
|
||||||
|
|
||||||
|
# 2. Sequential pre-creation for new materials
|
||||||
|
created_mats = []
|
||||||
|
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"])
|
||||||
|
created_mats.append(item["material_name"])
|
||||||
|
ctx.resolver.invalidate("materials")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not auto-create material: %s", e)
|
||||||
|
if created_mats:
|
||||||
|
newly_created["materials"] = created_mats
|
||||||
|
|
||||||
|
# 3. Sequential pre-creation for new projects
|
||||||
|
created_prjs = []
|
||||||
|
all_p_names = []
|
||||||
|
if payload.get("project_names"):
|
||||||
|
for p in payload["project_names"]:
|
||||||
|
p_str = str(p).strip()
|
||||||
|
if p_str and p_str not in all_p_names:
|
||||||
|
all_p_names.append(p_str)
|
||||||
|
elif payload.get("project_name"):
|
||||||
|
p_str = str(payload["project_name"]).strip()
|
||||||
|
if p_str and p_str not in all_p_names:
|
||||||
|
all_p_names.append(p_str)
|
||||||
|
|
||||||
|
for it in payload.get("items") or []:
|
||||||
|
it_p = it.get("project_name")
|
||||||
|
if it_p:
|
||||||
|
it_p_str = str(it_p).strip()
|
||||||
|
if it_p_str and it_p_str not in all_p_names:
|
||||||
|
all_p_names.append(it_p_str)
|
||||||
|
|
||||||
|
resolved_map = payload.setdefault("resolved_projects", {})
|
||||||
|
for p_name in all_p_names:
|
||||||
|
p_id = resolved_map.get(p_name)
|
||||||
|
if not p_id:
|
||||||
|
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||||
|
if res.resolved:
|
||||||
|
p_id = res.candidates[0].id
|
||||||
|
resolved_map[p_name] = p_id
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
|
||||||
|
p_id = created_prj.get("id")
|
||||||
|
if p_id:
|
||||||
|
resolved_map[p_name] = p_id
|
||||||
|
created_prjs.append(p_name)
|
||||||
|
ctx.resolver.invalidate("projects")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not auto-create project %s: %s", p_name, e)
|
||||||
|
|
||||||
|
if p_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
|
||||||
|
|
||||||
|
if created_prjs:
|
||||||
|
newly_created["projects"] = created_prjs
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
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, newly_created=newly_created))
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""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", "project": "p"}.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")]])
|
||||||
|
|
||||||
|
|
||||||
|
def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
|
||||||
|
"""Inline buttons for ambiguity sub-message (≤64 bytes callback_data)."""
|
||||||
|
return _kb([
|
||||||
|
[_btn("✅ تأیید و اعمال در پیشنویس", f"amb:ok:{prefix}:{amb_id}")],
|
||||||
|
[_btn("❌ تایید نمیشود", f"amb:no:{prefix}:{amb_id}")],
|
||||||
|
])
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
"""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",
|
||||||
|
"add_project",
|
||||||
|
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||||
|
"accept_quote", # پذیرش یک استعلام
|
||||||
|
"arrive_items", # اعلام رسیدن اقلام
|
||||||
|
"help",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم ERP ساختمانی و صنعتی در ایران هستی.
|
||||||
|
پیامهای صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاورهای کارگاهی و بازار بیان شدهاند که نسخههای متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آنها تولید شده است.
|
||||||
|
|
||||||
|
وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایششده و استخراج دقیق دادههای تدارکاتی است.
|
||||||
|
یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارکداون یا توضیح اضافه) با این ساختار دقیق:
|
||||||
|
|
||||||
|
{
|
||||||
|
"consensus_text": "بیانیه رسمی، پالایششده، فشرده و بدون تکیهکلام از تقاضای تدارکات کاربر",
|
||||||
|
"intent": یکی از {intents},
|
||||||
|
"patch": {شکل partial هر فعالیتی که کاربر داده},
|
||||||
|
"confidence": 0.0..1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
قواعد حیاتی:
|
||||||
|
۱. قواعد آوایی و دستوری لهجه اصفهانی در رمزگشایی خطاهای STT:
|
||||||
|
- شناسه مفعولی پیوسته: پسوند «ـشا» یا «ش ها» در پایان کلمات (مانند پولشا، بارشا) به معنای «آن را / آنها را» است.
|
||||||
|
- شناسه دوم شخص جمع (ت به د): پسوندهای «ـِدون»، «برادون»، «برهدون» به معنای «برایتان / به شما» هستند.
|
||||||
|
- تخمین زمانی و مقداری: کلمه «اینا» پس از اعداد یا زمان (مثلاً «۱۰ روز اینا»، «۴۰ تا اینا») قید تخمین و به معنای «حدوداً» است و نباید با کلمات نامربوط (مانند روزنامه) اشتباه گرفته شود.
|
||||||
|
- حذف حشوهای مکالمه: اصطلاحات پرکننده («عرض به خدمتتون»، «پامون درمیاد»، «دونهای اینا»، «شبت بخیر»، «همین») باید کاملاً از consensus_text پالایش شوند.
|
||||||
|
- حل تعارض نامها در مدلهای STT: در صورت تفاوت املایی نام تأمینکننده یا کالا بین چند مدل، اولویت با نگارشی است که با واژگان معتبر فارسی، نامهای خانوادگی استاندارد و اقلام صنعتی/ساختمانی تطابق دارد.
|
||||||
|
|
||||||
|
۲. ساخت فیلد consensus_text:
|
||||||
|
- متن consensus_text نباید انشا یا نقلقول داستانی پر از تعارفات و حواشی باشد.
|
||||||
|
- کلیه احوالپرسیها، تعارفات و تکیهکلامها را کاملاً حذف کن.
|
||||||
|
- این متن باید یک «بیانیه رسمی، تمیز و ساختاریافته تدارکاتی» باشد که شامل: نوع اقدام (استعلام/خرید)، اقلام، مقادیر، مشخصات، قیمتها، نام تأمینکننده، نام پروژهها، مهلت اعتبار و خلاصه نحوه پرداخت باشد.
|
||||||
|
مثال سبک بیانیه رسمی: «استعلام خرید ۱۲۰ شاخه لوله پلیکا ۶ اینچ (فی ۴۵۰,۰۰۰ تومان) از بازرگانی البرز برای پروژه پردیس. مهلت اعتبار پیشفاکتور: ۵ روز. شرایط پرداخت: نقد با ۵٪ تخفیف.»
|
||||||
|
|
||||||
|
۳. تفکیک وظایف محاسباتی با defaults.py:
|
||||||
|
- تو فقط ساختار مفهومی را در patch استخراج میکنی؛ محاسبات عددی اقساط و تاریخهای تقویم شمسی بر عهده توابع سیستم است.
|
||||||
|
- برای اقساط: تعداد اقساط را در installments_count استخراج کن (مثلاً ۴).
|
||||||
|
- برای پرداخت نقدی: درصد تخفیف را در discount_percent استخراج کن (مثلاً ۵).
|
||||||
|
- برای مهلت اعتبار: تعداد روز را در expiry_days بگذار (مثلاً ۵).
|
||||||
|
|
||||||
|
۴. قوانین استخراج پروژهها و اقلام:
|
||||||
|
- پروژهها: اگر کاربر چند پروژه را ذکر کرد (مانند «برای پردیس و کارگاه مرکزی»)، تمام نامها را عیناً در آرایه project_names قرار بده: ["پردیس", "کارگاه مرکزی"]. پروژهها را با برند اشتباه نگیر.
|
||||||
|
- اقلام: material_name فقط نام کلی کالا (مانند «لوله پلیکا» یا «میلگرد»)؛ مشخصات فنی (مانند «۶ اینچ» یا «آجدار») در آرایه specs؛ تعداد در quantity؛ واحد در unit_name؛ و قیمت واحد در unit_price (عددی خالص، مثلاً ۴۵۰ هزار = 450000).
|
||||||
|
|
||||||
|
۵. قوانین عمومی قصدها (Intents):
|
||||||
|
- اگر پیام کاربر اطلاعات جدیدی به پیشنویسِ باز اضافه میکند یا اصلاح میکند → 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 مقادیر.
|
||||||
|
|
||||||
|
شکل patch برای intent="register_quote"/"update_draft":
|
||||||
|
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD",
|
||||||
|
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}],
|
||||||
|
"payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]}
|
||||||
|
|
||||||
|
شکل 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="add_project":
|
||||||
|
{"project": {"name": str, "description": 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 | list[dict], draft_summary: str | None = None) -> dict:
|
||||||
|
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
|
||||||
|
fallback = {"consensus_text": "", "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
|
||||||
|
|
||||||
|
if isinstance(text, list):
|
||||||
|
first_text = text[0].get("text") if text else ""
|
||||||
|
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:"]
|
||||||
|
for idx, item in enumerate(text, start=1):
|
||||||
|
name = item.get("name") or f"مدل {idx}"
|
||||||
|
t = normalize_digits(item.get("text") or "")
|
||||||
|
lines.append(f"- نسخه {idx} ({name}): «{t}»")
|
||||||
|
lines.append("\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
|
||||||
|
user_content = "\n".join(lines)
|
||||||
|
elif isinstance(text, str) and ("مدل 1" in text or "مدل ۱" in text) and ("مدل 2" in text or "مدل ۲" in text):
|
||||||
|
user_content = (
|
||||||
|
"پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:\n"
|
||||||
|
+ normalize_digits(text)
|
||||||
|
+ "\n\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز."
|
||||||
|
)
|
||||||
|
first_text = text
|
||||||
|
else:
|
||||||
|
user_content = normalize_digits(str(text))
|
||||||
|
first_text = str(text)
|
||||||
|
|
||||||
|
fallback["consensus_text"] = first_text
|
||||||
|
|
||||||
|
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": user_content},
|
||||||
|
],
|
||||||
|
"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", {})
|
||||||
|
if not parsed.get("consensus_text"):
|
||||||
|
parsed["consensus_text"] = first_text
|
||||||
|
return parsed
|
||||||
|
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
logger.exception("NLU failure")
|
||||||
|
return fallback
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
|
||||||
|
|
||||||
|
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
|
||||||
|
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.api.client import ApiError, ErpClient
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.nlu import normalize_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candidate:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Resolution:
|
||||||
|
status: str # "one" | "many" | "none"
|
||||||
|
candidates: list[Candidate]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved(self) -> bool:
|
||||||
|
return self.status == "one"
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
|
||||||
|
q = normalize_text(query)
|
||||||
|
if not q:
|
||||||
|
return items[:limit]
|
||||||
|
scored = []
|
||||||
|
for item in items:
|
||||||
|
name = normalize_text(item.name)
|
||||||
|
if q == name:
|
||||||
|
ratio = 1.0
|
||||||
|
elif q in name and len(q) >= 3:
|
||||||
|
ratio = 0.85 + 0.15 * (len(q) / len(name))
|
||||||
|
elif name in q and len(name) >= 3:
|
||||||
|
ratio = 0.85 + 0.15 * (len(name) / len(q))
|
||||||
|
else:
|
||||||
|
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||||
|
if ratio >= 0.72:
|
||||||
|
scored.append((ratio, item))
|
||||||
|
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||||
|
return [item for ratio, item in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_units() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(u["id"]),
|
||||||
|
name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||||
|
extra={"code": u.get("code")},
|
||||||
|
)
|
||||||
|
for u in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_categories() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(c["id"]),
|
||||||
|
name=c.get("name") or c.get("slug") or str(c["id"]),
|
||||||
|
)
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_projects() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(p["id"]),
|
||||||
|
name=p.get("name") or str(p["id"]),
|
||||||
|
)
|
||||||
|
for p in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_suppliers(q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
if q:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||||
|
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id "
|
||||||
|
"WHERE s.name ILIKE :q LIMIT 20"),
|
||||||
|
{"q": f"%{q}%"}
|
||||||
|
)).mappings().all()
|
||||||
|
else:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||||
|
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20")
|
||||||
|
)).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(r["id"]),
|
||||||
|
name=r.get("name") or str(r["id"]),
|
||||||
|
extra={
|
||||||
|
"location": r.get("location"),
|
||||||
|
"phone": r.get("phone"),
|
||||||
|
"contact_person": r.get("contact_person"),
|
||||||
|
"category_name": r.get("category_name"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_materials(q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
if q:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"),
|
||||||
|
{"q": f"%{q}%"}
|
||||||
|
)).mappings().all()
|
||||||
|
else:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20")
|
||||||
|
)).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(r["id"]),
|
||||||
|
name=r.get("name") or str(r["id"]),
|
||||||
|
extra=dict(r),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Resolver:
|
||||||
|
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
|
||||||
|
|
||||||
|
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
|
||||||
|
self._api = api
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
|
||||||
|
|
||||||
|
async def _cached(self, key: str, fetch) -> list[Candidate]:
|
||||||
|
entry = self._cache.get(key)
|
||||||
|
now = time.monotonic()
|
||||||
|
if entry and now - entry[0] < self._ttl:
|
||||||
|
return entry[1]
|
||||||
|
items = await fetch()
|
||||||
|
self._cache[key] = (now, items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def invalidate(self, key: str | None = None) -> None:
|
||||||
|
if key:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
else:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sources
|
||||||
|
|
||||||
|
async def units(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_units()
|
||||||
|
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||||
|
extra={"code": u.get("code")}) for u in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_units()
|
||||||
|
return await self._cached("units", fetch)
|
||||||
|
|
||||||
|
async def categories(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_categories()
|
||||||
|
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_categories()
|
||||||
|
return await self._cached("categories", fetch)
|
||||||
|
|
||||||
|
async def projects(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_projects()
|
||||||
|
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_projects()
|
||||||
|
return await self._cached("projects", fetch)
|
||||||
|
|
||||||
|
async def suppliers(self, q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_suppliers(q=q, page_size=20)
|
||||||
|
items = [
|
||||||
|
Candidate(
|
||||||
|
id=str(s["id"]),
|
||||||
|
name=s.get("name") or str(s["id"]),
|
||||||
|
extra={
|
||||||
|
"location": s.get("location"),
|
||||||
|
"phone": s.get("phone"),
|
||||||
|
"contact_person": s.get("contact_person"),
|
||||||
|
"category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for s in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
items = await _db_suppliers(q=q)
|
||||||
|
return _rank(q, items) if q else items
|
||||||
|
|
||||||
|
async def materials(self, q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_materials(q=q, page_size=20)
|
||||||
|
extra = {str(m["id"]): m for m in rows}
|
||||||
|
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
|
||||||
|
for m in rows]
|
||||||
|
except Exception:
|
||||||
|
items = await _db_materials(q=q)
|
||||||
|
return _rank(q, items) if q else items
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ resolutions
|
||||||
|
|
||||||
|
async def resolve(self, kind: str, name: str | None) -> Resolution:
|
||||||
|
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return Resolution("none", [])
|
||||||
|
if kind == "supplier":
|
||||||
|
candidates = await self.suppliers(name)
|
||||||
|
elif kind == "material":
|
||||||
|
candidates = await self.materials(name)
|
||||||
|
elif kind == "project":
|
||||||
|
candidates = _rank(name, await self.projects())
|
||||||
|
elif kind == "unit":
|
||||||
|
candidates = _rank(name, await self.units())
|
||||||
|
elif kind == "category":
|
||||||
|
candidates = _rank(name, await self.categories())
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown kind: {kind}")
|
||||||
|
if not candidates:
|
||||||
|
return Resolution("none", [])
|
||||||
|
norm_name = normalize_text(name)
|
||||||
|
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
|
||||||
|
if len(exact) == 1:
|
||||||
|
return Resolution("one", exact)
|
||||||
|
top = candidates[0]
|
||||||
|
top_norm = normalize_text(top.name)
|
||||||
|
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
||||||
|
if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3:
|
||||||
|
ratio = max(ratio, 0.90)
|
||||||
|
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||||
|
return Resolution("one", [top])
|
||||||
|
return Resolution("many", candidates)
|
||||||
|
|
||||||
|
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
|
||||||
|
"""Return (unit_id, explicitly_resolved). Falls back to the material's
|
||||||
|
canonical unit when the spoken unit isn't found."""
|
||||||
|
if unit_name:
|
||||||
|
res = await self.resolve("unit", unit_name)
|
||||||
|
if res.resolved:
|
||||||
|
return res.candidates[0].id, True
|
||||||
|
if material and material.get("canonical_unit_id"):
|
||||||
|
return str(material["canonical_unit_id"]), False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
|
||||||
|
try:
|
||||||
|
return await resolver.resolve(kind, name)
|
||||||
|
except Exception:
|
||||||
|
return Resolution("none", [])
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
|
||||||
|
|
||||||
|
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
|
||||||
|
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
|
||||||
|
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
|
||||||
|
self-hosted whisper servers exposing the same contract).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.stt")
|
||||||
|
|
||||||
|
_CONVERT_TIMEOUT = 60
|
||||||
|
_STT_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
||||||
|
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
src = Path(tmp) / f"in{suffix or '.ogg'}"
|
||||||
|
dst = Path(tmp) / "out.mp3"
|
||||||
|
src.write_bytes(raw)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=_CONVERT_TIMEOUT,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0 or not dst.exists():
|
||||||
|
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
|
||||||
|
return dst.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
|
||||||
|
name = provider.get("name") or "stt"
|
||||||
|
base_url = (provider.get("base_url") or "").rstrip("/")
|
||||||
|
api_key = provider.get("api_key") or ""
|
||||||
|
model = provider.get("model") or "whisper-1"
|
||||||
|
language = provider.get("language") or "fa"
|
||||||
|
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
||||||
|
|
||||||
|
url = f"{base_url}/audio/transcriptions"
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")},
|
||||||
|
data={"model": model, "language": language},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
txt = str((resp.json() or {}).get("text") or "").strip()
|
||||||
|
if txt:
|
||||||
|
logger.info("STT provider %s succeeded: %r", name, txt[:60])
|
||||||
|
return {"name": name, "text": txt, "model": model}
|
||||||
|
else:
|
||||||
|
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("STT provider %s failed: %s", name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||||
|
filename: str | None = None) -> list[dict]:
|
||||||
|
"""Transcribe audio with all configured STT providers in parallel.
|
||||||
|
|
||||||
|
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
|
||||||
|
Empty list if all failed or silence.
|
||||||
|
"""
|
||||||
|
providers = settings.stt_providers
|
||||||
|
if not providers:
|
||||||
|
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
|
||||||
|
|
||||||
|
suffix = Path(filename or "").suffix
|
||||||
|
if not suffix and mime_type:
|
||||||
|
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
|
||||||
|
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
||||||
|
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||||
|
tasks = [_call_single_stt(client, p, converted) for p in providers]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
valid_transcripts: list[dict] = []
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, dict) and r.get("text"):
|
||||||
|
valid_transcripts.append(r)
|
||||||
|
|
||||||
|
return valid_transcripts
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||||
|
filename: str | None = None) -> str:
|
||||||
|
"""Return the Persian transcript for a voice note (empty string on silence)."""
|
||||||
|
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
|
||||||
|
return transcripts[0]["text"] if transcripts else ""
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""Persian message templates (the ERP's user-facing layer is Persian)."""
|
||||||
|
import html
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
STATUS_FA = {
|
||||||
|
"offered": "🔵 در انتظار بررسی",
|
||||||
|
"accepted": "🟢 پذیرفتهشده",
|
||||||
|
"arrived": "✅ رسیده",
|
||||||
|
"rejected": "🔴 ردشده",
|
||||||
|
"expired": "⚪️ منقضی",
|
||||||
|
}
|
||||||
|
|
||||||
|
MISSING_MARKS = {True: "✅", False: "❌"}
|
||||||
|
WARNING_MARK = "⚠️"
|
||||||
|
|
||||||
|
|
||||||
|
def esc(value) -> str:
|
||||||
|
return str(value if value is not None and value != "" else "—")
|
||||||
|
|
||||||
|
|
||||||
|
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
|
||||||
|
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
||||||
|
gy2 = gy if gm > 2 else gy - 1
|
||||||
|
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
|
||||||
|
jy = -1595 + (33 * (days // 12053))
|
||||||
|
days %= 12053
|
||||||
|
jy += 4 * (days // 1461)
|
||||||
|
days %= 1461
|
||||||
|
if days > 365:
|
||||||
|
jy += (days - 1) // 365
|
||||||
|
days = (days - 1) % 365
|
||||||
|
if days < 186:
|
||||||
|
jm = 1 + (days // 31)
|
||||||
|
jd = 1 + (days % 31)
|
||||||
|
else:
|
||||||
|
jm = 7 + ((days - 186) // 30)
|
||||||
|
jd = 1 + ((days - 186) % 30)
|
||||||
|
return jy, jm, jd
|
||||||
|
|
||||||
|
|
||||||
|
def fa_num(val, use_fa_digits: bool = True) -> str:
|
||||||
|
"""Format integer/float with thousands separator and Persian digits."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
num = float(val)
|
||||||
|
if num.is_integer():
|
||||||
|
s = f"{int(num):,}"
|
||||||
|
else:
|
||||||
|
s = f"{num:,.2f}".rstrip("0").rstrip(".")
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
|
||||||
|
return s.translate(trans)
|
||||||
|
return s
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
def fa_digits(val) -> str:
|
||||||
|
"""Convert ASCII digits to Persian digits without thousands separators."""
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return str(val).translate(trans)
|
||||||
|
|
||||||
|
|
||||||
|
def fa_date(value, use_fa_digits: bool = True) -> str:
|
||||||
|
if not value:
|
||||||
|
return "—"
|
||||||
|
s = str(value)[:10]
|
||||||
|
try:
|
||||||
|
parts = [int(p) for p in s.replace("/", "-").split("-")]
|
||||||
|
if len(parts) == 3 and parts[0] > 1900:
|
||||||
|
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
|
||||||
|
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return res.translate(trans)
|
||||||
|
return res
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return s.translate(trans)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
WELCOME = (
|
||||||
|
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
|
||||||
|
"میتوانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
|
||||||
|
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
|
||||||
|
"من هر چه را فهمیدم بهصورت پیشنویس آماده میکنم، چیزی که فهمیدم و چیزهای خالی را نشان "
|
||||||
|
"میدهم و فقط با تأیید تو در سیستم ثبت میکنم.\n\n"
|
||||||
|
"دستورها:\n"
|
||||||
|
"/drafts — پیشنویسهای باز\n"
|
||||||
|
"/cancel — لغو پیشنویس/عملیات جاری\n"
|
||||||
|
"/whoami — اتصال من\n"
|
||||||
|
"/unlink — قطع اتصال\n"
|
||||||
|
"/help — راهنما"
|
||||||
|
)
|
||||||
|
|
||||||
|
HELP = (
|
||||||
|
"چه کارهایی میتوانم بکنم؟\n\n"
|
||||||
|
"🧾 ثبت استعلام — تأمینکننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمیآورم.\n"
|
||||||
|
"🏢 افزودن تأمینکننده — «تأمینکننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
|
||||||
|
"📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
|
||||||
|
"📊 پرسوجو — «استعلامهای در انتظار؟»، «کدامها بهزودی منقضی میشوند؟»\n"
|
||||||
|
"✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
|
||||||
|
"🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
|
||||||
|
"هر ثبت فقط با دکمه ✅ انجام میشود. با /cancel هر چیز نیمهکاره را لغو کن."
|
||||||
|
)
|
||||||
|
|
||||||
|
LINK_START = (
|
||||||
|
"برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
|
||||||
|
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
|
||||||
|
)
|
||||||
|
LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمیشود). /cancel برای لغو"
|
||||||
|
LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا میتوانی فعالیتها را بفرستی. /help را ببین."
|
||||||
|
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
|
||||||
|
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبتها بدون انتساب کاربر انجام میشود."
|
||||||
|
|
||||||
|
DRAFTS_LIST_HEADER = "📋 پیشنویسهای باز:"
|
||||||
|
DRAFTS_EMPTY = "پیشنویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
|
||||||
|
NOTHING_TO_COMMIT = "پیشنویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
|
||||||
|
CANCELLED = "لغو شد. ✔️"
|
||||||
|
UNKNOWN_HINT = (
|
||||||
|
"متوجه منظورت نشد 🤔\n"
|
||||||
|
"میتوانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
|
||||||
|
)
|
||||||
|
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
|
||||||
|
AI_NOT_CONFIGURED = (
|
||||||
|
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
|
||||||
|
"با مدیر سیستم تماس بگیر."
|
||||||
|
)
|
||||||
|
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
|
||||||
|
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
|
||||||
|
ERR_GENERIC = "خطا: {detail}"
|
||||||
|
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را میخواهی؟"
|
||||||
|
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
|
||||||
|
ACCEPT_REPORT = (
|
||||||
|
"✅ پذیرش انجام شد\n"
|
||||||
|
"استعلام #{id}\n"
|
||||||
|
"وضعیت: {status}\n"
|
||||||
|
"اقلام تصمیمگیریشده: {count}"
|
||||||
|
)
|
||||||
|
ARRIVE_REPORT = (
|
||||||
|
"🚚 ثبت رسید انجام شد\n"
|
||||||
|
"استعلام #{id} — وضعیت: {status}\n{lines}"
|
||||||
|
)
|
||||||
|
ACCEPT_DONE_TS = "شرایط انتخابشده: {label}"
|
||||||
|
|
||||||
|
|
||||||
|
def missing_block(missing: list[dict]) -> str:
|
||||||
|
"""Checklist of missing vs. captured fields."""
|
||||||
|
if not missing:
|
||||||
|
return "همه فیلدها کامل است ✅"
|
||||||
|
lines = []
|
||||||
|
for m in missing:
|
||||||
|
if m.get("ok"):
|
||||||
|
mark = "✅"
|
||||||
|
elif m.get("blocking"):
|
||||||
|
mark = "❌"
|
||||||
|
else:
|
||||||
|
mark = "⚠️"
|
||||||
|
lines.append(f"{mark} {m.get('label')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
|
||||||
|
lines = ["📋 پیشنویس استعلام"]
|
||||||
|
|
||||||
|
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
|
||||||
|
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
|
||||||
|
|
||||||
|
if not has_blocking and not has_warnings:
|
||||||
|
lines.append("✅ همهی اطلاعات کامل است، آماده ثبت.")
|
||||||
|
elif has_blocking:
|
||||||
|
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
|
||||||
|
else:
|
||||||
|
lines.append("⚠️ پیشنویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
|
||||||
|
|
||||||
|
if status_note:
|
||||||
|
lines.append(status_note)
|
||||||
|
|
||||||
|
lines.append("━━━━━━━━━━━━━━\n")
|
||||||
|
|
||||||
|
# 🏢 تأمینکننده
|
||||||
|
lines.append("🏢 تأمینکننده")
|
||||||
|
sup_name = payload.get("supplier_name")
|
||||||
|
is_official = payload.get("is_official", True)
|
||||||
|
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
|
||||||
|
|
||||||
|
if payload.get("supplier_id"):
|
||||||
|
lines.append(f"{sup_name}{official_badge} ✅")
|
||||||
|
if payload.get("supplier_location"):
|
||||||
|
lines.append(f"📍 {payload['supplier_location']}")
|
||||||
|
contact_parts = []
|
||||||
|
if payload.get("supplier_phone"):
|
||||||
|
contact_parts.append(fa_digits(payload["supplier_phone"]))
|
||||||
|
if payload.get("supplier_contact_person"):
|
||||||
|
contact_parts.append(str(payload["supplier_contact_person"]))
|
||||||
|
if contact_parts:
|
||||||
|
lines.append(f"📞 {' — '.join(contact_parts)}")
|
||||||
|
elif sup_name:
|
||||||
|
lines.append(f"{sup_name}{official_badge} ✨ (تأمینکننده جدید)")
|
||||||
|
else:
|
||||||
|
lines.append("— (مشخص نشده)")
|
||||||
|
|
||||||
|
cat_name = payload.get("supplier_category_name")
|
||||||
|
if cat_name:
|
||||||
|
lines.append(f"\n🏷️ موضوع: {cat_name}")
|
||||||
|
|
||||||
|
# 🏗️ پروژه(ها)
|
||||||
|
p_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
|
||||||
|
resolved_projects = payload.get("resolved_projects") or {}
|
||||||
|
if p_names:
|
||||||
|
if len(p_names) == 1:
|
||||||
|
p = p_names[0]
|
||||||
|
badge = " ✅" if (p in resolved_projects or payload.get("project_id")) else " ✨ (پروژه جدید)"
|
||||||
|
lines.append(f"\n🏗️ پروژه: {p}{badge}")
|
||||||
|
else:
|
||||||
|
lines.append(f"\n🏗️ پروژهها ({fa_num(len(p_names))} مورد):")
|
||||||
|
for p in p_names:
|
||||||
|
badge = " ✅" if p in resolved_projects else " ✨ (پروژه جدید)"
|
||||||
|
lines.append(f"▫️ {p}{badge}")
|
||||||
|
|
||||||
|
# 📦 اقلام
|
||||||
|
items = payload.get("items") or []
|
||||||
|
total_amount = 0.0
|
||||||
|
has_any_price = False
|
||||||
|
|
||||||
|
if items:
|
||||||
|
lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
|
||||||
|
default_proj = payload.get("project_name")
|
||||||
|
for i, it in enumerate(items, start=1):
|
||||||
|
name = it.get("material_name") or f"قلم {i}"
|
||||||
|
title_parts = [str(name)]
|
||||||
|
if it.get("brand_name"):
|
||||||
|
title_parts.append(str(it["brand_name"]))
|
||||||
|
if it.get("specs"):
|
||||||
|
title_parts.append("، ".join(str(s) for s in it["specs"]))
|
||||||
|
if not it.get("material_id"):
|
||||||
|
title_parts.append("✨ (کالای جدید)")
|
||||||
|
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
|
||||||
|
|
||||||
|
# Project
|
||||||
|
item_proj = it.get("project_name") or default_proj
|
||||||
|
if item_proj:
|
||||||
|
p_badge = ""
|
||||||
|
if item_proj not in resolved_projects and not (payload.get("project_id") and len(p_names) <= 1):
|
||||||
|
p_badge = " ✨ (پروژه جدید)"
|
||||||
|
lines.append(f" 🏗️ {item_proj}{p_badge}")
|
||||||
|
else:
|
||||||
|
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||||
|
|
||||||
|
# Qty, Unit, Price, Total
|
||||||
|
qty = it.get("quantity")
|
||||||
|
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||||||
|
unit_price = it.get("unit_price")
|
||||||
|
|
||||||
|
calc_line = " "
|
||||||
|
if qty is not None:
|
||||||
|
calc_line += f"{fa_num(qty)} {unit or ''}".strip()
|
||||||
|
else:
|
||||||
|
calc_line += "مقدار نامشخص"
|
||||||
|
|
||||||
|
if unit_price:
|
||||||
|
has_any_price = True
|
||||||
|
calc_line += f" × {fa_num(unit_price)}"
|
||||||
|
if qty is not None:
|
||||||
|
try:
|
||||||
|
line_sum = float(qty) * float(unit_price)
|
||||||
|
total_amount += line_sum
|
||||||
|
calc_line += f" = {fa_num(line_sum)} تومان"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
calc_line += " تومان"
|
||||||
|
else:
|
||||||
|
calc_line += " تومان"
|
||||||
|
else:
|
||||||
|
calc_line += " × — = — تومان"
|
||||||
|
|
||||||
|
lines.append(calc_line)
|
||||||
|
|
||||||
|
if has_any_price and total_amount > 0:
|
||||||
|
lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
|
||||||
|
elif not has_any_price:
|
||||||
|
lines.append("\n💰 مجموع مبلغ: محاسبهنشده (قیمت اقلام وارد نشده)")
|
||||||
|
else:
|
||||||
|
lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
|
||||||
|
|
||||||
|
# 💳 شرایط پرداخت
|
||||||
|
sheets = payload.get("termsheets") or []
|
||||||
|
if sheets:
|
||||||
|
lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
|
||||||
|
for s_idx, t in enumerate(sheets, start=1):
|
||||||
|
lbl = t.get("label") or f"گزینه {s_idx}"
|
||||||
|
milestones = t.get("milestones") or t.get("schedule_steps") or []
|
||||||
|
if milestones:
|
||||||
|
lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
|
||||||
|
step_sum = 0.0
|
||||||
|
for m in milestones:
|
||||||
|
due = fa_date(m.get("due_date"))
|
||||||
|
amt = m.get("amount")
|
||||||
|
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
|
||||||
|
if amt:
|
||||||
|
try:
|
||||||
|
step_sum += float(amt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
note = f" ({m['note']})" if m.get("note") else ""
|
||||||
|
lines.append(f" • {due} — {amt_str}{note}")
|
||||||
|
|
||||||
|
if total_amount > 0 and step_sum > 0:
|
||||||
|
diff = abs(total_amount - step_sum)
|
||||||
|
if diff < 1.0:
|
||||||
|
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
|
||||||
|
else:
|
||||||
|
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
|
||||||
|
if len(items) > 0:
|
||||||
|
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
|
||||||
|
else:
|
||||||
|
disc = ""
|
||||||
|
if t.get("discount_value"):
|
||||||
|
disc = f" (تخفیف {fa_num(t['discount_value'])})"
|
||||||
|
lines.append(f"▫️ {lbl}{disc}")
|
||||||
|
elif items:
|
||||||
|
lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیشفرض)")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
meta_lines = []
|
||||||
|
if payload.get("quote_date"):
|
||||||
|
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
|
||||||
|
if payload.get("expiry_date"):
|
||||||
|
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
|
||||||
|
if payload.get("proforma_file"):
|
||||||
|
meta_lines.append(f"📎 پیشفاکتور: {payload['proforma_file']}")
|
||||||
|
if payload.get("notes"):
|
||||||
|
meta_lines.append(f"📝 توضیحات: {payload['notes']}")
|
||||||
|
|
||||||
|
if meta_lines:
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(meta_lines)
|
||||||
|
|
||||||
|
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
|
||||||
|
if has_blocking or has_warnings:
|
||||||
|
lines.append("\n📋 وضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
lines.append("\n━━━━━━━━━━━━━━")
|
||||||
|
if has_blocking:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
elif has_warnings:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
else:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
|
||||||
|
lines = ["🏢 پیشنویس تأمینکننده جدید", f"نام: {esc(payload.get('name'))}"]
|
||||||
|
lines.append(f"دسته: {esc(payload.get('category_name'))}")
|
||||||
|
if payload.get("phone"):
|
||||||
|
lines.append(f"تلفن: {esc(payload['phone'])}")
|
||||||
|
if payload.get("location"):
|
||||||
|
lines.append(f"موقعیت: {esc(payload['location'])}")
|
||||||
|
if payload.get("contact_person"):
|
||||||
|
lines.append(f"رابط: {esc(payload['contact_person'])}")
|
||||||
|
lines.append("\nوضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def material_review_text(payload: dict, missing: list[dict]) -> str:
|
||||||
|
lines = ["📦 پیشنویس کالای جدید", f"نام: {esc(payload.get('name'))}"]
|
||||||
|
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
|
||||||
|
if payload.get("topic"):
|
||||||
|
lines.append(f"تاپیک: {esc(payload['topic'])}")
|
||||||
|
lines.append("\nوضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str:
|
||||||
|
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
|
||||||
|
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||||||
|
if sup:
|
||||||
|
lines.append(f"تأمینکننده: {esc(sup)}")
|
||||||
|
items = created.get("line_items") or []
|
||||||
|
if items:
|
||||||
|
lines.append(f"اقلام: {len(items)}")
|
||||||
|
sheets = created.get("termsheets") or []
|
||||||
|
if sheets:
|
||||||
|
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||||
|
if payload.get("project_id") or payload.get("project_ids"):
|
||||||
|
lines.append("پروژه: پیوست شد")
|
||||||
|
|
||||||
|
if newly_created:
|
||||||
|
new_lines = []
|
||||||
|
if newly_created.get("supplier"):
|
||||||
|
new_lines.append(f"🏢 تأمینکننده جدید ثبت شد: {esc(newly_created['supplier'])}")
|
||||||
|
if newly_created.get("projects"):
|
||||||
|
new_lines.append(f"🏗️ پروژه(های) جدید ثبت شد: {esc('، '.join(newly_created['projects']))}")
|
||||||
|
if newly_created.get("materials"):
|
||||||
|
new_lines.append(f"📦 کالای جدید ثبت شد: {esc('، '.join(newly_created['materials']))}")
|
||||||
|
if new_lines:
|
||||||
|
lines.append("\n✨ موارد جدید ایجادشده:")
|
||||||
|
lines += new_lines
|
||||||
|
|
||||||
|
if remaining:
|
||||||
|
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||||
|
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||||||
|
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def project_review_text(payload: dict, missing: list[dict]) -> str:
|
||||||
|
lines = ["🏗️ پیشنویس پروژه جدید", f"نام: {esc(payload.get('name'))}"]
|
||||||
|
if payload.get("description"):
|
||||||
|
lines.append(f"توضیحات: {esc(payload['description'])}")
|
||||||
|
lines.append("\nوضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def project_committed_report(created: dict) -> str:
|
||||||
|
return f"✅ پروژه ثبت شد — {esc(created.get('name'))}"
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_committed_report(created: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
||||||
|
f"دسته: {esc((created.get('category') or {}).get('name'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def material_committed_report(created: dict) -> str:
|
||||||
|
return f"✅ کالا ثبت شد — {esc(created.get('name'))}"
|
||||||
|
|
||||||
|
|
||||||
|
def quote_line(quote: dict) -> str:
|
||||||
|
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
|
||||||
|
items = quote.get("line_items") or []
|
||||||
|
item_names = []
|
||||||
|
for it in items:
|
||||||
|
mat = (it.get("material") or {}).get("name")
|
||||||
|
if mat:
|
||||||
|
qty = it.get("quantity")
|
||||||
|
item_names.append(f"{mat} ({qty})" if qty else mat)
|
||||||
|
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
|
||||||
|
return (
|
||||||
|
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
|
||||||
|
f"{fa_date(quote.get('quote_date'))}{tail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
|
||||||
|
titles = {
|
||||||
|
"pending": "🔵 استعلامهای در انتظار بررسی",
|
||||||
|
"accepted": "🟢 پذیرفتهشده (در انتظار تحویل)",
|
||||||
|
"arrived": "✅ رسیده",
|
||||||
|
"expiring": "⏳ بهزودی منقضی میشوند (۷ روز آینده)",
|
||||||
|
"recent": "🕘 آخرین استعلامها",
|
||||||
|
}
|
||||||
|
if not quotes:
|
||||||
|
return f"{titles.get(kind, 'نتیجهای')}\nموردی نیست."
|
||||||
|
lines = [titles.get(kind, "استعلامها")]
|
||||||
|
if extra_note:
|
||||||
|
lines.append(extra_note)
|
||||||
|
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def ambiguity_message_text(title: str, details: str) -> str:
|
||||||
|
return (
|
||||||
|
f"🤔 آیا نظر شما این بود؟\n"
|
||||||
|
f"موضوع: {title}\n\n"
|
||||||
|
f"{details}"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -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.
@@ -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.
@@ -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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.bale.client import BaleClient
|
||||||
|
from app.api.client import ErpClient
|
||||||
|
from app.runtime import BotContext, LinkedUser
|
||||||
|
from app.nlu import understand
|
||||||
|
from app.handlers import quote
|
||||||
|
from app.defaults import AUTHORIZED_USERS
|
||||||
|
from app import drafts as draft_store
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s — %(message)s")
|
||||||
|
logger = logging.getLogger("send_analysis")
|
||||||
|
|
||||||
|
CHAT_ID = 1461166742
|
||||||
|
|
||||||
|
TRANSCRIPTS = [
|
||||||
|
{
|
||||||
|
"name": "مدل 1 (Whisper)",
|
||||||
|
"text": "الان شبت بخیر می خواستیم که بیست و پنج تا کیسه عرض به خدمت شما که گچ و خاک از مصطفوی بخریم و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی که اونا تقریبا در می آد پامون دونه ای بیست و پنج میلیون سیمانی ها پامون در می آد حدودا چهل میلیون اینا دونه ای بهمون گفته که می تونین سه قسط بهم بدید کلی پولش ها در طول زمان و یا بیاید یه بار پولش رو امروز بهم بدین یه تخفیف ده درصدی تقریبا براتون لحاظ می کنم عددش رو کم می کنم خریدمون هم عمدتا برای منشیانه و دفتر پیش فاکتورم گفته تا حدودا بیست روزی نامه براتون اوکیه همین",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "مدل 2 (Vosk)",
|
||||||
|
"text": "سلام شبت بخیر. میخواستیم که 25 تا کیسه عرض به خدمت شما که گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان 50 کیلویی که اونا تقریباً در میاد پامون دونهای 25 میلیون، سیمانیا پامون در میاد حدوداً 40 میلیون اینا دونهای. بهمون گفته که میتونین سه قسم بدین کلی پولشا در طول زمان و یا بیاین یه باره پولشو امروز بم بدین یه تخفیف 10 درصدی تقریباً بره دون، لحاظ میکنم عددشو کم میکنم. خریدم اونم اومدن برای منشیانه و دفتر. پیشفاکتورم گفته تا حدود یه 20 روز اینا برادون اوکیه. همین.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
print(f"Connecting to Bale and ERP API for Chat ID: {CHAT_ID}...")
|
||||||
|
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)
|
||||||
|
|
||||||
|
user_info = AUTHORIZED_USERS.get(CHAT_ID, {})
|
||||||
|
user = LinkedUser(
|
||||||
|
chat_id=CHAT_ID,
|
||||||
|
user_id=user_info.get("user_id"),
|
||||||
|
display_name=user_info.get("display_name", "مدیر تدارکات"),
|
||||||
|
role=user_info.get("role", "procurement_manager"),
|
||||||
|
is_admin=user_info.get("is_admin", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel previous active draft to ensure fresh card
|
||||||
|
active = await draft_store.get_active(CHAT_ID)
|
||||||
|
if active:
|
||||||
|
await draft_store.set_status(active.id, "cancelled")
|
||||||
|
|
||||||
|
print("Sending transcripts to NLU (LLM) with upgraded Isfahan dialect prompt...")
|
||||||
|
result = await understand(settings, TRANSCRIPTS)
|
||||||
|
print("NLU Result:")
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
consensus_text = result.get("consensus_text") or ""
|
||||||
|
patch = result.get("patch") or {}
|
||||||
|
|
||||||
|
intro_msg = (
|
||||||
|
f"🤖 **تحلیل هوشمند فایل صوتی (تجمیع خروجی چند مدل با لحاظ لهجه اصفهانی):**\n\n"
|
||||||
|
f"📝 **متن پالایششده و رسمی (Consensus):**\n«{consensus_text}»\n\n"
|
||||||
|
f"📋 پیشنویس استعلام خرید با مشخصات استخراجشده صادر گردید:"
|
||||||
|
)
|
||||||
|
await ctx.send(CHAT_ID, intro_msg)
|
||||||
|
|
||||||
|
# Create and render the new draft quote
|
||||||
|
await quote.handle_entities(
|
||||||
|
ctx,
|
||||||
|
CHAT_ID,
|
||||||
|
user,
|
||||||
|
patch,
|
||||||
|
source="voice",
|
||||||
|
)
|
||||||
|
print("Draft quote sent successfully to Bale chat!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""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 = 1461166742
|
||||||
|
|
||||||
|
_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 delete_message(self, chat_id, message_id):
|
||||||
|
return True
|
||||||
|
|
||||||
|
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.created_projects: 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 create_project(self, payload, actor_id=None):
|
||||||
|
self.created_projects.append(payload)
|
||||||
|
return {"id": f"prj-{len(self.created_projects)}", "name": payload["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, source_message_id=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,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
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_message_id(chat_id, message_id):
|
||||||
|
for draft in MEM["drafts"].values():
|
||||||
|
if draft.chat_id == chat_id and draft.status == "active":
|
||||||
|
if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id:
|
||||||
|
return draft
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_by_ambiguity_message_id(chat_id, message_id):
|
||||||
|
for draft in MEM["drafts"].values():
|
||||||
|
if draft.chat_id == chat_id and draft.status == "active":
|
||||||
|
for amb in (draft.payload or {}).get("ambiguities") or []:
|
||||||
|
if amb.get("message_id") == message_id:
|
||||||
|
return draft, amb
|
||||||
|
return None, 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, source_message_id=None, mark_bale_synced=False):
|
||||||
|
draft.missing = missing
|
||||||
|
if review_message_id:
|
||||||
|
draft.review_message_id = review_message_id
|
||||||
|
if source_message_id:
|
||||||
|
draft.source_message_id = source_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.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id
|
||||||
|
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 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
|
||||||
|
"پرداخت سه فاز")
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
|
||||||
|
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد 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
|
||||||
|
H.transcribe_multi = fake_transcribe_multi
|
||||||
|
return ctx, bale, api
|
||||||
|
|
||||||
|
|
||||||
|
def text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
|
||||||
|
cid = chat_id or CHAT
|
||||||
|
fuser = from_user or {"id": cid, "first_name": "مدیر تدارکات", "username": "proc_manager"}
|
||||||
|
msg = {"message_id": update_id, "chat": {"id": cid}, "text": text, "from": fuser}
|
||||||
|
if reply_to:
|
||||||
|
msg["reply_to_message"] = {"message_id": reply_to}
|
||||||
|
return {"update_id": update_id, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
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, understand
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# ---- pure logic -------------------------------------------------------
|
||||||
|
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
||||||
|
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
||||||
|
|
||||||
|
# ---- multi-STT understand pipeline support ---------------------------
|
||||||
|
multi_stt_samples = [
|
||||||
|
{"name": "whisper", "text": "25 کیسه گچ از مصطفوی"},
|
||||||
|
{"name": "vosk", "text": "بیست و پنج کیسه گچ از مصطفوی"},
|
||||||
|
]
|
||||||
|
stt_res = await understand(settings, multi_stt_samples)
|
||||||
|
check("multi-stt: pipeline accepted list input", isinstance(stt_res, dict) and "consensus_text" in stt_res)
|
||||||
|
check("multi-stt: consensus text generated", bool(stt_res.get("consensus_text")))
|
||||||
|
|
||||||
|
payload = new_quote_payload()
|
||||||
|
merge_quote_patch(payload, {
|
||||||
|
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||||
|
"payment_terms": [{"label": "۳ فاز"}],
|
||||||
|
})
|
||||||
|
missing = compute_quote_missing(payload)
|
||||||
|
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||||
|
merge_quote_patch(payload, {
|
||||||
|
"supplier_name": "سازان نو",
|
||||||
|
"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"] == "۳ فاز")
|
||||||
|
body, problems = build_quote_request(payload)
|
||||||
|
check("build: blocked without supplier", body is None)
|
||||||
|
|
||||||
|
# ---- pipeline: whitelist gate & unregistered blocking -----------------
|
||||||
|
ctx, bale, api = build_ctx()
|
||||||
|
UNAUTH_CHAT = 999999999
|
||||||
|
from app.handlers import UNREGISTERED_FILE
|
||||||
|
if UNREGISTERED_FILE.exists():
|
||||||
|
try:
|
||||||
|
UNREGISTERED_FILE.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 1. Unregistered user is blocked and recorded
|
||||||
|
await H.handle_update(
|
||||||
|
ctx,
|
||||||
|
text_update(
|
||||||
|
1,
|
||||||
|
"سلام من متقاضی جدید هستم",
|
||||||
|
chat_id=UNAUTH_CHAT,
|
||||||
|
from_user={"id": UNAUTH_CHAT, "first_name": "کاربر متفرقه", "username": "intruder"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
check("gate: unauthorized user blocked", "دسترسی شما هنوز تعریف نشده است" in bale.last_text())
|
||||||
|
|
||||||
|
import json
|
||||||
|
unreg_records = json.loads(UNREGISTERED_FILE.read_text(encoding="utf-8")) if UNREGISTERED_FILE.exists() else []
|
||||||
|
check(
|
||||||
|
"gate: unregistered logged to JSON file",
|
||||||
|
any(u.get("chat_id") == UNAUTH_CHAT and u.get("first_name") == "کاربر متفرقه" for u in unreg_records),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Whitelisted user (1461166742) bypasses Casdoor completely
|
||||||
|
await H.handle_update(ctx, text_update(2, "/whoami"))
|
||||||
|
check("gate: whitelisted user active without casdoor", "مدیر تدارکات" in bale.last_text())
|
||||||
|
check("gate: no casdoor login prompted", "نام کاربری Casdoor" not 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, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ---- new projects in quote + auto-creation on commit --------------------
|
||||||
|
NLU_QUEUE.append({
|
||||||
|
"intent": "register_quote",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"patch": {
|
||||||
|
"supplier_name": "سازان نو",
|
||||||
|
"project_names": ["منشانه", "دفتر"],
|
||||||
|
"items": [
|
||||||
|
{"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"},
|
||||||
|
{"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر"))
|
||||||
|
q_draft = await _active("quote")
|
||||||
|
check("multi-project: draft created", q_draft is not None)
|
||||||
|
check("multi-project: no bogus match to برج آسمان",
|
||||||
|
"برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {}))
|
||||||
|
check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text())
|
||||||
|
await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}"))
|
||||||
|
created_prj_names = [p["name"] for p in api.created_projects]
|
||||||
|
check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names)
|
||||||
|
check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- add project standalone --------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9,
|
||||||
|
"patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}})
|
||||||
|
await H.handle_update(ctx, text_update(20, "پروژه جدید امید"))
|
||||||
|
p_draft = await _active("project")
|
||||||
|
check("project: draft created", p_draft is not None)
|
||||||
|
await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}"))
|
||||||
|
check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects))
|
||||||
|
check("project: report sent", "پروژه ثبت شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- cancel / unknown --------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||||
|
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
|
||||||
|
await H.handle_update(ctx, text_update(23, "/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(24, "اسپاگتی بپز"))
|
||||||
|
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()))
|
||||||
+37
-1
@@ -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:
|
||||||
@@ -311,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 روی شبکهی
|
||||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+16
-3
@@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,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="/"
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ 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="flex items-start gap-3 w-full my-2">
|
<div className="flex items-start gap-2 w-full my-2">
|
||||||
{/* کادر انتخاب تاریخ */}
|
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||||
<div className="w-36 shrink-0">
|
<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 })}
|
||||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
|
|||||||
@@ -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,17 +96,12 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" ||
|
||||||
|
|||||||
@@ -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";
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+78
-116
@@ -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";
|
||||||
|
|
||||||
@@ -252,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;
|
||||||
@@ -322,7 +316,6 @@ 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) — بکاند این را میفرستد
|
// v2.6: هر تأمینکننده دقیقاً یک دسته دارد (FK) — بکاند این را میفرستد
|
||||||
category_id?: string | null;
|
category_id?: string | null;
|
||||||
category?: { id: string; slug: string; name: string } | null;
|
category?: { id: string; slug: string; name: string } | null;
|
||||||
@@ -402,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;
|
||||||
@@ -562,9 +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) || "" : ""),
|
||||||
// v2.7: دستهی متریال در بکاند از نخستین تأمینکنندهی متصل میآید
|
|
||||||
// (متریالِ بدون تأمینکننده → خالی)
|
|
||||||
supplier_category: raw.category?.name || "",
|
|
||||||
brand: raw.brands?.[0]?.name || "",
|
brand: raw.brands?.[0]?.name || "",
|
||||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||||
specs: (raw.attributes || [])
|
specs: (raw.attributes || [])
|
||||||
@@ -645,15 +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 {
|
||||||
// v2.6/v2.7: هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل
|
// هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل category
|
||||||
// category برمیگرداند — همین اولویت اول است. categories قدیمی (ستون حذفشده)
|
// برمیگرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
|
||||||
// و دستههای استنتاجشده از پیشنهادها فقط پشتیباناند.
|
|
||||||
const own = raw.category?.name ? [raw.category.name] : [];
|
const own = raw.category?.name ? [raw.category.name] : [];
|
||||||
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
|
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
topic,
|
topic,
|
||||||
categories: own.length ? own : legacy.length ? legacy : categories,
|
categories: own.length ? own : categories,
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
location: raw.location || "",
|
location: raw.location || "",
|
||||||
phone: raw.phone || "",
|
phone: raw.phone || "",
|
||||||
@@ -671,54 +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.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_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] : "";
|
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||||
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
|
||||||
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||||
if (!derivedTopic && !derivedCats.length) {
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
// دستهٔ ثبتشده روی خودِ تأمینکننده (FK بکاند) منبع اصلی است و نامش با نام
|
||||||
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
// تاپیکها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج میشود.
|
||||||
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
|
||||||
const backendCat = s.category?.name || "";
|
const backendCat = s.category?.name || "";
|
||||||
return adaptSupplier(
|
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
|
||||||
s,
|
|
||||||
derivedTopic || backendCat,
|
|
||||||
derivedCats.length ? derivedCats : 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 };
|
||||||
}
|
}
|
||||||
@@ -836,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}`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -860,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),
|
||||||
};
|
};
|
||||||
@@ -1076,6 +1020,73 @@ 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`, {});
|
||||||
|
},
|
||||||
|
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: {
|
||||||
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}`);
|
||||||
@@ -1136,7 +1147,7 @@ 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)));
|
||||||
@@ -1196,46 +1207,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);
|
||||||
@@ -1781,15 +1752,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: {
|
||||||
|
|||||||
@@ -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: "",
|
||||||
supplier_category: "",
|
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
base_unit: "",
|
base_unit: "",
|
||||||
@@ -102,15 +101,14 @@ export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | nu
|
|||||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
// name را به شکل «برند + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا نامِ
|
||||||
// نامِ پایه باقی بماند. دستهی همان رکوردهای قدیمی همان دستهی نخستین
|
// پایه باقی بماند.
|
||||||
// تأمینکننده است (supplier_category).
|
export function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||||
export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
|
||||||
const fallback = (name || "").trim();
|
const fallback = (name || "").trim();
|
||||||
if (!fallback) return "";
|
if (!fallback) return "";
|
||||||
let base = fallback;
|
let base = fallback;
|
||||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||||
const parts = [brand, category, ...specs]
|
const parts = [brand, ...specs]
|
||||||
.map((p) => (p || "").trim())
|
.map((p) => (p || "").trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a, b) => b.length - a.length);
|
.sort((a, b) => b.length - a.length);
|
||||||
@@ -151,7 +149,7 @@ export function matchMaterial(
|
|||||||
materials.find(
|
materials.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
base &&
|
base &&
|
||||||
normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base &&
|
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
|
||||||
sameIdentity(m),
|
sameIdentity(m),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -216,57 +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);
|
|
||||||
// categoryUnits/unitConversions با «نام دسته» کلید خوردهاند؛ دستهی متریال
|
|
||||||
// همان دستهی نخستین تأمینکننده است (v2.7). متریال بدون تأمینکننده دسته
|
|
||||||
// ندارد و تبدیلش انجام نمیشود.
|
|
||||||
const ref = refUnitForCategory(store, m.supplier_category);
|
|
||||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
|
||||||
const conv = findConversion(store, m.supplier_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 }[] {
|
||||||
@@ -336,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();
|
||||||
@@ -361,15 +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));
|
||||||
// v2.7: دسته روی تأمینکننده است و بکاند هم پیشنهادها را با دستهی
|
|
||||||
// تأمینکننده فیلتر میکند — همین قاعده اینجا هم اعمال میشود تا لیست و
|
|
||||||
// فیلتر یک رفتار داشته باشند.
|
|
||||||
if (catVal && !(supObj.categories || []).includes(catVal)) return false;
|
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay =
|
const hay =
|
||||||
`${supObj.name} ${quote.project || ""} ` +
|
`${supObj.name} ${quote.project || ""} ` +
|
||||||
matInfo.map((m) => `${m.supplier_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;
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ export interface Material {
|
|||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||||
topic: string;
|
topic: string;
|
||||||
// دستهی نمایشی — از نخستین تأمینکنندهی متصل به متریال میآید (v2.7:
|
|
||||||
// خودِ متریال دیگر دسته ندارد و دسته روی تأمینکننده است). متریال بدون
|
|
||||||
// تأمینکننده = رشتهی خالی. این مقدار کلیدِ تبدیلهای واحد هم هست.
|
|
||||||
supplier_category: string;
|
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
@@ -100,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;
|
||||||
@@ -131,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(user.role === "pm" ? "/inventory" : "/");
|
navigate(decodeURIComponent(returnUrl));
|
||||||
|
} else {
|
||||||
|
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||||
|
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("/");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ 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();
|
||||||
@@ -28,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);
|
||||||
@@ -40,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)
|
||||||
@@ -80,12 +80,6 @@ export function DashboardPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// دستهها روی تأمینکننده هستند (v2.7) و بکاند هم فیلتر پیشنهادها را با
|
|
||||||
// دستهی تأمینکننده انجام میدهد — پس گزینههای فیلتر از همانجا میآید.
|
|
||||||
const categoryOptions = useMemo(
|
|
||||||
() => [...new Set(suppliers.flatMap((s) => s.categories || []).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fa")),
|
|
||||||
[suppliers],
|
|
||||||
);
|
|
||||||
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 (
|
||||||
@@ -143,7 +137,6 @@ export function DashboardPage() {
|
|||||||
<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"))}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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,
|
||||||
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
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);
|
||||||
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
|
|
||||||
// دسته روی تأمینکننده است و دیگر برچسبِ این فیلد نیست)
|
|
||||||
cat: m.name || "",
|
cat: m.name || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
@@ -191,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: [],
|
||||||
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -234,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 () => {
|
||||||
@@ -266,6 +319,7 @@ 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;
|
||||||
|
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
@@ -276,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";
|
||||||
@@ -295,7 +350,6 @@ 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 = matchMaterial(materialsCopy, cat, brand, specs);
|
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material) {
|
||||||
@@ -308,7 +362,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
supplier_category: "",
|
|
||||||
};
|
};
|
||||||
materialsCopy.push(nm);
|
materialsCopy.push(nm);
|
||||||
const created = await addMaterial.mutateAsync(nm);
|
const created = await addMaterial.mutateAsync(nm);
|
||||||
@@ -323,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);
|
||||||
@@ -360,7 +413,7 @@ 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;
|
||||||
@@ -407,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<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">
|
<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 className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
<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>
|
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
@@ -422,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -493,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
{/* محتوا */}
|
||||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||||
{/* تب ۱: اقلام و مشخصات */}
|
{/* تب ۱: اقلام */}
|
||||||
{activeTab === "items" && (
|
{activeTab === "items" && (
|
||||||
<div className="space-y-4">
|
<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="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">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
@@ -537,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اقلام */}
|
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||||
@@ -574,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
{/* ردیف ۱: متریال و برند */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
<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-gray-200 bg-white px-2.5 text-xs 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"
|
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">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
<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-gray-200 bg-white px-2.5 text-xs 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"
|
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 })}
|
||||||
@@ -598,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
{/* ردیف ۲: مشخصه فنی (تمامعرض با فضای کافی برای تگها) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||||
<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 className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-gray-600">واحد</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 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>
|
||||||
|
|
||||||
{/* جعبه ۳ عددی مقادیر */}
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
<NumberInput
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
disabled={hasReceipts}
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||||
value={row.qty}
|
<NumberInput
|
||||||
onChange={(qty) => {
|
disabled={hasReceipts}
|
||||||
patchItem(row.key, {
|
value={row.qty}
|
||||||
qty,
|
onChange={(qty) => {
|
||||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
patchItem(row.key, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||||
placeholder="تعداد"
|
});
|
||||||
/>
|
}}
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
<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="flex flex-col gap-1">
|
{/* ردیف ۵: قیمت کل این قلم (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
<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">قیمت کل این قلم (تومان)</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -667,14 +730,15 @@ 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>
|
</div>
|
||||||
|
|
||||||
{/* شرایط پرداخت برای این قلم */}
|
{row.rejected && (
|
||||||
<div className="pt-2 border-t border-gray-100">
|
<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="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -684,44 +748,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
بازگرداندن این قلم به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tsCards.map((card, cIdx) => {
|
|
||||||
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
|
||||||
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
|
||||||
hasReceipts && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
|
||||||
checked={isSelected}
|
|
||||||
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] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{tsLabel(card, cIdx)}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -742,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تب ۲: شرایط پرداخت */}
|
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||||
{activeTab === "terms" && (
|
{activeTab === "terms" && (
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||||
|
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -763,70 +796,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
index={idx + 1}
|
||||||
>
|
milestones={card.milestones}
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||||
<div className="flex items-center gap-1.5">
|
items={itemOptions}
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
coveredIds={getCoveredIds(card)}
|
||||||
{faNum(idx + 1)}
|
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||||
</span>
|
onRemove={() => removeTsCard(card.key)}
|
||||||
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
/>
|
||||||
<span className="text-[11px] text-gray-500 font-medium">
|
|
||||||
({faNum(card.milestones.length)} مرحله)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!hasReceipts && tsCards.length > 1 && (
|
|
||||||
<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={() => removeTsCard(card.key)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[11px]">حذف</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
|
||||||
{card.milestones.map((ms, i) => (
|
|
||||||
<MilestoneRow
|
|
||||||
key={i}
|
|
||||||
milestone={ms}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(nextMs) =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onRemove={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.filter((_, j) => j !== i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasReceipts && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="line"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto text-xs text-gray-700"
|
|
||||||
onClick={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 me-1" />
|
|
||||||
افزودن مرحله پرداخت
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -892,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</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
|
||||||
|
files={proformas}
|
||||||
|
onChange={setProformas}
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر سفید، محکم و چسبان */}
|
{/* فوتر */}
|
||||||
<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">
|
<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
|
||||||
@@ -925,7 +923,7 @@ 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 bg-white border border-gray-200 text-gray-900">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
|||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
|
||||||
supplier: string;
|
supplier: string;
|
||||||
status: string;
|
status: string;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -40,7 +39,6 @@ const STATUS_TO_KEY: Record<string, string> = {
|
|||||||
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;
|
||||||
@@ -49,7 +47,6 @@ export interface FilterToolbarProps {
|
|||||||
export function FilterToolbar({
|
export function FilterToolbar({
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
categories,
|
|
||||||
suppliers,
|
suppliers,
|
||||||
sortDir,
|
sortDir,
|
||||||
onToggleSort,
|
onToggleSort,
|
||||||
@@ -58,10 +55,10 @@ 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: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||||
@@ -153,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
|
||||||
@@ -204,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
|
||||||
|
|||||||
@@ -35,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);
|
||||||
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
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" />
|
||||||
@@ -134,53 +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 min-w-0 items-center gap-1.5">
|
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||||
|
<div className="flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
className="min-w-0 flex-1 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>
|
||||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
|
||||||
q.is_official
|
|
||||||
? "bg-green-bg text-green"
|
|
||||||
: "bg-slate-bg text-muted",
|
|
||||||
)}
|
|
||||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
|
||||||
>
|
|
||||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
|
||||||
|
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||||
|
<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>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
|
||||||
|
<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")} />
|
||||||
@@ -188,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" />
|
||||||
@@ -200,7 +233,7 @@ 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">
|
||||||
<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 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
{activeItems.length > 0 ? (
|
{activeItems.length > 0 ? (
|
||||||
@@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<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)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
@@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
|
{/* ۳. مجموع مبلغ */}
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -286,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,7 +7,6 @@ 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";
|
||||||
@@ -76,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);
|
|
||||||
|
|
||||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||||
@@ -140,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 : "—";
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
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";
|
||||||
@@ -81,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("");
|
||||||
@@ -189,17 +188,10 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,21 +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);
|
|
||||||
// دستهی نمایشی متریال = دستهی نخستین تأمینکنندهی متصل (v2.7)
|
|
||||||
if (!m || !m.supplier_category) return;
|
|
||||||
out.add(m.supplier_category);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}, [quotes, materials, id]);
|
|
||||||
|
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return (
|
return (
|
||||||
@@ -81,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 () => {
|
||||||
@@ -129,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 gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xl font-extrabold">{s.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">{topicName}</Badge>
|
<div className="text-xl font-extrabold">{s.name}</div>
|
||||||
|
<Badge variant="outline">{topicName}</Badge>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
|
||||||
|
<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)}`}
|
||||||
@@ -164,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" /> متریالهای تأمینشده
|
||||||
@@ -204,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>
|
||||||
@@ -221,4 +260,4 @@ export function SupplierDetailPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و 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 suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
return suppliers.filter(
|
return suppliers.filter(
|
||||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||||
@@ -29,7 +38,11 @@ export function SupplierCard({
|
|||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head flex items-start justify-between gap-2">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||||
|
<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">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -49,17 +62,34 @@ export function SupplierCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
<span>
|
|
||||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||||
</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>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||||
|
</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">
|
||||||
@@ -71,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" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export interface ItemDraft {
|
export interface ItemDraft {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
@@ -51,7 +53,6 @@ export function materialOptions(
|
|||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
// متریالها سراسری هستند — مستقل از تاپیک
|
|
||||||
let list = materials;
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
@@ -67,21 +68,17 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و متریال
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
|
||||||
// همنام پیشنهاد میشوند.
|
|
||||||
if (cat)
|
if (cat)
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat),
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
);
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
apiSpecs.forEach((s) => s && base.add(s));
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
@@ -90,7 +87,7 @@ export function materialOptions(
|
|||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
// مرحله ۲ — مدیریت اقلام سفارش
|
||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
@@ -109,7 +106,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
const allMaterialNames = useMemo(() => {
|
const allMaterialNames = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
materials.forEach((m) => {
|
materials.forEach((m) => {
|
||||||
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category);
|
const base = materialBaseName(m.name, m.brand, m.specs);
|
||||||
if (base) set.add(base);
|
if (base) set.add(base);
|
||||||
});
|
});
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
@@ -167,146 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
|
||||||
// همان هویتِ متریال انجام میشود، نه دسته.
|
|
||||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
{/* هدر */}
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
<div className="space-y-1">
|
||||||
اقلام سفارش
|
<div className="flex items-center justify-between">
|
||||||
</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||||
|
۲
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
</span>
|
||||||
<span>متریال</span>
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||||
<span>برند</span>
|
اقلام سفارش
|
||||||
<span>مشخصه</span>
|
</h3>
|
||||||
<span>تعداد</span>
|
</div>
|
||||||
<span>واحد</span>
|
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||||
<span>قیمت واحد</span>
|
{faNum(items.length)} قلم
|
||||||
<span>قیمت کل</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||||
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-field="line_items" className="flex flex-col gap-2">
|
{/* لیست کارتهای اقلام */}
|
||||||
{items.map((it) => (
|
<div data-field="line_items" className="flex flex-col gap-3">
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
{items.map((it, idx) => (
|
||||||
<Combobox
|
<div
|
||||||
value={it.cat}
|
key={it.tempId}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||||
items={allMaterialNames}
|
>
|
||||||
allowAdd
|
{/* سربرگ قلم */}
|
||||||
addSuffix="به عنوان متریال جدید"
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||||
placeholder="متریال"
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
{faNum(idx + 1)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
</span>
|
||||||
className="min-w-0"
|
<span className="text-xs font-bold text-ink truncate">
|
||||||
/>
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
<Combobox
|
</span>
|
||||||
value={it.brand}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
{items.length > 1 && (
|
||||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
<button
|
||||||
allowAdd
|
type="button"
|
||||||
addSuffix="به عنوان برند جدید"
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
placeholder="برند"
|
title="حذف قلم"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onClick={() => remove(it.tempId)}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
>
|
||||||
inputClassName="!text-[12px] !h-9"
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
className="min-w-0"
|
<span className="text-[11px]">حذف</span>
|
||||||
/>
|
</button>
|
||||||
<MultiSelectCombobox
|
)}
|
||||||
values={it.specs}
|
</div>
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
{/* ردیف ۱: متریال و برند */}
|
||||||
placeholder="مشخصه"
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||||
<NumberInput
|
<Combobox
|
||||||
value={it.qty}
|
value={it.cat}
|
||||||
onChange={(qty) => {
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
patch(it.tempId, {
|
items={allMaterialNames}
|
||||||
qty,
|
allowAdd
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
addSuffix="به عنوان متریال جدید"
|
||||||
});
|
placeholder="نام متریال"
|
||||||
}}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
placeholder="تعداد"
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
showWords
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
/>
|
className="min-w-0"
|
||||||
<Combobox
|
/>
|
||||||
value={it.unit}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
allowAdd
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||||
addSuffix="به عنوان واحد جدید"
|
<Combobox
|
||||||
placeholder="واحد"
|
value={it.brand}
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
onAdd={(v) => {
|
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||||
const name = v.trim();
|
allowAdd
|
||||||
if (!name) return;
|
addSuffix="به عنوان برند جدید"
|
||||||
void apiV26.catalog
|
placeholder="برند"
|
||||||
.createUnit(name)
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
.then(() => {
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
setUnitOptions((prev) =>
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
className="min-w-0"
|
||||||
);
|
/>
|
||||||
})
|
</div>
|
||||||
.catch(() => {});
|
</div>
|
||||||
}}
|
|
||||||
inputClassName="!text-[12px] !h-9"
|
{/* ردیف ۲: مشخصه فنی */}
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||||
<NumberInput
|
<MultiSelectCombobox
|
||||||
value={it.price}
|
values={it.specs}
|
||||||
onChange={(price) => {
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
patch(it.tempId, {
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
price,
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
className="min-w-0"
|
||||||
});
|
/>
|
||||||
}}
|
</div>
|
||||||
placeholder="قیمت واحد"
|
|
||||||
showWords
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
maxDecimals={2}
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||||
/>
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<NumberInput
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
value={it.total}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
onChange={(total) => {
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||||
patch(it.tempId, {
|
<NumberInput
|
||||||
total,
|
value={it.qty}
|
||||||
price:
|
onChange={(qty) => {
|
||||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
patch(it.tempId, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||||
placeholder="قیمت کل"
|
});
|
||||||
showWords
|
}}
|
||||||
/>
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
/>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
title="حذف قلم"
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||||
onClick={() => remove(it.tempId)}
|
<Combobox
|
||||||
>
|
value={it.unit}
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
</svg>
|
allowAdd
|
||||||
</button>
|
addSuffix="به عنوان واحد جدید"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
|
onAdd={(v) => {
|
||||||
|
const name = v.trim();
|
||||||
|
if (!name) return;
|
||||||
|
void apiV26.catalog
|
||||||
|
.createUnit(name)
|
||||||
|
.then(() => {
|
||||||
|
setUnitOptions((prev) =>
|
||||||
|
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}}
|
||||||
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||||
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.price}
|
||||||
|
onChange={(price) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
price,
|
||||||
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت هر واحد"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.total}
|
||||||
|
onChange={(total) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
total,
|
||||||
|
price:
|
||||||
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت کل قلم"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
{/* دکمه افزودن قلم جدید */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
>
|
>
|
||||||
<span className="text-base font-bold leading-none">+</span>
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن قلم جدید</span>
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,43 +9,61 @@ export interface StepperProps {
|
|||||||
onGoto: (step: number) => void;
|
onGoto: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
const STEPS = [
|
||||||
|
{ id: 1, label: "تأمینکننده" },
|
||||||
|
{ id: 2, label: "اقلام" },
|
||||||
|
{ id: 3, label: "شرایط پرداخت" },
|
||||||
|
{ id: 4, label: "پروژه" },
|
||||||
|
{ id: 5, label: "بازبینی و ثبت" },
|
||||||
|
];
|
||||||
|
|
||||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
|
||||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||||
{STEPS.map((label, i) => {
|
{STEPS.map(({ id: step, label }) => {
|
||||||
const step = i + 1;
|
|
||||||
const isCurrent = step === current;
|
const isCurrent = step === current;
|
||||||
const isComplete = completedSteps.has(step);
|
const isComplete = completedSteps.has(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step}
|
key={step}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||||
|
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-[#2563eb] font-bold text-white"
|
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||||
: isComplete
|
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||||
? "bg-green-bg font-medium text-green"
|
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||||
: "hover:bg-slate-bg",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onGoto(step)}
|
onClick={() => onGoto(step)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
isCurrent
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: isComplete
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { track } from "../../lib/analytics";
|
import { track } from "../../lib/analytics";
|
||||||
import { Link, 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();
|
||||||
@@ -82,6 +182,120 @@ export function WizardPage() {
|
|||||||
const [proforma, setProforma] = useState<File | null>(null);
|
const [proforma, setProforma] = useState<File | null>(null);
|
||||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [loadedDraftId, setLoadedDraftId] = useState<string | null>(null);
|
||||||
|
const draftFetchedRef = useRef(false);
|
||||||
|
const lastRemoteUpdatedAtRef = useRef<string | null>(null);
|
||||||
|
const isSelfUpdatingRef = useRef<boolean>(false);
|
||||||
|
const isCommittedOrCancelledRef = useRef<boolean>(false);
|
||||||
|
const initialLoadedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// ---------- تابع اعمال دادههای پیشنویس به فیلدهای ویزارد ----------
|
||||||
|
const applyDraftPayloadToForm = useCallback(
|
||||||
|
(p: Record<string, any>) => {
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
// ۱. تأمینکننده و وضعیت رسمی
|
||||||
|
if (p.supplier_name !== undefined) {
|
||||||
|
setSupplierName(p.supplier_name || "");
|
||||||
|
setSupplierInvalid(false);
|
||||||
|
}
|
||||||
|
if (p.is_official !== undefined) {
|
||||||
|
setIsOfficial(Boolean(p.is_official));
|
||||||
|
}
|
||||||
|
|
||||||
|
// استنتاج تاپیک: ابتدا از اطلاعات تأمینکننده، سپس متریال، سپس اولین تاپیک موجود
|
||||||
|
let topicToSet = "";
|
||||||
|
if (p.supplier_name) {
|
||||||
|
const sup = suppliers.find((s) => normalize(s.name) === normalize(p.supplier_name));
|
||||||
|
if (sup?.topic) topicToSet = sup.topic;
|
||||||
|
}
|
||||||
|
if (!topicToSet && Array.isArray(p.items) && p.items.length > 0) {
|
||||||
|
for (const it of p.items) {
|
||||||
|
if (it.material_name) {
|
||||||
|
const mat = materials.find((m) => normalize(m.name) === normalize(it.material_name));
|
||||||
|
if (mat?.topic) {
|
||||||
|
topicToSet = mat.topic;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!topicToSet && topics.length > 0) {
|
||||||
|
topicToSet = topics[0].name;
|
||||||
|
}
|
||||||
|
if (topicToSet) {
|
||||||
|
setSelectedTopic(topicToSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. اقلام (Step 2)
|
||||||
|
let newItems: ItemDraft[] = [];
|
||||||
|
if (Array.isArray(p.items) && p.items.length > 0) {
|
||||||
|
newItems = p.items.map((it: any, idx: number) => {
|
||||||
|
const qty = it.quantity != null ? Number(it.quantity) : null;
|
||||||
|
const price = it.unit_price != null ? Number(it.unit_price) : null;
|
||||||
|
return {
|
||||||
|
tempId: "it" + idx,
|
||||||
|
cat: it.material_name || "",
|
||||||
|
brand: it.brand_name || "",
|
||||||
|
specs: Array.isArray(it.specs) ? it.specs : [],
|
||||||
|
qty,
|
||||||
|
unit: it.unit_name || "",
|
||||||
|
price,
|
||||||
|
total: qty != null && price != null ? qty * price : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setItems(newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. شرایط پرداخت / termsheet (Step 3)
|
||||||
|
if (Array.isArray(p.termsheets) && p.termsheets.length > 0) {
|
||||||
|
const newCards: TsCardDraft[] = p.termsheets.map((ts: any, idx: number) => ({
|
||||||
|
key: "ts" + idx,
|
||||||
|
milestones:
|
||||||
|
Array.isArray(ts.milestones) && ts.milestones.length > 0
|
||||||
|
? ts.milestones.map((m: any) => ({
|
||||||
|
when_iso: m.due_date || m.when_iso || null,
|
||||||
|
amount: m.amount != null ? Number(m.amount) : null,
|
||||||
|
}))
|
||||||
|
: [{ when_iso: null, amount: null }],
|
||||||
|
}));
|
||||||
|
setCards(newCards);
|
||||||
|
|
||||||
|
const targetIds = (newItems.length > 0 ? newItems : items).map((i) => i.tempId);
|
||||||
|
const newCov: Record<string, string[]> = {};
|
||||||
|
newCards.forEach((c) => {
|
||||||
|
newCov[c.key] = targetIds;
|
||||||
|
});
|
||||||
|
setCoverage(newCov);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. پروژه (Step 4)
|
||||||
|
const projNames: string[] = [];
|
||||||
|
if (Array.isArray(p.project_names)) {
|
||||||
|
projNames.push(...p.project_names.filter(Boolean));
|
||||||
|
}
|
||||||
|
if (p.project_name && !projNames.includes(p.project_name)) {
|
||||||
|
projNames.push(p.project_name);
|
||||||
|
}
|
||||||
|
if (projNames.length > 0) {
|
||||||
|
const newAssignment: Record<string, string[]> = {};
|
||||||
|
const targetItems = newItems.length > 0 ? newItems : items;
|
||||||
|
targetItems.forEach((it) => {
|
||||||
|
newAssignment[it.tempId] = projNames;
|
||||||
|
});
|
||||||
|
setAssignment(newAssignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۵. یادداشت و تاریخ انقضا (Step 5)
|
||||||
|
if (p.notes !== undefined) {
|
||||||
|
setNotes(p.notes || "");
|
||||||
|
}
|
||||||
|
if (p.expiry_date !== undefined) {
|
||||||
|
setExpiryDate(p.expiry_date || null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[suppliers, materials, topics, items],
|
||||||
|
);
|
||||||
|
|
||||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,6 +314,165 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
}, [params, suppliers, topics]);
|
}, [params, suppliers, topics]);
|
||||||
|
|
||||||
|
// ---------- بارگذاری اولیه پیشنویس ربات بله (?token= یا ?draft=) ----------
|
||||||
|
useEffect(() => {
|
||||||
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
|
if ((!token && !draftId) || draftFetchedRef.current) return;
|
||||||
|
|
||||||
|
draftFetchedRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
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 || !draft.payload) {
|
||||||
|
if (token) setTokenError("پیشنویس معتبری یافت نشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadedDraftId(draft.id);
|
||||||
|
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
||||||
|
|
||||||
|
applyDraftPayloadToForm(draft.payload);
|
||||||
|
setHighestVisited(5);
|
||||||
|
initialLoadedRef.current = true;
|
||||||
|
|
||||||
|
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (token) {
|
||||||
|
setTokenError(err?.message || "لینک بازبینی نامعتبر یا منقضی شده است.");
|
||||||
|
} else {
|
||||||
|
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [params, token, applyDraftPayloadToForm]);
|
||||||
|
|
||||||
|
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
||||||
|
useEffect(() => {
|
||||||
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
|
if (!token && !draftId) return;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||||
|
try {
|
||||||
|
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.status === "committed") {
|
||||||
|
isCommittedOrCancelledRef.current = true;
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "این پیشنهاد ثبت نهایی شد ✅",
|
||||||
|
subtitle: "استعلام با موفقیت در سیستم ثبت گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
||||||
|
navigate("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.status === "cancelled") {
|
||||||
|
isCommittedOrCancelledRef.current = true;
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "پیشنویس لغو شد",
|
||||||
|
subtitle: "این پیشنویس از طریق ربات بله لغو گردید. میتوانید این برگه را ببندید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
||||||
|
navigate("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
draft.updated_at &&
|
||||||
|
lastRemoteUpdatedAtRef.current &&
|
||||||
|
draft.updated_at > lastRemoteUpdatedAtRef.current &&
|
||||||
|
!isSelfUpdatingRef.current
|
||||||
|
) {
|
||||||
|
lastRemoteUpdatedAtRef.current = draft.updated_at;
|
||||||
|
applyDraftPayloadToForm(draft.payload);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* خطای موقت شبکه در پولینگ نادیده گرفته میشود */
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [params, token, isStandaloneMode, navigate, applyDraftPayloadToForm]);
|
||||||
|
|
||||||
|
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
||||||
|
useEffect(() => {
|
||||||
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
|
if ((!token && !draftId) || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
isSelfUpdatingRef.current = true;
|
||||||
|
try {
|
||||||
|
const payloadToSave: Record<string, any> = {
|
||||||
|
supplier_name: supplierName.trim() || null,
|
||||||
|
is_official: isOfficial,
|
||||||
|
items: items.map((it) => ({
|
||||||
|
material_name: it.cat.trim() || null,
|
||||||
|
brand_name: it.brand.trim() || null,
|
||||||
|
specs: it.specs,
|
||||||
|
quantity: it.qty,
|
||||||
|
unit_name: it.unit.trim() || null,
|
||||||
|
unit_price: it.price,
|
||||||
|
})),
|
||||||
|
termsheets: cards.map((c, i) => ({
|
||||||
|
label: "گزینه " + faNum(i + 1),
|
||||||
|
milestones: (c.milestones || []).map((m) => ({
|
||||||
|
due_date: m.when_iso,
|
||||||
|
amount: m.amount,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
project_names: [...new Set(Object.values(assignment).flat())],
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
expiry_date: expiryDate || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await api.drafts.updateByToken(token, payloadToSave);
|
||||||
|
const res = await api.drafts.verifyToken(token);
|
||||||
|
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) {
|
||||||
|
console.warn("Auto-save to draft failed:", err);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isSelfUpdatingRef.current = false;
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params, token]);
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
const completedSteps = useMemo(() => {
|
const completedSteps = useMemo(() => {
|
||||||
const s = new Set<number>();
|
const s = new Set<number>();
|
||||||
@@ -127,11 +500,9 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (_rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(projects.map((p) => p.name));
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -159,7 +530,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- خلاصهی مرحله ۵ ----------
|
// ---------- خلاصه مرحله ۵ ----------
|
||||||
const review = useMemo(() => {
|
const review = useMemo(() => {
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
@@ -202,23 +573,14 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const combined = `${field} ${msg}`.toLowerCase();
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
// ۱. تأمینکننده
|
|
||||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
|
||||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
return { step: 2, field: field || "line_items" };
|
return { step: 2, field: field || "line_items" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
|
||||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
return { step: 3, field: field || "termsheets" };
|
return { step: 3, field: field || "termsheets" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۴. پروژه
|
|
||||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
|
||||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
@@ -291,10 +653,6 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
|
||||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
|
||||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
|
||||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
const matched = matchMaterial(
|
const matched = matchMaterial(
|
||||||
@@ -317,7 +675,6 @@ export function WizardPage() {
|
|||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit || "عدد",
|
base_unit: unit || "عدد",
|
||||||
supplier_category: "",
|
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -394,6 +751,23 @@ 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");
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
await api.drafts.commit(draftId, created.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not commit draft:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (proforma) {
|
if (proforma) {
|
||||||
try {
|
try {
|
||||||
await api.files.upload(created.id, "proforma", proforma);
|
await api.files.upload(created.id, "proforma", proforma);
|
||||||
@@ -403,8 +777,17 @@ 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);
|
||||||
@@ -412,6 +795,34 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
const token = params.get("token");
|
||||||
|
const draftId = params.get("draft") || params.get("draft_id");
|
||||||
|
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 {
|
||||||
|
await api.drafts.cancel(draftId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Could not cancel draft:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isStandaloneMode) {
|
||||||
|
setExitInfo({
|
||||||
|
show: true,
|
||||||
|
title: "پیشنویس لغو شد",
|
||||||
|
subtitle: "این پیشنویس استعلام لغو گردید. میتوانید این برگه را ببندید و به ربات بله بازگردید.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const incomplete: { label: string; step: number }[] = [];
|
const incomplete: { label: string; step: number }[] = [];
|
||||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||||
@@ -426,19 +837,73 @@ 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">
|
<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 && (
|
||||||
|
<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="flex items-center gap-2">
|
||||||
|
<span className="inline-flex h-6 px-2.5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||||
|
ربات بله
|
||||||
|
</span>
|
||||||
|
<span>اطلاعات پیشنویس استعلام با هوش مصنوعی بارگذاری شد. لطفاً اقلام و شرایط را بازبینی فرمایید.</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono font-medium">#{loadedDraftId.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* نوار مراحل استپر */}
|
||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
|
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -462,12 +927,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -478,7 +943,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -492,7 +957,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
@@ -507,15 +972,14 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
{/* پاورقی کنترل مراحل */}
|
||||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
|
||||||
<div>
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span>قبلی</span>
|
<span>قبلی</span>
|
||||||
@@ -524,20 +988,20 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Link
|
<button
|
||||||
to="/"
|
type="button"
|
||||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
onClick={() => void handleCancel()}
|
||||||
|
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors cursor-pointer border-none bg-transparent"
|
||||||
>
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</Link>
|
</button>
|
||||||
|
|
||||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>بعدی</span>
|
<span>بعدی</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -548,7 +1012,7 @@ export function WizardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>ذخیره پیشنهاد</span>
|
<span>ذخیره پیشنهاد</span>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -557,21 +1021,21 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ فیلدهای خالی */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-[13.5px] leading-7">
|
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||||
{emptyFields.map((f) => (
|
{emptyFields.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.step}
|
key={f.step}
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
goToStep(f.step);
|
goToStep(f.step);
|
||||||
@@ -598,7 +1062,7 @@ export function WizardPage() {
|
|||||||
goToStep(emptyFields[0]?.step ?? 1);
|
goToStep(emptyFields[0]?.step ?? 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
تکمیل فیلد ها
|
تکمیل فیلدها
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type {
|
import type {
|
||||||
CategoryUnit,
|
|
||||||
Material,
|
Material,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
@@ -15,7 +14,6 @@ import type {
|
|||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
Topic,
|
Topic,
|
||||||
UnitConversion,
|
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
|
|
||||||
interface AppStore {
|
interface AppStore {
|
||||||
@@ -24,8 +22,6 @@ interface AppStore {
|
|||||||
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
|
||||||
|
|
||||||
@@ -45,9 +41,6 @@ interface AppStore {
|
|||||||
deleteSupplier: (id: string) => void;
|
deleteSupplier: (id: string) => void;
|
||||||
addMaterial: (m: Material) => void;
|
addMaterial: (m: Material) => void;
|
||||||
addProject: (p: Project) => void;
|
addProject: (p: Project) => void;
|
||||||
setCategoryUnit: (cu: CategoryUnit) => void;
|
|
||||||
setConversion: (c: UnitConversion) => void;
|
|
||||||
deleteConversion: (id: string) => void;
|
|
||||||
setStandardConversion: (sc: StandardConversion) => void;
|
setStandardConversion: (sc: StandardConversion) => void;
|
||||||
deleteStandardConversion: (id: string) => void;
|
deleteStandardConversion: (id: string) => void;
|
||||||
|
|
||||||
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
materials: [],
|
materials: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
quotes: [],
|
quotes: [],
|
||||||
categoryUnits: [],
|
|
||||||
unitConversions: [],
|
|
||||||
standardConversions: [],
|
standardConversions: [],
|
||||||
topics: [],
|
topics: [],
|
||||||
|
|
||||||
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
materials: store.materials,
|
materials: store.materials,
|
||||||
projects: store.projects,
|
projects: store.projects,
|
||||||
quotes: store.quotes,
|
quotes: store.quotes,
|
||||||
categoryUnits: store.categoryUnits,
|
|
||||||
unitConversions: store.unitConversions,
|
|
||||||
standardConversions: store.standardConversions,
|
standardConversions: store.standardConversions,
|
||||||
topics: store.topics,
|
topics: store.topics,
|
||||||
}),
|
}),
|
||||||
@@ -96,24 +85,6 @@ export const useAppStore = create<AppStore>()((set) => ({
|
|||||||
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
||||||
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
||||||
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
||||||
setCategoryUnit: (cu) =>
|
|
||||||
set((s) => {
|
|
||||||
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
|
|
||||||
const categoryUnits = [...s.categoryUnits];
|
|
||||||
if (idx >= 0) categoryUnits[idx] = cu;
|
|
||||||
else categoryUnits.push(cu);
|
|
||||||
return { categoryUnits };
|
|
||||||
}),
|
|
||||||
setConversion: (c) =>
|
|
||||||
set((s) => {
|
|
||||||
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
|
|
||||||
const unitConversions = [...s.unitConversions];
|
|
||||||
if (idx >= 0) unitConversions[idx] = c;
|
|
||||||
else unitConversions.push(c);
|
|
||||||
return { unitConversions };
|
|
||||||
}),
|
|
||||||
deleteConversion: (id) =>
|
|
||||||
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
|
|
||||||
setStandardConversion: (sc) =>
|
setStandardConversion: (sc) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user