Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/ai/ai_tool_payloads.py

2985 lines
118 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""نرمال‌سازی payload ابزارهای نوشتنی AI به قرارداد سرویس حسابیکس."""
from __future__ import annotations
from datetime import date
from typing import Any, Dict, List, Optional
_LINE_ROOT_TO_EXTRA = (
"unit_price",
"line_discount",
"discount_amount",
"discount_percent",
"tax_amount",
"tax_percent",
"tax_rate",
"warehouse_id",
"line_total",
"discount_type",
"discount_value",
"unit",
"unit_price_source",
)
_INVOICE_PERSON_TYPES = frozenset({
"invoice_sales",
"invoice_purchase",
"invoice_sales_return",
"invoice_purchase_return",
})
_INVOICE_TYPE_ALIASES = {
"sale": "invoice_sales",
"sales": "invoice_sales",
"فروش": "invoice_sales",
"فاکتور فروش": "invoice_sales",
"purchase": "invoice_purchase",
"buy": "invoice_purchase",
"خرید": "invoice_purchase",
"فاکتور خرید": "invoice_purchase",
"sale_return": "invoice_sales_return",
"sales_return": "invoice_sales_return",
"برگشت از فروش": "invoice_sales_return",
"برگشت فروش": "invoice_sales_return",
"purchase_return": "invoice_purchase_return",
"برگشت از خرید": "invoice_purchase_return",
"برگشت خرید": "invoice_purchase_return",
"waste": "invoice_waste",
"ضایعات": "invoice_waste",
"direct_consumption": "invoice_direct_consumption",
"مصرف مستقیم": "invoice_direct_consumption",
"production": "invoice_production",
"تولید": "invoice_production",
}
_INVOICE_LINE_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "شناسه کالا/خدمت از search_products",
},
"quantity": {"type": "number", "description": "تعداد (بزرگتر از صفر)"},
"unit_price": {
"type": "number",
"description": "قیمت واحد به ارز فاکتور (صفر یا مثبت)",
},
"description": {"type": "string", "description": "شرح سطر (اختیاری)"},
"warehouse_id": {
"type": "integer",
"description": "انبار این سطر (اختیاری؛ وگرنه warehouse_id هدر)",
},
"unit": {"type": "string", "description": "واحد سطر (اصلی یا فرعی کالا)"},
"discount_amount": {"type": "number", "description": "مبلغ تخفیف سطر"},
"discount_percent": {"type": "number", "description": "درصد تخفیف سطر ۰ تا ۱۰۰"},
"discount_type": {
"type": "string",
"enum": ["percent", "amount"],
"description": "نوع تخفیف سطر مثل فرم: percent یا amount",
},
"discount_value": {
"type": "number",
"description": "مقدار تخفیف مطابق discount_type",
},
"tax_percent": {"type": "number", "description": "درصد مالیات سطر"},
"tax_rate": {"type": "number", "description": "معادل tax_percent"},
"tax_amount": {"type": "number", "description": "مبلغ مالیات سطر (اختیاری)"},
"selected_instance_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "شناسه نمونه‌های یونیک این سطر (کالای یونیک)",
},
},
"required": ["product_id", "quantity", "unit_price"],
}
_INVOICE_PAYMENT_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"bank",
"cash_register",
"petty_cash",
"check",
"person",
"account",
],
"description": (
"نوع تسویه مثل تب پرداخت فرم. "
"bank از list_bank_accounts، cash_register از list_cash_registers، "
"petty_cash از list_petty_cash، check از search_checks."
),
},
"amount": {"type": "number", "description": "مبلغ پرداخت (بزرگتر از صفر)"},
"account_id": {
"type": "integer",
"description": "شناسه بانک/صندوق/تنخواه مطابق type",
},
"bank_id": {"type": "integer"},
"cash_register_id": {"type": "integer"},
"petty_cash_id": {"type": "integer"},
"check_id": {"type": "integer"},
"person_id": {"type": "integer", "description": "برای type=person"},
"transaction_date": {"type": "string", "format": "date"},
"description": {"type": "string"},
"commission": {"type": "number"},
"settles_amount": {
"type": "number",
"description": "مبلغ تسویه به ارز فاکتور (پرداخت بین‌ارزی)",
},
"fx_rate": {"type": "number"},
"payment_currency_id": {"type": "integer"},
"allow_large_fx_diff": {"type": "boolean"},
},
"required": ["type", "amount"],
}
_INVOICE_ADJUSTMENT_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["addition", "deduction"],
"description": "addition=اضافه، deduction=کسور",
},
"account_id": {
"type": "integer",
"description": "سرفصل از list_accounts",
},
"amount": {"type": "number"},
"tax_rate": {"type": "number", "description": "درصد مالیات این ردیف"},
"description": {"type": "string"},
},
"required": ["kind", "account_id", "amount"],
}
_INVOICE_WRITE_FIELDS_SCHEMA: Dict[str, Any] = {
"invoice_type": {
"type": "string",
"enum": [
"invoice_sales",
"invoice_purchase",
"invoice_sales_return",
"invoice_purchase_return",
"invoice_waste",
"invoice_direct_consumption",
"invoice_production",
],
"description": (
"نوع فاکتور مثل فرم. فروش/خرید و برگشت‌ها person_id می‌خواهند. "
"معادل فارسی (فروش/خرید/ضایعات/تولید) هم پذیرفته می‌شود."
),
},
"document_date": {
"type": "string",
"format": "date",
"description": (
"تاریخ فاکتور. ISO میلادی YYYY-MM-DD یا شمسی YYYY/MM/DD. "
"اگر خالی بماند، امروز گذاشته می‌شود."
),
},
"due_date": {
"type": "string",
"format": "date",
"description": "تاریخ سررسید. اگر خالی باشد همان تاریخ فاکتور در extra_info ذخیره می‌شود.",
},
"currency_id": {
"type": "integer",
"description": (
"شناسه ارز فاکتور از list_currencies. "
"اگر نفرستی، ارز پیش‌فرض کسب‌وکار استفاده می‌شود."
),
},
"fx_rate_id": {
"type": "integer",
"description": "شناسه نرخ ارز دستی وقتی ارز فاکتور با ارز پایه فرق دارد",
},
"person_id": {
"type": "integer",
"description": (
"شناسه عددی مشتری/تامین‌کننده از search_persons (فیلد id). "
"برای فروش، خرید و برگشت‌ها اجباری است. نام شخص را اینجا نگذار."
),
},
"description": {"type": "string", "description": "عنوان/شرح فاکتور"},
"code": {
"type": "string",
"description": "شماره فاکتور دستی؛ اگر خالی باشد خودکار تولید می‌شود",
},
"is_proforma": {
"type": "boolean",
"description": "true = پیش‌فاکتور. در پیش‌فاکتور payments ارسال نمی‌شود.",
},
"warehouse_id": {
"type": "integer",
"description": "انبار پیش‌فرض سند از list_warehouses",
},
"project_id": {
"type": "integer",
"description": "شناسه پروژه از search_projects",
},
"tag_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "برچسب‌های فاکتور",
},
"post_inventory": {
"type": "boolean",
"description": "صدور حواله انبار برای این فاکتور",
},
"auto_post_warehouse": {
"type": "boolean",
"description": "ثبت قطعی حواله (وگرنه حواله پیش‌نویس)",
},
"ignore_credit_check": {
"type": "boolean",
"description": "نادیده گرفتن سقف اعتبار مشتری (فروش)",
},
"warehouse_release_mode": {
"type": "string",
"enum": ["none", "draft", "posted"],
"description": "حواله انبار: none=بدون حواله، draft=پیش‌نویس، posted=ثبت قطعی",
},
"seller_id": {
"type": "integer",
"description": "فروشنده/بازاریاب از search_persons (نوع فروشنده)",
},
"commission": {
"type": "object",
"description": "کارمزد فروشنده",
"properties": {
"type": {"type": "string", "enum": ["percentage", "amount"]},
"value": {"type": "number"},
},
},
"global_discount": {
"type": "object",
"description": "تخفیف کلی فاکتور مثل فرم",
"properties": {
"type": {"type": "string", "enum": ["percent", "amount"]},
"value": {"type": "number"},
},
},
"invoice_adjustments": {
"type": "array",
"description": "اضافات و کسورات فاکتور. سرفصل از list_accounts.",
"items": _INVOICE_ADJUSTMENT_ITEM_SCHEMA,
},
"installment_plan": {
"type": "object",
"description": "طرح اقساط فروش (فقط فاکتور قطعی فروش/برگشت فروش)",
"properties": {
"down_payment": {"type": "number"},
"num_installments": {"type": "integer"},
"first_due_date": {"type": "string", "format": "date"},
"period": {"type": "string", "enum": ["monthly", "days"]},
"period_days": {"type": "integer"},
"interest_rate": {"type": "number"},
"schedule": {
"type": "array",
"items": {
"type": "object",
"properties": {
"seq": {"type": "integer"},
"due_date": {"type": "string", "format": "date"},
"principal": {"type": "number"},
"interest": {"type": "number"},
"total": {"type": "number"},
},
},
},
},
},
"loyalty_redemption_points": {
"type": "integer",
"description": "امتیاز باشگاه مشتریان برای کسر از فاکتور فروش",
},
"payments": {
"type": "array",
"description": (
"تسویه‌های همزمان فاکتور قطعی (تب پرداخت فرم). "
"برای پیش‌فاکتور نفرست. در ویرایش اگر بفرستی جایگزین اسناد دریافت/پرداخت قبلی است."
),
"items": _INVOICE_PAYMENT_ITEM_SCHEMA,
},
"lines": {
"type": "array",
"description": (
"اقلام فاکتور. حداقل یک سطر در ایجاد. "
"product_id از search_products. قیمت واحد را با unit_price بفرست."
),
"items": _INVOICE_LINE_ITEM_SCHEMA,
},
}
CREATE_INVOICE_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": dict(_INVOICE_WRITE_FIELDS_SCHEMA),
"required": ["invoice_type", "lines"],
}
CREATE_INVOICE_DESCRIPTION = (
"ثبت فاکتور با همان فیلدهای فرم صدور فاکتور: نوع، تاریخ، سررسید، ارز، طرف‌حساب، "
"اقلام (قیمت/تخفیف/مالیات/انبار)، تخفیف کلی، اضافات/کسورات، فروشنده و کارمزد، "
"اقساط، پرداخت همزمان، پروژه، برچسب، پیش‌فاکتور. "
"قبل از صدا: search_persons، search_products، در صورت نیاز list_currencies و list_warehouses. "
"برای فروش/خرید person_id عددی الزامی است. نیاز به تأیید."
)
def coerce_positive_int(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def coerce_number(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _as_dict(value: Any) -> Dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def normalize_ai_invoice_line(line: Any) -> Dict[str, Any]:
"""unit_price و فیلدهای سطر فرم را به extra_info ببر (قرارداد create_invoice)."""
if not isinstance(line, dict):
raise ValueError("هر سطر فاکتور باید object باشد")
out = dict(line)
extra = _as_dict(out.get("extra_info"))
for key in _LINE_ROOT_TO_EXTRA:
raw = out.get(key)
if raw is not None and extra.get(key) is None:
extra[key] = raw
if extra.get("line_discount") is None and extra.get("discount_amount") is not None:
extra["line_discount"] = extra["discount_amount"]
if extra.get("tax_percent") is None and extra.get("tax_rate") is not None:
extra["tax_percent"] = extra["tax_rate"]
if extra.get("tax_rate") is None and extra.get("tax_percent") is not None:
extra["tax_rate"] = extra["tax_percent"]
instances = out.get("selected_instance_ids")
if instances is None:
instances = extra.get("selected_instance_ids")
if isinstance(instances, list):
extra["selected_instance_ids"] = [
int(x) for x in instances if x is not None and x != ""
]
product_id = coerce_positive_int(out.get("product_id"))
if product_id is None:
raise ValueError("هر سطر فاکتور به product_id عددی نیاز دارد. از search_products بگیر.")
qty = coerce_number(out.get("quantity"))
if qty is None or qty <= 0:
raise ValueError("هر سطر فاکتور به quantity بزرگتر از صفر نیاز دارد.")
out["product_id"] = product_id
out["quantity"] = qty
out["extra_info"] = extra
return out
def _person_id_from_args(args: Dict[str, Any]) -> Optional[int]:
extra = _as_dict(args.get("extra_info"))
for candidate in (
args.get("person_id"),
args.get("customer_id"),
args.get("supplier_id"),
extra.get("person_id"),
extra.get("customer_id"),
):
parsed = coerce_positive_int(candidate)
if parsed:
return parsed
return None
def _default_currency_id(db: Any, business_id: Any) -> Optional[int]:
bid = coerce_positive_int(business_id)
if not bid or db is None:
return None
try:
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == bid).first()
return coerce_positive_int(getattr(biz, "default_currency_id", None) if biz else None)
except Exception:
return None
def _normalize_invoice_type(raw: Any) -> str:
s = str(raw or "").strip()
if not s:
return ""
mapped = _INVOICE_TYPE_ALIASES.get(s) or _INVOICE_TYPE_ALIASES.get(s.lower())
return mapped or s
def _normalize_invoice_adjustments(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("invoice_adjustments باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
account_id = coerce_positive_int(item.get("account_id"))
amount = coerce_number(item.get("amount"))
if not account_id or amount is None or amount <= 0:
continue
kind = str(item.get("kind") or "addition").strip().lower()
if kind in ("کسور", "deduct", "discount"):
kind = "deduction"
elif kind in ("اضافه", "add"):
kind = "addition"
if kind not in ("addition", "deduction"):
kind = "addition"
row: Dict[str, Any] = {
"kind": kind,
"account_id": account_id,
"amount": amount,
"tax_rate": coerce_number(item.get("tax_rate")) or 0,
}
desc = item.get("description")
if desc:
row["description"] = str(desc).strip()
out.append(row)
return out
def _normalize_invoice_payments(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("payments باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
amount = coerce_number(item.get("amount"))
if amount is None or amount <= 0:
continue
ttype = str(
item.get("type") or item.get("transaction_type") or item.get("account_type") or ""
).strip().lower()
ttype = {
"بانک": "bank",
"صندوق": "cash_register",
"تنخواه": "petty_cash",
"چک": "check",
"شخص": "person",
"حساب": "account",
"cash": "cash_register",
}.get(ttype, ttype)
if ttype not in ("bank", "cash_register", "petty_cash", "check", "person", "account"):
raise ValueError(
"type هر پرداخت باید bank، cash_register، petty_cash، check، person یا account باشد."
)
row: Dict[str, Any] = {
"type": ttype,
"transaction_type": ttype,
"amount": amount,
}
account_id = coerce_positive_int(
item.get("account_id")
or item.get("bank_id")
or item.get("cash_register_id")
or item.get("petty_cash_id")
)
if ttype == "bank":
row["bank_id"] = coerce_positive_int(item.get("bank_id")) or account_id
elif ttype == "cash_register":
row["cash_register_id"] = coerce_positive_int(item.get("cash_register_id")) or account_id
elif ttype == "petty_cash":
row["petty_cash_id"] = coerce_positive_int(item.get("petty_cash_id")) or account_id
elif ttype == "check":
cid = coerce_positive_int(item.get("check_id") or item.get("account_id"))
if cid:
row["check_id"] = cid
elif ttype == "person":
pid = coerce_positive_int(item.get("person_id") or item.get("account_id"))
if pid:
row["person_id"] = pid
elif ttype == "account":
if account_id:
row["account_id"] = account_id
if item.get("transaction_date"):
row["transaction_date"] = item.get("transaction_date")
if item.get("description"):
row["description"] = item.get("description")
if item.get("commission") is not None:
row["commission"] = coerce_number(item.get("commission"))
if item.get("settles_amount") is not None:
row["settles_amount"] = coerce_number(item.get("settles_amount"))
if item.get("fx_rate") is not None:
row["fx_rate"] = coerce_number(item.get("fx_rate"))
pay_cur = coerce_positive_int(item.get("payment_currency_id"))
if pay_cur:
row["payment_currency_id"] = pay_cur
if item.get("allow_large_fx_diff") is not None:
row["allow_large_fx_diff"] = bool(item.get("allow_large_fx_diff"))
out.append(row)
return out
def _apply_invoice_header_extra(raw: Dict[str, Any], extra: Dict[str, Any]) -> None:
warehouse_id = coerce_positive_int(raw.get("warehouse_id") or extra.get("warehouse_id"))
if warehouse_id:
extra["warehouse_id"] = warehouse_id
if raw.get("is_proforma") is not None:
extra.setdefault("is_proforma", bool(raw.get("is_proforma")))
due = raw.get("due_date") or extra.get("due_date")
if due:
extra["due_date"] = due
if raw.get("post_inventory") is not None:
extra["post_inventory"] = bool(raw.get("post_inventory"))
if raw.get("auto_post_warehouse") is not None:
extra["auto_post_warehouse"] = bool(raw.get("auto_post_warehouse"))
mode = raw.get("warehouse_release_mode")
if mode == "none":
extra["post_inventory"] = False
extra["auto_post_warehouse"] = False
elif mode == "draft":
extra["post_inventory"] = True
extra["auto_post_warehouse"] = False
elif mode == "posted":
extra["post_inventory"] = True
extra["auto_post_warehouse"] = True
if raw.get("ignore_credit_check") is not None:
extra["ignore_credit_check"] = bool(raw.get("ignore_credit_check"))
seller_id = coerce_positive_int(raw.get("seller_id") or extra.get("seller_id"))
if seller_id:
extra["seller_id"] = seller_id
commission = raw.get("commission") if "commission" in raw else extra.get("commission")
if isinstance(commission, dict) and commission.get("value") is not None:
ctype = str(commission.get("type") or "percentage").strip().lower()
if ctype in ("percent", "percentage", "درصد"):
ctype = "percentage"
elif ctype in ("amount", "مبلغ"):
ctype = "amount"
extra["commission"] = {"type": ctype, "value": coerce_number(commission.get("value"))}
gd = raw.get("global_discount") if "global_discount" in raw else extra.get("global_discount")
if isinstance(gd, dict) and gd.get("value") is not None:
gtype = str(gd.get("type") or "percent").strip().lower()
if gtype in ("درصد", "percentage"):
gtype = "percent"
elif gtype in ("مبلغ",):
gtype = "amount"
extra["global_discount"] = {"type": gtype, "value": coerce_number(gd.get("value"))}
elif raw.get("global_discount") is None and "global_discount" in raw:
extra.pop("global_discount", None)
adjs = raw.get("invoice_adjustments")
if adjs is not None:
extra["invoice_adjustments"] = _normalize_invoice_adjustments(adjs) or []
plan = raw.get("installment_plan")
if isinstance(plan, dict):
extra["installment_plan"] = plan
elif raw.get("installment_plan") is None and "installment_plan" in raw:
extra.pop("installment_plan", None)
def build_create_invoice_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
"""بدنهٔ create_invoice از آرگومان مدل — هم‌تراز فرم صدور فاکتور."""
raw = dict(args or {})
extra = _as_dict(raw.get("extra_info"))
person_id = _person_id_from_args(raw)
if person_id:
extra["person_id"] = person_id
_apply_invoice_header_extra(raw, extra)
lines_in = raw.get("lines") or raw.get("items") or []
if not isinstance(lines_in, list) or not lines_in:
raise ValueError("lines الزامی است و باید حداقل یک قلم کالا داشته باشد.")
lines: List[Dict[str, Any]] = [normalize_ai_invoice_line(item) for item in lines_in]
invoice_type = _normalize_invoice_type(raw.get("invoice_type") or raw.get("type"))
if invoice_type in _INVOICE_PERSON_TYPES and not person_id:
raise ValueError(
"person_id الزامی است. ابتدا search_persons را صدا بزن و id عددی شخص را بفرست."
)
currency_id = coerce_positive_int(raw.get("currency_id")) or _default_currency_id(
db, business_id or raw.get("business_id")
)
document_date = raw.get("document_date") or raw.get("invoice_date")
if not document_date:
document_date = date.today().isoformat()
payload: Dict[str, Any] = {
"invoice_type": invoice_type,
"document_date": document_date,
"currency_id": currency_id,
"person_id": person_id,
"description": raw.get("description"),
"lines": lines,
"extra_info": extra,
}
if raw.get("is_proforma") is not None:
payload["is_proforma"] = bool(raw.get("is_proforma"))
if raw.get("due_date"):
payload["due_date"] = raw.get("due_date")
if raw.get("project_id") is not None:
payload["project_id"] = coerce_positive_int(raw.get("project_id"))
if raw.get("code"):
payload["code"] = str(raw.get("code")).strip()
fx_rate_id = coerce_positive_int(raw.get("fx_rate_id"))
if fx_rate_id:
payload["fx_rate_id"] = fx_rate_id
if "tag_ids" in raw:
tags = raw.get("tag_ids") or []
payload["tag_ids"] = [int(x) for x in tags if x is not None and x != ""]
if raw.get("loyalty_redemption_points") is not None:
payload["loyalty_redemption_points"] = int(raw.get("loyalty_redemption_points") or 0)
if not bool(payload.get("is_proforma")) and "payments" in raw:
payload["payments"] = _normalize_invoice_payments(raw.get("payments")) or []
return payload
def annotate_schema_descriptions(
schema: Dict[str, Any],
descriptions: Dict[str, str],
) -> Dict[str, Any]:
"""توضیح فارسی به propertyهایی که description ندارند اضافه می‌کند."""
props = schema.get("properties")
if not isinstance(props, dict):
return schema
for key, text in descriptions.items():
spec = props.get(key)
if isinstance(spec, dict) and not (spec.get("description") or "").strip():
props[key] = {**spec, "description": text}
return schema
INVOICE_LINE_ITEM_SCHEMA: Dict[str, Any] = _INVOICE_LINE_ITEM_SCHEMA
UPDATE_INVOICE_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"invoice_id": {
"type": "integer",
"description": "شناسه سند فاکتور از search_invoices / get_invoice_details",
},
**_INVOICE_WRITE_FIELDS_SCHEMA,
"lines": {
"type": "array",
"description": (
"اقلام کامل فاکتور. اگر نفرستی، سطرهای فعلی حفظ می‌شوند. "
"اگر بفرستی جایگزین کل اقلام می‌شود. unit_price در ریشهٔ هر سطر."
),
"items": _INVOICE_LINE_ITEM_SCHEMA,
},
},
"required": ["invoice_id"],
}
UPDATE_INVOICE_DESCRIPTION = (
"ویرایش فاکتور با همان فیلدهای فرم ویرایش: تاریخ، سررسید، طرف‌حساب، اقلام، "
"تخفیف کلی، اضافات/کسورات، فروشنده/کارمزد، اقساط، پرداخت‌ها، پروژه، برچسب، پیش‌فاکتور. "
"اول get_invoice_details. اگر فقط تاریخ/شرح/شخص عوض می‌شود lines را نفرست. "
"payments در صورت ارسال جایگزین تسویه‌های قبلی است. نیاز به تأیید."
)
def load_existing_invoice_lines(db: Any, invoice_id: int) -> List[Dict[str, Any]]:
from adapters.db.models.invoice_item_line import InvoiceItemLine
rows = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id == int(invoice_id))
.all()
)
out: List[Dict[str, Any]] = []
for row in rows:
extra = _as_dict(getattr(row, "extra_info", None))
line = {
"product_id": getattr(row, "product_id", None),
"quantity": getattr(row, "quantity", None),
"description": getattr(row, "description", None),
"extra_info": extra,
}
if extra.get("unit_price") is not None:
line["unit_price"] = extra["unit_price"]
out.append(normalize_ai_invoice_line(line))
return out
def build_update_invoice_payload(
args: Dict[str, Any],
*,
db: Any = None,
invoice_id: Any = None,
) -> Dict[str, Any]:
"""بدنهٔ update_invoice؛ اگر lines نباشد از سند فعلی پر می‌شود."""
raw = dict(args or {})
iid = coerce_positive_int(invoice_id or raw.get("invoice_id"))
extra = _as_dict(raw.get("extra_info"))
person_id = _person_id_from_args(raw)
if person_id:
extra["person_id"] = person_id
_apply_invoice_header_extra(raw, extra)
payload: Dict[str, Any] = {}
for key in ("document_date", "currency_id", "description", "is_proforma", "project_id", "code"):
if key in raw and raw[key] is not None:
payload[key] = raw[key]
if raw.get("invoice_type") or raw.get("type"):
payload["invoice_type"] = _normalize_invoice_type(raw.get("invoice_type") or raw.get("type"))
if raw.get("due_date"):
payload["due_date"] = raw.get("due_date")
fx_rate_id = coerce_positive_int(raw.get("fx_rate_id"))
if fx_rate_id:
payload["fx_rate_id"] = fx_rate_id
if "tag_ids" in raw:
tags = raw.get("tag_ids") or []
payload["tag_ids"] = [int(x) for x in tags if x is not None and x != ""]
if raw.get("loyalty_redemption_points") is not None:
payload["loyalty_redemption_points"] = int(raw.get("loyalty_redemption_points") or 0)
if person_id:
payload["person_id"] = person_id
if extra:
payload["extra_info"] = extra
if "payments" in raw and not bool(raw.get("is_proforma")):
payload["payments"] = _normalize_invoice_payments(raw.get("payments")) or []
lines_in = raw.get("lines") if "lines" in raw else raw.get("items")
if isinstance(lines_in, list) and lines_in:
payload["lines"] = [normalize_ai_invoice_line(item) for item in lines_in]
elif db is not None and iid:
existing = load_existing_invoice_lines(db, iid)
if not existing:
raise ValueError(
"این فاکتور سطری ندارد. برای ویرایش اقلام، lines را با product_id و unit_price بفرست."
)
payload["lines"] = existing
else:
raise ValueError(
"برای ویرایش فاکتور invoice_id لازم است؛ اگر اقلام را عوض نمی‌کنی همان id کافی است."
)
return payload
_CHECK_TYPE_ALIASES = {
"received": "received",
"دریافتی": "received",
"دریافت": "received",
"in": "received",
"transferred": "transferred",
"پرداختی": "transferred",
"پرداخت": "transferred",
"issued": "transferred",
"out": "transferred",
}
CREATE_CHECK_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["received", "transferred"],
"description": "received=چک دریافتی (person_id اجباری)، transferred=چک پرداختی",
},
"check_number": {"type": "string", "description": "شماره چک"},
"amount": {"type": "number", "description": "مبلغ (بزرگتر از صفر)"},
"issue_date": {"type": "string", "format": "date", "description": "تاریخ صدور"},
"due_date": {"type": "string", "format": "date", "description": "تاریخ سررسید (>= صدور)"},
"person_id": {
"type": "integer",
"description": "شناسه شخص از search_persons. برای چک دریافتی اجباری است.",
},
"currency_id": {
"type": "integer",
"description": "شناسه ارز از list_currencies؛ اگر خالی باشد ارز پیش‌فرض کسب‌وکار",
},
"bank_name": {"type": "string", "description": "نام بانک (اختیاری)"},
"branch_name": {"type": "string", "description": "شعبه (اختیاری)"},
"sayad_code": {"type": "string", "description": "شناسه صیاد ۱۶ رقمی (اختیاری)"},
},
"required": ["type", "check_number", "amount", "issue_date", "due_date"],
}
def build_create_check_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
raw = dict(args or {})
ctype = str(raw.get("type") or "").strip()
ctype = _CHECK_TYPE_ALIASES.get(ctype, ctype.lower())
if ctype not in ("received", "transferred"):
raise ValueError("type باید received یا transferred باشد.")
person_id = _person_id_from_args(raw)
if ctype == "received" and not person_id:
raise ValueError("برای چک دریافتی person_id الزامی است. از search_persons بگیر.")
amount = coerce_number(raw.get("amount"))
if amount is None or amount <= 0:
raise ValueError("amount باید عدد بزرگتر از صفر باشد.")
currency_id = coerce_positive_int(raw.get("currency_id")) or _default_currency_id(
db, business_id or raw.get("business_id")
)
if not currency_id:
raise ValueError("currency_id مشخص نیست. list_currencies را صدا بزن.")
return {
"type": ctype,
"check_number": str(raw.get("check_number") or "").strip(),
"amount": amount,
"issue_date": raw.get("issue_date"),
"due_date": raw.get("due_date"),
"person_id": person_id,
"currency_id": currency_id,
"bank_name": raw.get("bank_name"),
"branch_name": raw.get("branch_name"),
"sayad_code": raw.get("sayad_code"),
"document_date": raw.get("document_date") or raw.get("issue_date"),
}
_ACCOUNT_KIND_ALIASES = {
"bank": "bank",
"بانک": "bank",
"cash": "cash_register",
"cash_register": "cash_register",
"صندوق": "cash_register",
"petty_cash": "petty_cash",
"تنخواه": "petty_cash",
"تنخواهگردان": "petty_cash",
}
def _account_kind(value: Any) -> str:
return _ACCOUNT_KIND_ALIASES.get(str(value or "").strip().lower(), str(value or "").strip())
_TX_TYPE_ALIASES = {
**_ACCOUNT_KIND_ALIASES,
"check": "check",
"چک": "check",
"check_expense": "check",
"خرج چک": "check",
"person": "person",
"شخص": "person",
"account": "account",
"حساب": "account",
}
def _transaction_kind(value: Any) -> str:
s = str(value or "").strip()
return _TX_TYPE_ALIASES.get(s) or _TX_TYPE_ALIASES.get(s.lower()) or s.lower()
def _normalize_cash_side_line(
item: Any,
*,
allow_account: bool = False,
default_amount: Optional[float] = None,
default_description: Optional[str] = None,
default_transaction_date: Optional[str] = None,
) -> Dict[str, Any]:
"""سطر بانک/صندوق/تنخواه/چک/شخص (و در هزینه: حساب) مطابق فرم UI."""
if not isinstance(item, dict):
raise ValueError("هر سطر نقدی باید object باشد.")
ttype = _transaction_kind(
item.get("transaction_type") or item.get("type") or item.get("account_type")
)
allowed = {"bank", "cash_register", "petty_cash", "check", "person"}
if allow_account:
allowed.add("account")
if ttype not in allowed:
raise ValueError(
"transaction_type باید bank، cash_register، petty_cash، check، person"
+ (" یا account" if allow_account else "")
+ " باشد."
)
amount = coerce_number(item.get("amount"))
if amount is None:
amount = default_amount
if amount is None or amount <= 0:
raise ValueError("مبلغ هر سطر باید بزرگتر از صفر باشد.")
row: Dict[str, Any] = {
"transaction_type": ttype,
"amount": amount,
}
desc = item.get("description") or default_description
if desc:
row["description"] = desc
tx_date = item.get("transaction_date") or default_transaction_date
if tx_date:
row["transaction_date"] = tx_date
if item.get("commission") is not None:
row["commission"] = coerce_number(item.get("commission"))
generic_id = coerce_positive_int(
item.get("account_id")
or item.get("counterparty_id")
or item.get("bank_id")
or item.get("bank_account_id")
or item.get("cash_register_id")
or item.get("petty_cash_id")
or item.get("check_id")
or item.get("person_id")
)
if ttype == "bank":
bank_id = coerce_positive_int(
item.get("bank_id") or item.get("bank_account_id") or generic_id
)
if not bank_id:
raise ValueError("برای بانک، bank_id را از list_bank_accounts بگیر.")
row["bank_id"] = bank_id
row["bank_account_id"] = bank_id
if item.get("bank_name"):
row["bank_name"] = item.get("bank_name")
if item.get("bank_account_name"):
row["bank_account_name"] = item.get("bank_account_name")
elif ttype == "cash_register":
cid = coerce_positive_int(item.get("cash_register_id") or generic_id)
if not cid:
raise ValueError("برای صندوق، cash_register_id را از list_cash_registers بگیر.")
row["cash_register_id"] = cid
if item.get("cash_register_name"):
row["cash_register_name"] = item.get("cash_register_name")
elif ttype == "petty_cash":
pid = coerce_positive_int(item.get("petty_cash_id") or generic_id)
if not pid:
raise ValueError("برای تنخواه، petty_cash_id را از list_petty_cash بگیر.")
row["petty_cash_id"] = pid
if item.get("petty_cash_name"):
row["petty_cash_name"] = item.get("petty_cash_name")
elif ttype == "check":
cid = coerce_positive_int(item.get("check_id") or generic_id)
if not cid:
raise ValueError("برای چک، check_id را از search_checks بگیر.")
row["check_id"] = cid
if item.get("check_number"):
row["check_number"] = item.get("check_number")
elif ttype == "person":
pid = coerce_positive_int(item.get("person_id") or generic_id)
if not pid:
raise ValueError("برای شخص، person_id را از search_persons بگیر.")
row["person_id"] = pid
if item.get("person_name"):
row["person_name"] = item.get("person_name")
else:
aid = coerce_positive_int(item.get("account_id") or generic_id)
if not aid:
raise ValueError("برای حساب، account_id را از list_accounts بگیر.")
row["account_id"] = aid
if item.get("account_name"):
row["account_name"] = item.get("account_name")
if item.get("settles_amount") is not None:
row["settles_amount"] = coerce_number(item.get("settles_amount"))
if item.get("fx_rate") is not None:
row["fx_rate"] = coerce_number(item.get("fx_rate"))
pay_cur = coerce_positive_int(item.get("payment_currency_id"))
if pay_cur:
row["payment_currency_id"] = pay_cur
if item.get("allow_large_fx_diff") is not None:
row["allow_large_fx_diff"] = bool(item.get("allow_large_fx_diff"))
native_amt = item.get("account_currency_amount")
if native_amt is not None:
row["account_currency_amount"] = native_amt
native_cur = coerce_positive_int(item.get("account_currency_id"))
if native_cur:
row["account_currency_id"] = native_cur
return row
def _normalize_person_doc_line(item: Any) -> Dict[str, Any]:
if not isinstance(item, dict):
raise ValueError("هر سطر شخص باید object باشد.")
person_id = coerce_positive_int(item.get("person_id"))
amount = coerce_number(item.get("amount"))
if not person_id:
raise ValueError("هر سطر شخص به person_id از search_persons نیاز دارد.")
if amount is None or amount <= 0:
raise ValueError("مبلغ سطر شخص باید بزرگتر از صفر باشد.")
row: Dict[str, Any] = {"person_id": person_id, "amount": amount}
if item.get("person_name"):
row["person_name"] = item.get("person_name")
if item.get("description"):
row["description"] = item.get("description")
extra = _as_dict(item.get("extra_info"))
invoice_id = coerce_positive_int(item.get("invoice_id") or extra.get("invoice_id"))
if invoice_id:
extra["invoice_id"] = invoice_id
extra["link_to_invoice"] = True
code = item.get("invoice_code") or extra.get("invoice_code")
if code:
extra["invoice_code"] = code
if extra:
row["extra_info"] = extra
return row
def _normalize_settlements(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
person_id = coerce_positive_int(item.get("person_id"))
invoice_id = coerce_positive_int(item.get("invoice_id"))
if not person_id or not invoice_id:
continue
allocations_in = item.get("allocations") or []
allocations: List[Dict[str, Any]] = []
if isinstance(allocations_in, dict):
for seq, amount in allocations_in.items():
amt = coerce_number(amount)
try:
seq_i = int(seq)
except (TypeError, ValueError):
continue
if seq_i > 0 and amt is not None and amt > 0:
allocations.append({"seq": seq_i, "amount": amt})
elif isinstance(allocations_in, list):
for alloc in allocations_in:
if not isinstance(alloc, dict):
continue
seq_i = coerce_positive_int(alloc.get("seq"))
amt = coerce_number(alloc.get("amount"))
if seq_i and amt is not None and amt > 0:
allocations.append({"seq": seq_i, "amount": amt})
if allocations:
out.append(
{
"person_id": person_id,
"invoice_id": invoice_id,
"allocations": allocations,
}
)
return out
def _settlements_from_person_lines(lines: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
raw: List[Dict[str, Any]] = []
for item in lines:
if not isinstance(item, dict):
continue
invoice_id = coerce_positive_int(
item.get("installment_invoice_id") or item.get("settlement_invoice_id")
)
person_id = coerce_positive_int(item.get("person_id"))
allocations = item.get("installment_allocations") or item.get("allocations")
if invoice_id and person_id and allocations:
raw.append(
{
"person_id": person_id,
"invoice_id": invoice_id,
"allocations": allocations,
}
)
return _normalize_settlements(raw)
_CASH_SIDE_LINE_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"transaction_type": {
"type": "string",
"enum": ["bank", "cash_register", "petty_cash", "check", "person", "account"],
"description": (
"نوع طرف نقدی مثل فرم: bank از list_bank_accounts، "
"cash_register از list_cash_registers، petty_cash از list_petty_cash، "
"check از search_checks، person از search_persons، account از list_accounts"
),
},
"amount": {"type": "number", "description": "مبلغ سطر (بزرگتر از صفر)"},
"transaction_date": {
"type": "string",
"description": "تاریخ تراکنش این سطر (اختیاری)",
},
"commission": {"type": "number", "description": "کارمزد سطر (اختیاری)"},
"description": {"type": "string", "description": "شرح سطر (اختیاری)"},
"bank_id": {"type": "integer", "description": "شناسه حساب بانکی کسب‌وکار"},
"cash_register_id": {"type": "integer", "description": "شناسه صندوق"},
"petty_cash_id": {"type": "integer", "description": "شناسه تنخواه"},
"check_id": {"type": "integer", "description": "شناسه چک از search_checks"},
"person_id": {"type": "integer", "description": "شناسه شخص اگر طرف شخص است"},
"account_id": {"type": "integer", "description": "شناسه حساب کدینگ اگر طرف حساب است"},
"settles_amount": {
"type": "number",
"description": "مبلغ تسویه به ارز طرف‌حساب در حالت بین‌ارزی",
},
"fx_rate": {"type": "number", "description": "نرخ تسعیر سطر (بین‌ارزی)"},
"payment_currency_id": {
"type": "integer",
"description": "ارز طرف‌حساب در حالت بین‌ارزی از list_currencies",
},
"allow_large_fx_diff": {
"type": "boolean",
"description": "اجازه اختلاف زیاد نرخ تسعیر",
},
},
"required": ["transaction_type", "amount"],
}
_RECEIPT_PERSON_LINE_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"person_id": {
"type": "integer",
"description": "شناسه شخص از search_persons",
},
"amount": {"type": "number", "description": "مبلغ این شخص (بزرگتر از صفر)"},
"description": {"type": "string", "description": "شرح سطر شخص (اختیاری)"},
"invoice_id": {
"type": "integer",
"description": "اتصال این دریافت/پرداخت به فاکتور از search_invoices",
},
"invoice_code": {"type": "string", "description": "شماره فاکتور (اختیاری)"},
"installment_invoice_id": {
"type": "integer",
"description": "فاکتور اقساطی برای تخصیص (فقط دریافت)",
},
"installment_allocations": {
"type": "array",
"description": "تخصیص اقساط: [{seq, amount}]",
"items": {
"type": "object",
"properties": {
"seq": {"type": "integer"},
"amount": {"type": "number"},
},
},
},
},
"required": ["person_id", "amount"],
}
_EXPENSE_ITEM_LINE_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"account_id": {
"type": "integer",
"description": "شناسه سرفصل هزینه/درآمد از list_accounts",
},
"amount": {"type": "number", "description": "مبلغ سطر (بزرگتر از صفر)"},
"description": {"type": "string", "description": "شرح سطر (اختیاری)"},
},
"required": ["account_id", "amount"],
}
def build_create_transfer_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
raw = dict(args or {})
src = raw.get("source") if isinstance(raw.get("source"), dict) else {}
dst = raw.get("destination") if isinstance(raw.get("destination"), dict) else {}
from_type = _account_kind(raw.get("from_account_type") or src.get("type"))
to_type = _account_kind(raw.get("to_account_type") or dst.get("type"))
from_id = coerce_positive_int(raw.get("from_account_id") or src.get("id"))
to_id = coerce_positive_int(raw.get("to_account_id") or dst.get("id"))
if from_type not in ("bank", "cash_register", "petty_cash"):
raise ValueError("from_account_type باید bank یا cash_register یا petty_cash باشد.")
if to_type not in ("bank", "cash_register", "petty_cash"):
raise ValueError("to_account_type باید bank یا cash_register یا petty_cash باشد.")
if not from_id or not to_id:
raise ValueError(
"from_account_id و to_account_id الزامی است. از list_bank_accounts / "
"list_cash_registers / list_petty_cash بگیر."
)
amount = coerce_number(raw.get("amount"))
if amount is None or amount <= 0:
raise ValueError("amount باید بزرگتر از صفر باشد.")
currency_id = coerce_positive_int(raw.get("currency_id")) or _default_currency_id(
db, business_id or raw.get("business_id")
)
if not currency_id:
raise ValueError("currency_id مشخص نیست. list_currencies را صدا بزن.")
return {
"document_date": raw.get("document_date") or date.today().isoformat(),
"currency_id": currency_id,
"amount": amount,
"description": raw.get("description"),
"source": {"type": from_type, "id": from_id},
"destination": {"type": to_type, "id": to_id},
}
_RECEIPT_TYPE_ALIASES = {
"receipt": "receipt",
"دریافت": "receipt",
"payment": "payment",
"پرداخت": "payment",
}
_RECEIPT_WRITE_FIELDS_SCHEMA: Dict[str, Any] = {
"document_date": {
"type": "string",
"format": "date",
"description": "تاریخ سند؛ اگر خالی بماند امروز",
},
"currency_id": {
"type": "integer",
"description": "شناسه ارز از list_currencies؛ اگر خالی باشد ارز پیش‌فرض",
},
"project_id": {
"type": "integer",
"description": "پروژه سند از search_projects (اختیاری)",
},
"description": {"type": "string", "description": "شرح سند (اختیاری)"},
"person_id": {
"type": "integer",
"description": "میانبر تک‌شخص: شناسه از search_persons وقتی person_lines نفرستی",
},
"amount": {
"type": "number",
"description": "میانبر تک‌مبلغ وقتی person_lines/account_lines نفرستی",
},
"account_type": {
"type": "string",
"enum": ["bank", "cash_register", "petty_cash", "check", "person"],
"description": (
"میانبر طرف نقدی. bank/cash_register/petty_cash از لیست بانک/صندوق/تنخواه، "
"check از search_checks، person از search_persons — نه کدینگ list_accounts."
),
},
"account_id": {
"type": "integer",
"description": "میانبر شناسه طرف نقدی مطابق account_type",
},
"check_id": {
"type": "integer",
"description": "میانبر چک وقتی account_type=check",
},
"commission": {"type": "number", "description": "کارمزد طرف نقدی در حالت میانبر"},
"transaction_date": {
"type": "string",
"description": "تاریخ تراکنش طرف نقدی در حالت میانبر",
},
"invoice_id": {
"type": "integer",
"description": "اتصال شخص میانبر به فاکتور از search_invoices",
},
"person_lines": {
"type": "array",
"description": (
"سطرهای اشخاص مثل فرم. اگر خالی باشد از person_id+amount ساخته می‌شود. "
"جمع مبالغ باید با account_lines برابر باشد."
),
"items": _RECEIPT_PERSON_LINE_SCHEMA,
},
"account_lines": {
"type": "array",
"description": (
"سطرهای بانک/صندوق/تنخواه/چک/شخص مثل فرم. "
"اگر خالی باشد از account_type+account_id ساخته می‌شود."
),
"items": {
**_CASH_SIDE_LINE_SCHEMA,
"properties": {
k: v
for k, v in _CASH_SIDE_LINE_SCHEMA["properties"].items()
if k != "account_id"
},
},
},
"settlements": {
"type": "array",
"description": (
"تخصیص اقساط فقط برای دریافت: "
"[{person_id, invoice_id, allocations:[{seq, amount}]}]"
),
"items": {
"type": "object",
"properties": {
"person_id": {"type": "integer"},
"invoice_id": {"type": "integer"},
"allocations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"seq": {"type": "integer"},
"amount": {"type": "number"},
},
},
},
},
},
},
}
CREATE_RECEIPT_PAYMENT_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["receipt", "payment"],
"description": "receipt=دریافت از شخص، payment=پرداخت به شخص.",
},
**_RECEIPT_WRITE_FIELDS_SCHEMA,
},
"required": ["type"],
}
CREATE_RECEIPT_PAYMENT_DESCRIPTION = (
"ثبت دریافت یا پرداخت با همان فیلدهای فرم: چند شخص، چند طرف نقدی "
"(بانک/صندوق/تنخواه/چک/شخص)، کارمزد، پروژه، اتصال به فاکتور، اقساط دریافت، بین‌ارزی. "
"برای سند ساده: person_id + amount + account_type + account_id کافی است. "
"قبل از صدا: search_persons و list_bank_accounts یا list_cash_registers. "
"account_id کدینگ list_accounts نیست. نیاز به تأیید."
)
UPDATE_RECEIPT_PAYMENT_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"document_id": {
"type": "integer",
"description": "شناسه سند از search_receipts_payments",
},
**_RECEIPT_WRITE_FIELDS_SCHEMA,
},
"required": ["document_id"],
}
UPDATE_RECEIPT_PAYMENT_DESCRIPTION = (
"ویرایش سند دریافت/پرداخت با جایگزینی کامل سطرها مثل فرم. "
"اول search_receipts_payments. person_lines و account_lines را کامل بفرست "
"یا همان میانبر تک‌سطر. پروژه، فاکتور، اقساط و کارمزد هم همین‌جا است. نیاز به تأیید."
)
def _build_receipt_payment_body(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
require_type: bool = True,
) -> Dict[str, Any]:
raw = dict(args or {})
dtype = str(raw.get("type") or raw.get("document_type") or "").strip()
dtype = _RECEIPT_TYPE_ALIASES.get(dtype, dtype.lower())
if require_type and dtype not in ("receipt", "payment"):
raise ValueError("type باید receipt یا payment باشد.")
person_id = _person_id_from_args(raw)
amount = coerce_number(raw.get("amount"))
desc = raw.get("description") or ""
raw_person_lines = raw.get("person_lines") if isinstance(raw.get("person_lines"), list) else []
raw_account_lines = raw.get("account_lines") if isinstance(raw.get("account_lines"), list) else []
if raw_person_lines:
person_lines = [_normalize_person_doc_line(item) for item in raw_person_lines]
else:
if not person_id or amount is None or amount <= 0:
raise ValueError(
"person_lines یا person_id+amount الزامی است. person_id را از search_persons بگیر."
)
shortcut_person: Dict[str, Any] = {
"person_id": person_id,
"amount": amount,
"description": desc,
}
if raw.get("invoice_id"):
shortcut_person["invoice_id"] = raw.get("invoice_id")
if raw.get("invoice_code"):
shortcut_person["invoice_code"] = raw.get("invoice_code")
person_lines = [_normalize_person_doc_line(shortcut_person)]
tx_date = raw.get("transaction_date")
if raw_account_lines:
account_lines = [
_normalize_cash_side_line(
item,
default_amount=amount,
default_description=desc,
default_transaction_date=tx_date,
)
for item in raw_account_lines
]
else:
if amount is None or amount <= 0:
raise ValueError("amount باید بزرگتر از صفر باشد.")
account_type = _transaction_kind(raw.get("account_type") or raw.get("transaction_type"))
account_id = coerce_positive_int(
raw.get("account_id")
or raw.get("bank_id")
or raw.get("cash_register_id")
or raw.get("petty_cash_id")
or raw.get("check_id")
)
if account_type not in ("bank", "cash_register", "petty_cash", "check", "person") or not account_id:
raise ValueError(
"account_lines یا account_type+account_id الزامی است. "
"id را از list_bank_accounts / list_cash_registers / list_petty_cash / search_checks بگیر "
"نه از list_accounts."
)
shortcut_account: Dict[str, Any] = {
"transaction_type": account_type,
"amount": amount,
"description": desc,
"account_id": account_id,
}
if raw.get("commission") is not None:
shortcut_account["commission"] = raw.get("commission")
if tx_date:
shortcut_account["transaction_date"] = tx_date
if raw.get("check_id"):
shortcut_account["check_id"] = raw.get("check_id")
if raw.get("settles_amount") is not None:
shortcut_account["settles_amount"] = raw.get("settles_amount")
if raw.get("fx_rate") is not None:
shortcut_account["fx_rate"] = raw.get("fx_rate")
if raw.get("payment_currency_id"):
shortcut_account["payment_currency_id"] = raw.get("payment_currency_id")
account_lines = [_normalize_cash_side_line(shortcut_account)]
currency_id = coerce_positive_int(raw.get("currency_id")) or _default_currency_id(
db, business_id or raw.get("business_id")
)
if not currency_id:
raise ValueError("currency_id مشخص نیست. list_currencies را صدا بزن.")
extra = _as_dict(raw.get("extra_info"))
settlements = _normalize_settlements(raw.get("settlements") or extra.get("settlements"))
if not settlements:
settlements = _settlements_from_person_lines(
raw_person_lines or [raw]
)
if settlements:
extra["settlements"] = settlements
payload: Dict[str, Any] = {
"person_lines": person_lines,
"account_lines": account_lines,
}
if raw.get("document_date"):
payload["document_date"] = raw.get("document_date")
elif require_type:
payload["document_date"] = date.today().isoformat()
payload["currency_id"] = currency_id
if "description" in raw or require_type:
payload["description"] = desc
if extra or "extra_info" in raw or "settlements" in raw:
payload["extra_info"] = extra or None
if dtype in ("receipt", "payment"):
payload["document_type"] = dtype
if "project_id" in raw:
payload["project_id"] = coerce_positive_int(raw.get("project_id"))
return payload
def build_create_receipt_payment_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
return _build_receipt_payment_body(
args, db=db, business_id=business_id, require_type=True
)
def build_update_receipt_payment_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
payload = _build_receipt_payment_body(
args, db=db, business_id=business_id, require_type=False
)
payload.pop("document_type", None)
return payload
_WH_DOC_TYPE_ALIASES = {
"receipt": "receipt",
"ورود": "receipt",
"in": "receipt",
"issue": "issue",
"خروج": "issue",
"out": "issue",
"transfer": "transfer",
"انتقال": "transfer",
"adjustment": "adjustment",
"تعدیل": "adjustment",
"production_in": "production_in",
"production_out": "production_out",
}
CREATE_WAREHOUSE_DOCUMENT_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"doc_type": {
"type": "string",
"enum": ["receipt", "issue", "transfer", "adjustment", "production_in", "production_out"],
"description": (
"نوع حواله دستی. receipt=ورود، issue=خروج، transfer=انتقال بین انبار، "
"adjustment=تعدیل. این ابزار حوالهٔ مستقل است نه فاکتور."
),
},
"document_date": {
"type": "string",
"format": "date",
"description": "تاریخ حواله؛ اگر خالی بماند امروز",
},
"warehouse_id": {
"type": "integer",
"description": "انبار واحد (ورود=مقصد، خروج=مبدأ). از list_warehouses",
},
"warehouse_id_from": {
"type": "integer",
"description": "انبار مبدأ — برای خروج و انتقال لازم است",
},
"warehouse_id_to": {
"type": "integer",
"description": "انبار مقصد — برای ورود و انتقال لازم است",
},
"description": {"type": "string", "description": "شرح حواله (اختیاری)"},
"lines": {
"type": "array",
"description": "اقلام حواله. product_id از search_products، quantity > 0",
"items": {
"type": "object",
"properties": {
"product_id": {"type": "integer", "description": "شناسه کالا از search_products"},
"quantity": {"type": "number", "description": "تعداد مثبت"},
"description": {"type": "string", "description": "شرح سطر (اختیاری)"},
},
"required": ["product_id", "quantity"],
},
},
},
"required": ["doc_type", "lines"],
}
CREATE_WAREHOUSE_DOCUMENT_DESCRIPTION = (
"ثبت حواله انبار دستی (ورود/خروج/انتقال). قبل از صدا: "
"list_warehouses برای شناسه انبار، search_products برای product_id. "
"برای ورود warehouse_id یا warehouse_id_to؛ برای خروج warehouse_id یا warehouse_id_from. "
"نیاز به تأیید. حواله در وضعیت پیش‌نویس ساخته می‌شود."
)
def build_create_warehouse_document_payload(args: Dict[str, Any]) -> Dict[str, Any]:
raw = dict(args or {})
doc_type = str(raw.get("doc_type") or raw.get("type") or "").strip()
doc_type = _WH_DOC_TYPE_ALIASES.get(doc_type, doc_type.lower())
allowed = ("receipt", "issue", "transfer", "adjustment", "production_in", "production_out")
if doc_type not in allowed:
raise ValueError(
"doc_type باید یکی از receipt/issue/transfer/adjustment باشد."
)
lines_in = raw.get("lines") or raw.get("items") or []
if not isinstance(lines_in, list) or not lines_in:
raise ValueError("lines الزامی است؛ هر سطر product_id و quantity دارد.")
lines: List[Dict[str, Any]] = []
for item in lines_in:
if not isinstance(item, dict):
raise ValueError("هر سطر حواله باید object باشد.")
pid = coerce_positive_int(item.get("product_id"))
qty = coerce_number(item.get("quantity"))
if not pid:
raise ValueError("هر سطر به product_id عددی نیاز دارد. از search_products بگیر.")
if qty is None or qty <= 0:
raise ValueError("هر سطر به quantity بزرگتر از صفر نیاز دارد.")
line = {"product_id": pid, "quantity": qty}
if item.get("description"):
line["description"] = item.get("description")
lines.append(line)
wid = coerce_positive_int(raw.get("warehouse_id"))
w_from = coerce_positive_int(raw.get("warehouse_id_from"))
w_to = coerce_positive_int(raw.get("warehouse_id_to"))
if doc_type in ("receipt", "production_in"):
w_to = w_to or wid
if not w_to:
raise ValueError("برای حواله ورود warehouse_id یا warehouse_id_to لازم است. از list_warehouses بگیر.")
elif doc_type in ("issue", "production_out"):
w_from = w_from or wid
if not w_from:
raise ValueError("برای حواله خروج warehouse_id یا warehouse_id_from لازم است. از list_warehouses بگیر.")
elif doc_type == "transfer":
if not w_from or not w_to:
raise ValueError("برای انتقال، warehouse_id_from و warehouse_id_to هر دو لازم است.")
else:
w_to = w_to or w_from or wid
if not w_to and not w_from:
raise ValueError("برای تعدیل حداقل یک انبار لازم است.")
return {
"doc_type": doc_type,
"document_date": raw.get("document_date") or date.today().isoformat(),
"warehouse_id_from": w_from,
"warehouse_id_to": w_to,
"description": raw.get("description"),
"lines": lines,
}
_WORKFLOW_STATUS_ALIASES = {
"پیش‌نویس": "پیش‌نویس",
"draft": "پیش‌نویس",
"فعال": "فعال",
"active": "فعال",
"غیرفعال": "غیرفعال",
"inactive": "غیرفعال",
}
def normalize_workflow_graph(data: Any) -> Dict[str, Any]:
if not isinstance(data, dict):
raise ValueError(
"workflow_data باید object با nodes و connections باشد. "
"اول get_workflow_design_rules سپس list_workflow_trigger_catalog و "
"validate_workflow_draft را صدا بزن."
)
nodes = data.get("nodes")
connections = data.get("connections")
if not isinstance(nodes, list):
raise ValueError("workflow_data.nodes باید آرایه باشد.")
if connections is None:
connections = []
if not isinstance(connections, list):
raise ValueError("workflow_data.connections باید آرایه باشد.")
return {**data, "nodes": nodes, "connections": connections}
def build_create_workflow_payload(args: Dict[str, Any]) -> Dict[str, Any]:
raw = dict(args or {})
name = str(raw.get("name") or "").strip()
if not name:
raise ValueError("name الزامی است.")
data = normalize_workflow_graph(raw.get("workflow_data"))
status = str(raw.get("status") or "پیش‌نویس").strip()
status = _WORKFLOW_STATUS_ALIASES.get(status, status)
return {
"name": name,
"description": raw.get("description"),
"workflow_data": data,
"status": status,
"settings": raw.get("settings") if isinstance(raw.get("settings"), dict) else None,
}
_EXPENSE_WRITE_FIELDS_SCHEMA: Dict[str, Any] = {
"document_date": {
"type": "string",
"format": "date",
"description": "تاریخ سند؛ اگر خالی بماند امروز",
},
"currency_id": {
"type": "integer",
"description": "شناسه ارز از list_currencies؛ اگر خالی باشد ارز پیش‌فرض",
},
"project_id": {
"type": "integer",
"description": "پروژه سند از search_projects (اختیاری)",
},
"description": {"type": "string", "description": "شرح سند (اختیاری)"},
"account_id": {
"type": "integer",
"description": "میانبر تک‌سرفصل هزینه/درآمد از list_accounts وقتی item_lines نفرستی",
},
"amount": {
"type": "number",
"description": "میانبر تک‌مبلغ وقتی item_lines/counterparty_lines نفرستی",
},
"line_description": {
"type": "string",
"description": "شرح سطر حساب در حالت میانبر",
},
"counterparty_type": {
"type": "string",
"enum": ["bank", "cash_register", "petty_cash", "check", "person", "account"],
"description": (
"میانبر نوع طرف‌حساب. bank/cash_register/petty_cash از لیست نقد، "
"check از search_checks، person از search_persons، account از list_accounts"
),
},
"counterparty_id": {
"type": "integer",
"description": "میانبر شناسه طرف‌حساب مطابق counterparty_type",
},
"commission": {"type": "number", "description": "کارمزد طرف‌حساب در حالت میانبر"},
"transaction_date": {
"type": "string",
"description": "تاریخ تراکنش طرف‌حساب در حالت میانبر",
},
"item_lines": {
"type": "array",
"description": (
"سطرهای سرفصل هزینه/درآمد مثل فرم. اگر خالی باشد از account_id+amount ساخته می‌شود. "
"جمع باید با counterparty_lines برابر باشد."
),
"items": _EXPENSE_ITEM_LINE_SCHEMA,
},
"counterparty_lines": {
"type": "array",
"description": (
"سطرهای طرف‌حساب مثل فرم: بانک/صندوق/تنخواه/چک/شخص/حساب. "
"اگر خالی باشد از counterparty_type+counterparty_id ساخته می‌شود."
),
"items": _CASH_SIDE_LINE_SCHEMA,
},
}
CREATE_EXPENSE_INCOME_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"document_type": {
"type": "string",
"enum": ["expense", "income"],
"description": "expense=هزینه، income=درآمد",
},
**_EXPENSE_WRITE_FIELDS_SCHEMA,
},
"required": ["document_type"],
}
CREATE_EXPENSE_INCOME_DESCRIPTION = (
"ثبت هزینه یا درآمد با همان فیلدهای فرم: چند سرفصل، چند طرف‌حساب "
"(بانک/صندوق/تنخواه/چک/شخص/حساب)، کارمزد، پروژه. "
"برای سند ساده: account_id + amount + counterparty_type + counterparty_id کافی است. "
"قبل از صدا: list_accounts برای سرفصل و list_bank_accounts یا list_cash_registers برای طرف نقدی. "
"این ابزار حواله کالای هزینه/درآمد انبار نیست. نیاز به تأیید."
)
UPDATE_EXPENSE_INCOME_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"document_id": {
"type": "integer",
"description": "شناسه سند از search_expense_income",
},
**_EXPENSE_WRITE_FIELDS_SCHEMA,
},
"required": ["document_id"],
}
UPDATE_EXPENSE_INCOME_DESCRIPTION = (
"ویرایش سند هزینه/درآمد با جایگزینی کامل سطرها مثل فرم. "
"اول search_expense_income. item_lines و counterparty_lines را کامل بفرست "
"یا همان میانبر تک‌سطر. پروژه و کارمزد هم همین‌جا است. نیاز به تأیید."
)
def _normalize_expense_item_line(item: Any) -> Dict[str, Any]:
if not isinstance(item, dict):
raise ValueError("هر سطر حساب هزینه/درآمد باید object باشد.")
account_id = coerce_positive_int(item.get("account_id"))
amount = coerce_number(item.get("amount"))
if not account_id:
raise ValueError("هر سطر حساب به account_id از list_accounts نیاز دارد.")
if amount is None or amount <= 0:
raise ValueError("مبلغ سطر حساب باید بزرگتر از صفر باشد.")
row: Dict[str, Any] = {"account_id": account_id, "amount": amount}
if item.get("description"):
row["description"] = item.get("description")
return row
def _build_expense_income_body(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
require_type: bool = True,
) -> Dict[str, Any]:
raw = dict(args or {})
dtype = str(raw.get("document_type") or raw.get("type") or "").strip().lower()
dtype = {"expense": "expense", "هزینه": "expense", "income": "income", "درآمد": "income"}.get(
dtype, dtype
)
if require_type and dtype not in ("expense", "income"):
raise ValueError("document_type باید expense یا income باشد.")
amount = coerce_number(raw.get("amount"))
desc = raw.get("description")
raw_items = raw.get("item_lines") if isinstance(raw.get("item_lines"), list) else []
raw_cps = (
raw.get("counterparty_lines")
if isinstance(raw.get("counterparty_lines"), list)
else []
)
if raw_items:
item_lines = [_normalize_expense_item_line(item) for item in raw_items]
else:
account_id = coerce_positive_int(raw.get("account_id"))
if not account_id or amount is None or amount <= 0:
raise ValueError(
"item_lines یا account_id+amount الزامی است. account_id را از list_accounts بگیر."
)
item_lines = [
_normalize_expense_item_line(
{
"account_id": account_id,
"amount": amount,
"description": raw.get("line_description") or desc,
}
)
]
doc_date = raw.get("document_date") or (date.today().isoformat() if require_type else None)
tx_date = raw.get("transaction_date") or (f"{doc_date}T12:00:00" if doc_date else None)
if raw_cps:
counterparty_lines = [
_normalize_cash_side_line(
item,
allow_account=True,
default_amount=amount,
default_description=desc,
default_transaction_date=tx_date,
)
for item in raw_cps
]
else:
raw_cp = str(raw.get("counterparty_type") or "").strip()
cp_type = _transaction_kind(raw_cp)
cp_id = coerce_positive_int(raw.get("counterparty_id"))
if cp_type not in ("bank", "cash_register", "petty_cash", "check", "person", "account") or not cp_id:
raise ValueError(
"counterparty_lines یا counterparty_type+counterparty_id الزامی است."
)
if amount is None or amount <= 0:
raise ValueError("amount باید بزرگتر از صفر باشد.")
shortcut: Dict[str, Any] = {
"transaction_type": cp_type,
"amount": amount,
"transaction_date": tx_date,
"description": desc,
"account_id": cp_id,
}
if raw.get("commission") is not None:
shortcut["commission"] = raw.get("commission")
if raw.get("check_id"):
shortcut["check_id"] = raw.get("check_id")
counterparty_lines = [
_normalize_cash_side_line(shortcut, allow_account=True)
]
currency_id = coerce_positive_int(raw.get("currency_id")) or _default_currency_id(
db, business_id or raw.get("business_id")
)
if not currency_id:
raise ValueError("currency_id مشخص نیست. list_currencies را صدا بزن.")
payload: Dict[str, Any] = {
"currency_id": currency_id,
"item_lines": item_lines,
"counterparty_lines": counterparty_lines,
}
if doc_date:
payload["document_date"] = doc_date
if "description" in raw or require_type:
payload["description"] = desc
if dtype in ("expense", "income"):
payload["document_type"] = dtype
if "project_id" in raw:
payload["project_id"] = coerce_positive_int(raw.get("project_id"))
extra = _as_dict(raw.get("extra_info"))
if extra or "extra_info" in raw:
payload["extra_info"] = extra or None
return payload
def build_create_expense_income_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
return _build_expense_income_body(
args, db=db, business_id=business_id, require_type=True
)
def build_update_expense_income_payload(
args: Dict[str, Any],
*,
db: Any = None,
business_id: Any = None,
) -> Dict[str, Any]:
payload = _build_expense_income_body(
args, db=db, business_id=business_id, require_type=False
)
payload.pop("document_type", None)
return payload
# --- اشخاص: هم‌تراز با فرم UI / PersonCreateRequest و PersonUpdateRequest ---
PERSON_TYPE_ENUM = (
"customer",
"supplier",
"marketer",
"employee",
"partner",
"seller",
"shareholder",
)
_PERSON_TYPE_ALIASES = {
"customer": "مشتری",
"مشتری": "مشتری",
"supplier": "تامین‌کننده",
"تامین‌کننده": "تامین‌کننده",
"تامین کننده": "تامین‌کننده",
"تأمين‌کننده": "تامین‌کننده",
"vendor": "تامین‌کننده",
"marketer": "بازاریاب",
"بازاریاب": "بازاریاب",
"employee": "کارمند",
"کارمند": "کارمند",
"partner": "همکار",
"همکار": "همکار",
"seller": "فروشنده",
"فروشنده": "فروشنده",
"shareholder": "سهامدار",
"سهامدار": "سهامدار",
}
_PERSON_SOCIAL_PLATFORMS = (
"telegram",
"bale",
"rubika",
"eitaa",
"whatsapp",
"instagram",
"linkedin",
"twitter",
"other",
)
_PERSON_BANK_ACCOUNT_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"bank_name": {
"type": "string",
"description": "نام بانک (مثلاً مهر ایران). الزامی.",
},
"account_number": {"type": "string", "description": "شماره حساب (اختیاری)"},
"card_number": {
"type": "string",
"description": "شماره کارت بانکی شخص (اختیاری)",
},
"sheba_number": {"type": "string", "description": "شماره شبا (اختیاری)"},
},
"required": ["bank_name"],
}
_PERSON_SOCIAL_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"platform_key": {
"type": "string",
"enum": list(_PERSON_SOCIAL_PLATFORMS),
"description": "پلتفرم پیام‌رسان. برای سایر از other + custom_label استفاده کن.",
},
"value": {"type": "string", "description": "آیدی، لینک یا شماره"},
"custom_label": {
"type": "string",
"description": "برچسب نمایش؛ برای platform_key=other الزامی است",
},
},
"required": ["platform_key", "value"],
}
_PERSON_OPENING_BALANCE_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"amount": {
"type": "number",
"description": "مبلغ مانده. در ایجاد باید >0 باشد مگر در ویرایش با clear=true",
},
"balance_type": {
"type": "string",
"enum": ["debit", "credit"],
"description": "debit=شخص بدهکار (دریافتنی)، credit=بستانکار (پرداختنی)",
},
"fiscal_year_id": {
"type": "integer",
"description": "سال مالی؛ اگر خالی باشد سال جاری",
},
"clear": {
"type": "boolean",
"description": "فقط در ویرایش: حذف خط مانده از سند افتتاحیه",
},
},
}
_PERSON_WRITE_FIELDS_SCHEMA: Dict[str, Any] = {
"alias_name": {
"type": "string",
"description": "نام مستعار نمایشی. اگر خالی باشد از name ساخته می‌شود.",
},
"first_name": {"type": "string", "description": "نام"},
"last_name": {"type": "string", "description": "نام خانوادگی"},
"person_types": {
"type": "array",
"items": {"type": "string", "enum": list(PERSON_TYPE_ENUM)},
"description": (
"انواع شخص (چندانتخابی). معادل فرم UI: "
"customer/supplier/marketer/employee/partner/seller/shareholder"
),
},
"company_name": {"type": "string", "description": "نام شرکت"},
"name_prefix": {
"type": "string",
"description": "پیشوند: آقای، خانم، شرکت، دکتر، مهندس، موسسه، اداره، سازمان، بنیاد، انجمن",
},
"legal_entity_type": {
"type": "string",
"enum": ["natural", "legal"],
"description": "natural=حقیقی، legal=حقوقی",
},
"payment_id": {"type": "string", "description": "شناسه پرداخت"},
"person_group_id": {
"type": "integer",
"description": "شناسه گروه اشخاص از list_person_groups",
},
"national_id": {"type": "string", "description": "شناسه ملی / کد ملی"},
"registration_number": {"type": "string", "description": "شماره ثبت"},
"economic_id": {"type": "string", "description": "شناسه اقتصادی"},
"country": {"type": "string"},
"province": {"type": "string"},
"city": {"type": "string"},
"address": {"type": "string"},
"postal_code": {"type": "string"},
"phone": {"type": "string", "description": "تلفن ثابت"},
"mobile": {"type": "string", "description": "موبایل"},
"mobile_2": {"type": "string"},
"mobile_3": {"type": "string"},
"fax": {"type": "string"},
"email": {"type": "string", "format": "email"},
"website": {"type": "string"},
"bank_accounts": {
"type": "array",
"description": (
"حساب‌های بانکی روی کارت شخص (نام بانک، شماره حساب، کارت، شبا). "
"این همان بخش فرم تعریف شخص است — نه list_bank_accounts که حساب خزانه کسب‌وکار است. "
"ابزار جداگانه‌ای مثل create_bank_card وجود ندارد؛ همین‌جا بفرست. "
"در ویرایش اگر ارسال شود کل لیست جایگزین می‌شود."
),
"items": _PERSON_BANK_ACCOUNT_ITEM_SCHEMA,
},
"social_contacts": {
"type": "array",
"description": "راه‌های ارتباط (تلگرام و …). در ویرایش اگر ارسال شود جایگزین کامل است.",
"items": _PERSON_SOCIAL_ITEM_SCHEMA,
},
"share_count": {
"type": "integer",
"description": "تعداد سهام؛ برای سهامدار الزامی و حداقل ۱",
},
"commission_sale_percent": {"type": "number", "description": "درصد پورسانت فروش (بازاریاب/فروشنده)"},
"commission_sales_return_percent": {"type": "number"},
"commission_sales_amount": {"type": "number"},
"commission_sales_return_amount": {"type": "number"},
"commission_exclude_discounts": {"type": "boolean"},
"commission_exclude_additions_deductions": {"type": "boolean"},
"commission_post_in_invoice_document": {"type": "boolean"},
"credit_limit": {"type": "number", "description": "سقف اعتبار شخص"},
"credit_check_enabled": {
"type": "boolean",
"description": "بررسی اعتبار. اگر نفرستی از تنظیم کسب‌وکار تبعیت می‌شود.",
},
"opening_balance": _PERSON_OPENING_BALANCE_SCHEMA,
}
CREATE_PERSON_DESCRIPTION = (
"ایجاد شخص جدید با همان فیلدهای فرم تعریف شخص در رابط کاربری. "
"name و person_type الزامی است. برای کارت/شبا/شماره حساب شخص از bank_accounts استفاده کن "
"(نه list_bank_accounts و نه ابزار ساختگی create_bank_card). "
"گروه را با list_person_groups بگیر. نیاز به تأیید."
)
CREATE_PERSON_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "نام نمایشی شخص (alias_name). همان نامی که کاربر گفته را عیناً بفرست.",
},
"person_type": {
"type": "string",
"enum": list(PERSON_TYPE_ENUM),
"description": "نوع شخص. اگر چند نوع است person_types را هم بفرست.",
},
"code": {
"type": "integer",
"description": "کد شخص؛ اگر خالی باشد خودکار تولید می‌شود",
},
"tax_id": {
"type": "string",
"description": "سازگاری قدیمی: اگر national_id خالی باشد به‌عنوان شناسه ملی استفاده می‌شود",
},
**_PERSON_WRITE_FIELDS_SCHEMA,
},
"required": ["name", "person_type"],
}
UPDATE_PERSON_DESCRIPTION = (
"ویرایش شخص با همان فیلدهای فرم ویرایش در رابط کاربری. فقط فیلدهایی را بفرست که باید عوض شوند. "
"bank_accounts در صورت ارسال جایگزین کامل حساب‌های بانکی شخص می‌شود. نیاز به تأیید."
)
UPDATE_PERSON_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"person_id": {"type": "integer", "description": "شناسه شخص از search_persons"},
"name": {
"type": "string",
"description": "نام نمایشی جدید (alias_name). اگر خالی باشد نام عوض نمی‌شود.",
},
"person_type": {
"type": "string",
"enum": list(PERSON_TYPE_ENUM),
"description": "نوع تکی؛ ترجیحاً person_types را بفرست",
},
"code": {"type": "integer", "description": "کد شخص"},
"tax_id": {
"type": "string",
"description": "سازگاری قدیمی برای شناسه ملی اگر national_id نفرستی",
},
**_PERSON_WRITE_FIELDS_SCHEMA,
},
"required": ["person_id"],
}
def _blank_to_none(value: Any) -> Optional[str]:
if value is None:
return None
s = str(value).strip()
return s if s else None
def normalize_person_type_value(raw: Any) -> Optional[str]:
if raw is None:
return None
key = str(raw).strip()
if not key:
return None
mapped = _PERSON_TYPE_ALIASES.get(key) or _PERSON_TYPE_ALIASES.get(key.lower())
if mapped:
return mapped
raise ValueError(
"نوع شخص نامعتبر است. مقادیر مجاز: "
+ "، ".join(PERSON_TYPE_ENUM)
+ " یا معادل فارسی (مشتری، تامین‌کننده، بازاریاب، کارمند، همکار، فروشنده، سهامدار)."
)
def normalize_person_types(args: Dict[str, Any]) -> List[str]:
values: List[str] = []
raw_list = args.get("person_types")
if isinstance(raw_list, list):
for item in raw_list:
mapped = normalize_person_type_value(item)
if mapped and mapped not in values:
values.append(mapped)
single = normalize_person_type_value(args.get("person_type")) if args.get("person_type") else None
if single and single not in values:
values.append(single)
return values
def _normalize_person_bank_accounts(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("bank_accounts باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
bank_name = _blank_to_none(
item.get("bank_name") or item.get("bank") or item.get("name")
)
if not bank_name:
continue
out.append(
{
"bank_name": bank_name,
"account_number": _blank_to_none(
item.get("account_number") or item.get("account")
),
"card_number": _blank_to_none(
item.get("card_number") or item.get("card")
),
"sheba_number": _blank_to_none(
item.get("sheba_number")
or item.get("sheba")
or item.get("iban")
),
}
)
return out
def _normalize_person_social_contacts(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("social_contacts باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
platform = (_blank_to_none(item.get("platform_key") or item.get("platform")) or "").lower()
value = _blank_to_none(item.get("value"))
if not platform or not value:
continue
row: Dict[str, Any] = {"platform_key": platform, "value": value}
label = _blank_to_none(item.get("custom_label") or item.get("label"))
if label:
row["custom_label"] = label
out.append(row)
return out
def _normalize_opening_balance(raw: Any) -> Optional[Dict[str, Any]]:
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("opening_balance باید شیء باشد.")
payload: Dict[str, Any] = {}
if "amount" in raw and raw.get("amount") is not None:
payload["amount"] = float(raw["amount"])
if raw.get("balance_type"):
bt = str(raw["balance_type"]).strip().lower()
aliases = {
"debit": "debit",
"بدهکار": "debit",
"دریافتنی": "debit",
"credit": "credit",
"بستانکار": "credit",
"پرداختنی": "credit",
}
mapped = aliases.get(bt)
if not mapped:
raise ValueError("balance_type باید debit یا credit باشد.")
payload["balance_type"] = mapped
if raw.get("fiscal_year_id") is not None:
payload["fiscal_year_id"] = int(raw["fiscal_year_id"])
if "clear" in raw:
payload["clear"] = bool(raw["clear"])
return payload or None
def _copy_optional_str(args: Dict[str, Any], payload: Dict[str, Any], *keys: str) -> None:
for key in keys:
if key in args:
payload[key] = _blank_to_none(args.get(key))
def _copy_optional_number(args: Dict[str, Any], payload: Dict[str, Any], key: str, *, as_int: bool = False) -> None:
if key not in args:
return
raw = args.get(key)
if raw is None or raw == "":
payload[key] = None
return
payload[key] = int(raw) if as_int else float(raw)
def _copy_optional_bool(args: Dict[str, Any], payload: Dict[str, Any], key: str) -> None:
if key in args:
payload[key] = None if args.get(key) is None else bool(args.get(key))
def _apply_person_write_fields(args: Dict[str, Any], payload: Dict[str, Any], *, for_update: bool) -> None:
_copy_optional_str(
args,
payload,
"first_name",
"last_name",
"company_name",
"name_prefix",
"legal_entity_type",
"payment_id",
"national_id",
"registration_number",
"economic_id",
"country",
"province",
"city",
"address",
"postal_code",
"phone",
"mobile",
"mobile_2",
"mobile_3",
"fax",
"email",
"website",
)
if "tax_id" in args and not payload.get("national_id"):
payload["national_id"] = _blank_to_none(args.get("tax_id"))
if "code" in args:
_copy_optional_number(args, payload, "code", as_int=True)
if "person_group_id" in args:
_copy_optional_number(args, payload, "person_group_id", as_int=True)
if "share_count" in args:
_copy_optional_number(args, payload, "share_count", as_int=True)
for key in (
"commission_sale_percent",
"commission_sales_return_percent",
"commission_sales_amount",
"commission_sales_return_amount",
"credit_limit",
):
_copy_optional_number(args, payload, key)
for key in (
"commission_exclude_discounts",
"commission_exclude_additions_deductions",
"commission_post_in_invoice_document",
"credit_check_enabled",
):
_copy_optional_bool(args, payload, key)
if "bank_accounts" in args:
payload["bank_accounts"] = _normalize_person_bank_accounts(args.get("bank_accounts")) or []
if "social_contacts" in args:
payload["social_contacts"] = _normalize_person_social_contacts(args.get("social_contacts")) or []
if "opening_balance" in args:
payload["opening_balance"] = _normalize_opening_balance(args.get("opening_balance"))
types = normalize_person_types(args)
if types:
payload["person_types"] = types
elif not for_update and args.get("person_type"):
payload["person_types"] = normalize_person_types(args)
def build_create_person_payload(args: Dict[str, Any]) -> Dict[str, Any]:
name = _blank_to_none(args.get("alias_name") or args.get("name"))
if not name:
first = _blank_to_none(args.get("first_name"))
last = _blank_to_none(args.get("last_name"))
name = " ".join(p for p in (first, last) if p) or None
if not name:
raise ValueError("name الزامی است.")
types = normalize_person_types(args)
if not types:
raise ValueError(
"person_type یا person_types الزامی است "
"(customer، supplier، marketer، employee، partner، seller، shareholder)."
)
payload: Dict[str, Any] = {
"alias_name": name,
"person_types": types,
}
_apply_person_write_fields(args, payload, for_update=False)
if "first_name" not in args and args.get("name") and not args.get("last_name"):
payload.setdefault("first_name", name)
payload["person_types"] = types
return payload
def build_update_person_payload(args: Dict[str, Any]) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
if args.get("alias_name") or args.get("name"):
payload["alias_name"] = _blank_to_none(args.get("alias_name") or args.get("name"))
if args.get("name") and "first_name" not in args:
payload["first_name"] = payload["alias_name"]
_apply_person_write_fields(args, payload, for_update=True)
return payload
# --- کالا/خدمت: هم‌تراز با فرم UI / ProductCreateRequest و ProductUpdateRequest ---
_PRODUCT_ITEM_TYPE_ALIASES = {
"کالا": "کالا",
"product": "کالا",
"goods": "کالا",
"commodity": "کالا",
"item": "کالا",
"خدمت": "خدمت",
"service": "خدمت",
}
_PRODUCT_INVENTORY_MODE_ALIASES = {
"bulk": "bulk",
"فله": "bulk",
"فله‌ای": "bulk",
"unique": "unique",
"یونیک": "unique",
"serial": "unique",
}
_PRODUCT_FIELD_ALIASES = {
"price": "base_sales_price",
"sales_price": "base_sales_price",
"sale_price": "base_sales_price",
"unit_price": "base_sales_price",
"purchase_price": "base_purchase_price",
"buy_price": "base_purchase_price",
"unit": "main_unit",
"warehouse_id": "default_warehouse_id",
"type": "item_type",
"sku": "code",
"fx_sales_price": "sales_price_fx",
"fx_purchase_price": "purchase_price_fx",
"fx_currency_id": "price_fx_currency_id",
}
_PRODUCT_CATALOG_SPEC_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"field_id": {"type": "integer", "description": "شناسه قالب مشخصات کسب‌وکار (اختیاری)"},
"label": {"type": "string", "description": "عنوان مشخصه"},
"value": {"type": "string", "description": "مقدار مشخصه"},
"sort_order": {"type": "integer"},
},
"required": ["label"],
}
_PRODUCT_SUPPLIER_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"person_id": {
"type": "integer",
"description": "شناسه شخص تأمین‌کننده از search_persons (اختیاری)",
},
"name": {"type": "string", "description": "نام تأمین‌کننده اگر person_id نباشد"},
"website": {"type": "string"},
"phone": {"type": "string"},
"email": {"type": "string"},
"notes": {"type": "string"},
"is_preferred": {"type": "boolean"},
"sort_order": {"type": "integer"},
"social_contacts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"platform_key": {
"type": "string",
"enum": list(_PERSON_SOCIAL_PLATFORMS),
},
"value": {"type": "string"},
"custom_label": {"type": "string"},
},
"required": ["platform_key", "value"],
},
},
},
}
_PRODUCT_PRICE_LIST_ITEM_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"price_list_id": {
"type": "integer",
"description": "شناسه لیست قیمت از list_price_lists",
},
"price_list_name": {
"type": "string",
"description": "نام لیست (عمده/همکار/…) اگر id را نداری",
},
"currency_id": {
"type": "integer",
"description": "شناسه ارز از list_currencies",
},
"currency_code": {
"type": "string",
"description": "کد ارز مثل USD یا IRR اگر id را نداری",
},
"price": {"type": "number", "description": "قیمت در این لیست و این ارز"},
"tier_name": {
"type": "string",
"description": "نام پله (تکی/عمده/همکار). پیش‌فرض: پیش‌فرض",
},
"min_qty": {
"type": "number",
"description": "حداقل تعداد برای این پله. پیش‌فرض ۰",
},
},
"required": ["price"],
}
_PRODUCT_OPENING_BALANCE_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"quantity": {
"type": "number",
"description": "تعداد اولیه. در ایجاد باید >0 باشد مگر در ویرایش با clear=true",
},
"cost_price": {
"type": "number",
"description": "بهای تمام‌شده هر واحد برای ارزش‌گذاری موجودی",
},
"warehouse_id": {
"type": "integer",
"description": "انبار از list_warehouses؛ اگر خالی باشد default_warehouse_id کالا",
},
"fiscal_year_id": {"type": "integer", "description": "سال مالی؛ پیش‌فرض سال جاری"},
"clear": {
"type": "boolean",
"description": "فقط در ویرایش: حذف خط موجودی اولیه از سند افتتاحیه",
},
},
}
_PRODUCT_WRITE_FIELDS_SCHEMA: Dict[str, Any] = {
"item_type": {
"type": "string",
"enum": ["کالا", "خدمت"],
"description": "کالا (انبارداری ممکن) یا خدمت. معادل product/service هم پذیرفته می‌شود.",
},
"code": {
"type": "string",
"description": "کد کالا؛ اگر خالی باشد خودکار تولید می‌شود",
},
"description": {"type": "string", "description": "توضیحات کالا"},
"category_id": {
"type": "integer",
"description": "شناسه دسته‌بندی از search_categories",
},
"main_unit": {"type": "string", "description": "واحد اصلی (مثلاً عدد، کیلوگرم)"},
"secondary_unit": {"type": "string", "description": "واحد فرعی؛ با unit_conversion_factor"},
"unit_conversion_factor": {
"type": "number",
"description": "ضریب تبدیل واحد فرعی به اصلی (مثلاً ۱۲ یعنی یک بسته = ۱۲ عدد)",
},
"base_sales_price": {
"type": "number",
"description": "قیمت فروش پایه به ارز پایه کسب‌وکار",
},
"base_purchase_price": {
"type": "number",
"description": "قیمت خرید پایه به ارز پایه کسب‌وکار",
},
"sales_price_fx": {
"type": "number",
"description": "قیمت فروش به ارز دیگر (چندارزی). با price_fx_currency_id بفرست.",
},
"purchase_price_fx": {
"type": "number",
"description": "قیمت خرید به ارز دیگر (چندارزی). با price_fx_currency_id بفرست.",
},
"price_fx_currency_id": {
"type": "integer",
"description": "ارز قیمت‌های ارزی کالا از list_currencies",
},
"auto_update_base_from_fx": {
"type": "boolean",
"description": "به‌روزرسانی خودکار قیمت پایه از نرخ × قیمت ارزی",
},
"base_sales_note": {"type": "string", "description": "یادداشت پیش‌فرض فروش"},
"base_purchase_note": {"type": "string", "description": "یادداشت پیش‌فرض خرید"},
"track_inventory": {
"type": "boolean",
"description": "کنترل موجودی انبار. برای خدمت باید false باشد.",
},
"default_warehouse_id": {
"type": "integer",
"description": "انبار پیش‌فرض از list_warehouses",
},
"reorder_point": {"type": "integer", "description": "نقطه سفارش مجدد"},
"min_order_qty": {"type": "integer", "description": "حداقل تعداد سفارش"},
"lead_time_days": {"type": "integer", "description": "زمان تحویل به روز"},
"inventory_mode": {
"type": "string",
"enum": ["bulk", "unique"],
"description": "bulk=فله‌ای، unique=یونیک (سریال/بارکد نمونه)",
},
"track_serial": {"type": "boolean", "description": "ردیابی سریال برای کالای یونیک"},
"track_barcode": {"type": "boolean", "description": "ردیابی بارکد برای کالای یونیک"},
"is_sales_taxable": {"type": "boolean"},
"is_purchase_taxable": {"type": "boolean"},
"sales_tax_rate": {"type": "number", "description": "نرخ مالیات فروش (درصد ۰ تا ۱۰۰)"},
"purchase_tax_rate": {"type": "number", "description": "نرخ مالیات خرید (درصد)"},
"tax_type_id": {"type": "integer", "description": "شناسه نوع مالیات از get_tax_settings"},
"tax_code": {"type": "string", "description": "کد مالیاتی محصول"},
"tax_unit_id": {"type": "integer", "description": "شناسه واحد مالیاتی"},
"attribute_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "شناسه ویژگی‌ها از list_product_attributes. در ویرایش جایگزین کامل است.",
},
"barcode": {"type": "string", "description": "بارکد تکی (legacy)"},
"general_barcodes": {
"type": "string",
"description": "بارکدهای عمومی جدا شده با ویرگول برای اسکن و چاپ برچسب",
},
"image_file_id": {
"type": "string",
"description": "UUID فایل تصویر از قبل آپلودشده (آپلود باینری از این ابزار ممکن نیست)",
},
"is_active": {"type": "boolean", "description": "فعال بودن کالا"},
"is_public_catalog": {
"type": "boolean",
"description": "انتشار در کاتالوگ عمومی شبکه تأمین",
},
"catalog_short_description": {"type": "string"},
"catalog_expert_review": {"type": "string"},
"catalog_specifications": {
"type": "array",
"description": "مشخصات فنی کاتالوگ (عنوان/مقدار)",
"items": _PRODUCT_CATALOG_SPEC_ITEM_SCHEMA,
},
"catalog_brand": {"type": "string"},
"catalog_model": {"type": "string"},
"catalog_country_of_origin": {"type": "string"},
"catalog_video_url": {"type": "string"},
"catalog_gallery_file_ids": {
"type": "array",
"items": {"type": "string"},
"description": "شناسه فایل‌های گالری کاتالوگ (حداکثر ۱۲)",
},
"suppliers": {
"type": "array",
"description": "تأمین‌کنندگان کالا. در ویرایش اگر ارسال شود جایگزین کامل است.",
"items": _PRODUCT_SUPPLIER_ITEM_SCHEMA,
},
"opening_balance": _PRODUCT_OPENING_BALANCE_SCHEMA,
"price_list_items": {
"type": "array",
"description": (
"قیمت کالا در لیست‌های قیمت فرم تعریف کالا (عمده/همکار/ارز جدا). "
"مثل UI بعد از ذخیره کالا upsert می‌شود؛ جایگزین همهٔ لیست‌ها نیست. "
"شناسه لیست از list_price_lists، ارز از list_currencies."
),
"items": _PRODUCT_PRICE_LIST_ITEM_SCHEMA,
},
}
CREATE_PRODUCT_DESCRIPTION = (
"ایجاد کالا یا خدمت با همان فیلدهای فرم تعریف کالا در رابط کاربری: "
"واحد، قیمت فروش/خرید پایه، قیمت ارزی (sales_price_fx و price_fx_currency_id)، "
"لیست‌های قیمت (price_list_items)، انبار، مالیات، بارکد، ویژگی، تأمین‌کننده، کاتالوگ، تعداد اولیه. "
"name الزامی است. دسته از search_categories، انبار از list_warehouses، "
"لیست قیمت از list_price_lists، ارز از list_currencies. "
"فرمول ساخت (BOM) و نمونه‌های یونیک ابزار جدا دارند. نیاز به تأیید."
)
CREATE_PRODUCT_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"name": {"type": "string", "description": "نام کالا یا خدمت. همان نام کاربر را عیناً بفرست."},
**_PRODUCT_WRITE_FIELDS_SCHEMA,
},
"required": ["name"],
}
UPDATE_PRODUCT_DESCRIPTION = (
"ویرایش کالا/خدمت با همان فیلدهای فرم ویرایش در رابط کاربری، از جمله قیمت پایه/ارزی و price_list_items. "
"فقط فیلدهای ارسالی تغییر می‌کنند. attribute_ids و suppliers در صورت ارسال جایگزین کامل‌اند. "
"price_list_items مثل فرم upsert است نه حذف همهٔ قیمت‌های قبلی. نیاز به تأیید."
)
UPDATE_PRODUCT_PARAMETERS_SCHEMA: Dict[str, Any] = {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "شناسه کالا از search_products",
},
"name": {"type": "string", "description": "نام جدید"},
**_PRODUCT_WRITE_FIELDS_SCHEMA,
},
"required": ["product_id"],
}
def normalize_product_item_type(raw: Any) -> Optional[str]:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
mapped = _PRODUCT_ITEM_TYPE_ALIASES.get(s) or _PRODUCT_ITEM_TYPE_ALIASES.get(s.lower())
if mapped:
return mapped
raise ValueError("item_type باید کالا یا خدمت باشد (یا product/service).")
def normalize_product_inventory_mode(raw: Any) -> Optional[str]:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
mapped = _PRODUCT_INVENTORY_MODE_ALIASES.get(s) or _PRODUCT_INVENTORY_MODE_ALIASES.get(s.lower())
if mapped:
return mapped
raise ValueError("inventory_mode باید bulk یا unique باشد.")
def _with_product_aliases(args: Dict[str, Any]) -> Dict[str, Any]:
out = dict(args)
for src, dest in _PRODUCT_FIELD_ALIASES.items():
if dest not in out and src in out:
out[dest] = out[src]
if "category_id" not in out and "category" in out:
raw = out.get("category")
if isinstance(raw, int) or (isinstance(raw, str) and raw.strip().isdigit()):
out["category_id"] = int(raw)
if "general_barcodes" not in out and "barcodes" in out:
raw = out.get("barcodes")
if isinstance(raw, list):
out["general_barcodes"] = ",".join(
str(x).strip() for x in raw if str(x).strip()
) or None
else:
out["general_barcodes"] = _blank_to_none(raw)
return out
def _int_id_list(raw: Any, field: str) -> Optional[List[int]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError(f"{field} باید آرایه عدد باشد.")
out: List[int] = []
for item in raw:
if item is None or item == "":
continue
try:
out.append(int(item))
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} باید شامل شناسه عددی باشد.") from exc
return out
def _str_id_list(raw: Any, field: str) -> Optional[List[str]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError(f"{field} باید آرایه رشته باشد.")
return [s for s in (_blank_to_none(x) for x in raw) if s]
def _normalize_catalog_specifications(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("catalog_specifications باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
label = _blank_to_none(item.get("label") or item.get("title") or item.get("name"))
if not label:
continue
row: Dict[str, Any] = {
"label": label,
"value": item.get("value") if item.get("value") is not None else "",
"sort_order": int(item["sort_order"]) if item.get("sort_order") is not None else i,
}
if item.get("field_id") is not None:
row["field_id"] = int(item["field_id"])
out.append(row)
return out
def _normalize_product_suppliers(raw: Any) -> Optional[List[Dict[str, Any]]]:
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("suppliers باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
name = _blank_to_none(item.get("name"))
person_id = item.get("person_id")
if person_id is None and not name:
continue
row: Dict[str, Any] = {
"name": name,
"website": _blank_to_none(item.get("website")),
"phone": _blank_to_none(item.get("phone")),
"email": _blank_to_none(item.get("email")),
"notes": _blank_to_none(item.get("notes")),
"is_preferred": bool(item.get("is_preferred", False)),
"sort_order": int(item["sort_order"]) if item.get("sort_order") is not None else i,
}
if person_id is not None and person_id != "":
row["person_id"] = int(person_id)
socials = _normalize_person_social_contacts(item.get("social_contacts"))
if socials:
row["social_contacts"] = socials
out.append(row)
return out
def _normalize_product_opening_balance(raw: Any) -> Optional[Dict[str, Any]]:
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("opening_balance باید شیء باشد.")
payload: Dict[str, Any] = {}
qty = raw.get("quantity")
if qty is None:
qty = raw.get("qty") or raw.get("count")
if qty is not None and qty != "":
payload["quantity"] = float(qty)
cost = raw.get("cost_price")
if cost is None:
cost = raw.get("cost") or raw.get("unit_cost")
if cost is not None and cost != "":
payload["cost_price"] = float(cost)
if raw.get("warehouse_id") is not None and raw.get("warehouse_id") != "":
payload["warehouse_id"] = int(raw["warehouse_id"])
if raw.get("fiscal_year_id") is not None and raw.get("fiscal_year_id") != "":
payload["fiscal_year_id"] = int(raw["fiscal_year_id"])
if "clear" in raw:
payload["clear"] = bool(raw["clear"])
return payload or None
def _apply_product_write_fields(args: Dict[str, Any], payload: Dict[str, Any]) -> None:
if "item_type" in args:
payload["item_type"] = normalize_product_item_type(args.get("item_type"))
if "code" in args:
code = args.get("code")
payload["code"] = None if code is None or code == "" else str(code).strip() or None
_copy_optional_str(
args,
payload,
"description",
"main_unit",
"secondary_unit",
"base_sales_note",
"base_purchase_note",
"tax_code",
"barcode",
"general_barcodes",
"image_file_id",
"catalog_short_description",
"catalog_expert_review",
"catalog_brand",
"catalog_model",
"catalog_country_of_origin",
"catalog_video_url",
)
for key in (
"category_id",
"price_fx_currency_id",
"default_warehouse_id",
"reorder_point",
"min_order_qty",
"lead_time_days",
"tax_type_id",
"tax_unit_id",
):
_copy_optional_number(args, payload, key, as_int=True)
for key in (
"unit_conversion_factor",
"base_sales_price",
"base_purchase_price",
"sales_price_fx",
"purchase_price_fx",
"sales_tax_rate",
"purchase_tax_rate",
):
_copy_optional_number(args, payload, key)
for key in (
"auto_update_base_from_fx",
"track_inventory",
"track_serial",
"track_barcode",
"is_sales_taxable",
"is_purchase_taxable",
"is_active",
"is_public_catalog",
):
_copy_optional_bool(args, payload, key)
if "inventory_mode" in args:
payload["inventory_mode"] = normalize_product_inventory_mode(args.get("inventory_mode"))
if "attribute_ids" in args:
payload["attribute_ids"] = _int_id_list(args.get("attribute_ids"), "attribute_ids") or []
if "catalog_gallery_file_ids" in args:
payload["catalog_gallery_file_ids"] = (
_str_id_list(args.get("catalog_gallery_file_ids"), "catalog_gallery_file_ids") or []
)
if "catalog_specifications" in args:
payload["catalog_specifications"] = (
_normalize_catalog_specifications(args.get("catalog_specifications")) or []
)
if "suppliers" in args:
payload["suppliers"] = _normalize_product_suppliers(args.get("suppliers")) or []
if "opening_balance" in args:
payload["opening_balance"] = _normalize_product_opening_balance(args.get("opening_balance"))
def build_create_product_payload(args: Dict[str, Any]) -> Dict[str, Any]:
src = _with_product_aliases(args)
name = _blank_to_none(src.get("name"))
if not name:
raise ValueError("name الزامی است.")
payload: Dict[str, Any] = {"name": name}
_apply_product_write_fields(src, payload)
if payload.get("item_type") is None:
payload["item_type"] = "کالا"
return payload
def build_update_product_payload(args: Dict[str, Any]) -> Dict[str, Any]:
src = _with_product_aliases(args)
payload: Dict[str, Any] = {}
if "name" in src:
name = _blank_to_none(src.get("name"))
if not name:
raise ValueError("name نمی‌تواند خالی باشد.")
payload["name"] = name
_apply_product_write_fields(src, payload)
return payload
def extract_product_price_list_items(args: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""ردیف‌های لیست قیمت فرم کالا — در ProductCreateRequest نیستند و جدا upsert می‌شوند."""
raw = args.get("price_list_items")
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError("price_list_items باید آرایه باشد.")
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
price = item.get("price")
if price is None or price == "":
continue
row: Dict[str, Any] = {"price": float(price)}
pl_id = item.get("price_list_id")
if pl_id is None:
pl_id = item.get("list_id")
if pl_id is None:
maybe_list = item.get("price_list")
if isinstance(maybe_list, int) or (
isinstance(maybe_list, str) and maybe_list.strip().isdigit()
):
pl_id = maybe_list
if pl_id is not None and pl_id != "":
row["price_list_id"] = int(pl_id)
name = _blank_to_none(item.get("price_list_name") or item.get("list_name"))
if name is None and not isinstance(item.get("price_list"), (int, float)):
name = _blank_to_none(item.get("price_list"))
if name:
row["price_list_name"] = name
cur_id = item.get("currency_id")
if cur_id is not None and cur_id != "":
row["currency_id"] = int(cur_id)
code = _blank_to_none(
item.get("currency_code") or item.get("currency") or item.get("fx_code")
)
if code:
row["currency_code"] = code
if "price_list_id" not in row and "price_list_name" not in row:
raise ValueError(
"هر ردیف price_list_items باید price_list_id یا price_list_name داشته باشد. "
"ابتدا list_price_lists را صدا بزن."
)
tier = _blank_to_none(item.get("tier_name") or item.get("tier"))
if tier:
row["tier_name"] = tier
min_qty = item.get("min_qty")
if min_qty is None:
min_qty = item.get("min_quantity")
if min_qty is not None and min_qty != "":
row["min_qty"] = float(min_qty)
out.append(row)
return out