forked from hesabix/arc
1223 lines
41 KiB
Python
Executable file
1223 lines
41 KiB
Python
Executable file
from typing import Dict, Any, List
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, Request, Body, Response
|
|
from sqlalchemy.orm import Session
|
|
|
|
from adapters.db.session import get_db, SessionLocal
|
|
from app.core.auth_dependency import get_current_user, AuthContext
|
|
from app.core.permissions import require_business_access
|
|
from app.core.responses import success_response, ApiError
|
|
from adapters.db.models.document import Document
|
|
from adapters.db.models.invoice_item_line import InvoiceItemLine
|
|
from adapters.db.models.warehouse_document import WarehouseDocument
|
|
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
|
|
from app.services.invoice_service import (
|
|
INVOICE_SALES,
|
|
INVOICE_SALES_RETURN,
|
|
INVOICE_PURCHASE,
|
|
INVOICE_PURCHASE_RETURN,
|
|
INVOICE_DIRECT_CONSUMPTION,
|
|
INVOICE_PRODUCTION,
|
|
INVOICE_WASTE,
|
|
)
|
|
from adapters.api.v1.list_query_common import WarehouseDocListQueryBody, warehouse_doc_list_to_dict
|
|
from app.services.warehouse_service import (
|
|
apply_warehouse_documents_sort,
|
|
bulk_delete_warehouse_documents,
|
|
cancel_warehouse_document,
|
|
calculate_stock_count_differences,
|
|
create_from_invoice,
|
|
create_manual_warehouse_document,
|
|
create_stock_count_adjustment,
|
|
compute_invoice_line_quantities,
|
|
delete_warehouse_document,
|
|
export_warehouse_documents_excel,
|
|
post_warehouse_document,
|
|
search_invoices_for_warehouse_sources,
|
|
start_stock_count,
|
|
update_warehouse_document,
|
|
update_warehouse_document_line,
|
|
warehouse_document_to_dict,
|
|
warehouse_documents_filtered_query,
|
|
)
|
|
from app.services.invoice_warehouse_bulk_service import run_bulk_warehouse_invoice_operation
|
|
|
|
|
|
router = APIRouter(prefix="/warehouse-docs", tags=["warehouse_docs"])
|
|
|
|
|
|
_INVOICE_SOURCE_CONFIG: Dict[str, Dict[str, Any]] = {
|
|
"sales": {
|
|
"invoice_types": [INVOICE_SALES],
|
|
"doc_type": "issue",
|
|
"movement": "out",
|
|
},
|
|
"purchase": {
|
|
"invoice_types": [INVOICE_PURCHASE],
|
|
"doc_type": "receipt",
|
|
"movement": "in",
|
|
},
|
|
"sales_return": {
|
|
"invoice_types": [INVOICE_SALES_RETURN],
|
|
"doc_type": "receipt",
|
|
"movement": "in",
|
|
},
|
|
"purchase_return": {
|
|
"invoice_types": [INVOICE_PURCHASE_RETURN],
|
|
"doc_type": "issue",
|
|
"movement": "out",
|
|
},
|
|
"waste": {
|
|
"invoice_types": [INVOICE_WASTE],
|
|
"doc_type": "issue",
|
|
"movement": "out",
|
|
},
|
|
"direct_consumption": {
|
|
"invoice_types": [INVOICE_DIRECT_CONSUMPTION],
|
|
"doc_type": "issue",
|
|
"movement": "out",
|
|
},
|
|
"production": {
|
|
"invoice_types": [INVOICE_PRODUCTION],
|
|
"doc_type": "issue",
|
|
"movement": None,
|
|
},
|
|
}
|
|
|
|
|
|
def _resolve_doc_type(invoice_type: str, movement: str | None, override: str | None = None) -> str:
|
|
if override:
|
|
return override
|
|
if movement == "in":
|
|
return "receipt"
|
|
if movement == "out":
|
|
return "issue"
|
|
if invoice_type in (INVOICE_SALES, INVOICE_PURCHASE_RETURN, INVOICE_WASTE, INVOICE_DIRECT_CONSUMPTION):
|
|
return "issue"
|
|
if invoice_type in (INVOICE_PURCHASE, INVOICE_SALES_RETURN):
|
|
return "receipt"
|
|
if invoice_type == INVOICE_PRODUCTION:
|
|
return "issue"
|
|
return "issue"
|
|
|
|
|
|
def _load_invoice_lines(db: Session, invoice_id: int) -> List[Dict[str, Any]]:
|
|
rows = (
|
|
db.query(InvoiceItemLine)
|
|
.filter(InvoiceItemLine.document_id == invoice_id)
|
|
.order_by(InvoiceItemLine.id.asc())
|
|
.all()
|
|
)
|
|
lines: List[Dict[str, Any]] = []
|
|
for row in rows:
|
|
lines.append({
|
|
"invoice_item_line_id": int(row.id),
|
|
"product_id": row.product_id,
|
|
"quantity": float(row.quantity or 0),
|
|
"extra_info": row.extra_info or {},
|
|
})
|
|
return lines
|
|
|
|
|
|
@router.get("/business/{business_id}/invoice/{invoice_id}/line-quantities")
|
|
@require_business_access("business_id")
|
|
def get_invoice_line_quantities(
|
|
request: Request,
|
|
business_id: int,
|
|
invoice_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""محاسبه مقادیر مورد نیاز، از قبل و باقی مانده برای خطوط فاکتور."""
|
|
if not ctx.has_business_permission("inventory", "read"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
|
|
inv = db.query(Document).filter(Document.id == invoice_id).first()
|
|
if not inv or inv.business_id != business_id:
|
|
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
|
|
|
line_quantities = compute_invoice_line_quantities(db, business_id, inv)
|
|
|
|
return success_response(data={"lines": line_quantities}, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/from-invoice/{invoice_id}")
|
|
@require_business_access("business_id")
|
|
def create_warehouse_doc_from_invoice(
|
|
request: Request,
|
|
business_id: int,
|
|
invoice_id: int,
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""ایجاد حواله از فاکتور."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
inv = db.query(Document).filter(Document.id == invoice_id).first()
|
|
if not inv or inv.business_id != business_id:
|
|
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
|
if bool(getattr(inv, "is_proforma", False)):
|
|
raise ApiError(
|
|
"PROFORMA_INVOICE",
|
|
"برای پیشفاکتور ایجاد حواله از فاکتور مجاز نیست؛ ابتدا فاکتور را قطعی کنید تا ثبت موجودی با گزارشها همسو باشد.",
|
|
http_status=400,
|
|
)
|
|
|
|
lines = payload.get("lines")
|
|
if not isinstance(lines, list) or not lines:
|
|
lines = _load_invoice_lines(db, invoice_id)
|
|
if not lines:
|
|
raise ApiError("LINES_REQUIRED", "هیچ کالایی برای این فاکتور ثبت نشده است", http_status=400)
|
|
|
|
movement_filter = str(payload.get("movement") or "").strip().lower()
|
|
if movement_filter not in ("", "in", "out"):
|
|
movement_filter = ""
|
|
if movement_filter:
|
|
filtered = [
|
|
ln for ln in lines
|
|
if (ln.get("extra_info") or {}).get("movement") == movement_filter
|
|
]
|
|
if not filtered:
|
|
raise ApiError("NO_LINES_FOR_MOVEMENT", "هیچ خطی با حرکت انتخابشده یافت نشد", http_status=400)
|
|
lines = filtered
|
|
|
|
doc_type_override = payload.get("doc_type")
|
|
wh_type = _resolve_doc_type(inv.document_type, movement_filter or None, doc_type_override)
|
|
|
|
# استخراج فیلدهای ارسال و تاریخ حواله از payload
|
|
extra_data = {
|
|
"document_date": payload.get("document_date"),
|
|
"description": payload.get("description"),
|
|
"delivery_method": payload.get("delivery_method"),
|
|
"carrier_name": payload.get("carrier_name"),
|
|
"recipient_name": payload.get("recipient_name"),
|
|
"recipient_phone": payload.get("recipient_phone"),
|
|
"tracking_number": payload.get("tracking_number"),
|
|
}
|
|
# حذف فیلدهای None
|
|
extra_data = {k: v for k, v in extra_data.items() if v is not None}
|
|
|
|
wh = create_from_invoice(db, business_id, inv, lines, wh_type, ctx.get_user_id(), extra_data if extra_data else None)
|
|
db.commit()
|
|
return success_response(data={"id": wh.id, "code": wh.code, "status": wh.status}, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/sources/invoices/search")
|
|
@require_business_access("business_id")
|
|
def search_invoice_sources_for_warehouse(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""فهرست فاکتورهایی که میتوان برای آنها حواله انبار ایجاد کرد."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
|
|
body = payload or {}
|
|
source_key = str(body.get("invoice_type") or "sales").strip().lower()
|
|
cfg = _INVOICE_SOURCE_CONFIG.get(source_key)
|
|
if not cfg:
|
|
raise ApiError("INVALID_INVOICE_TYPE", "نوع فاکتور معتبر نیست", http_status=400)
|
|
|
|
def _to_bool(val: Any) -> bool:
|
|
if isinstance(val, bool):
|
|
return val
|
|
if isinstance(val, (int, float)):
|
|
return bool(val)
|
|
if isinstance(val, str):
|
|
return val.strip().lower() in ("1", "true", "yes", "on")
|
|
return False
|
|
|
|
try:
|
|
take = int(body.get("take") or 20)
|
|
except Exception:
|
|
take = 20
|
|
take = max(1, min(100, take))
|
|
|
|
try:
|
|
skip = int(body.get("skip") or 0)
|
|
except Exception:
|
|
skip = 0
|
|
skip = max(0, skip)
|
|
|
|
search_term = str(body.get("search") or "").strip()
|
|
include_completed = _to_bool(body.get("include_completed"))
|
|
|
|
response = search_invoices_for_warehouse_sources(
|
|
db,
|
|
business_id,
|
|
cfg["invoice_types"],
|
|
doc_type_hint=cfg.get("doc_type"),
|
|
search_term=search_term,
|
|
include_completed=include_completed,
|
|
skip=skip,
|
|
take=take,
|
|
)
|
|
return success_response(data=response, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/create")
|
|
@require_business_access("business_id")
|
|
def create_warehouse_doc_manual(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""ایجاد حواله انبار دستی."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
wh = create_manual_warehouse_document(db, business_id, ctx.get_user_id(), payload)
|
|
db.commit()
|
|
return success_response(data=warehouse_document_to_dict(db, wh), request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/{wh_id}/post")
|
|
@require_business_access("business_id")
|
|
def post_warehouse_doc_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""پست کردن حواله."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
res = post_warehouse_document(db, wh_id)
|
|
db.commit()
|
|
return success_response(data=res, request=request)
|
|
|
|
|
|
@router.get("/business/{business_id}/{wh_id}")
|
|
@require_business_access("business_id")
|
|
def get_warehouse_doc(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""دریافت جزئیات حواله."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
|
|
if not wh or wh.business_id != business_id:
|
|
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
|
|
return success_response(data={"item": warehouse_document_to_dict(db, wh)}, request=request)
|
|
|
|
|
|
@router.put("/business/{business_id}/{wh_id}")
|
|
@require_business_access("business_id")
|
|
def update_warehouse_doc(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""ویرایش حواله انبار (فقط draft)."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
wh = update_warehouse_document(db, business_id, wh_id, ctx.get_user_id(), payload)
|
|
db.commit()
|
|
return success_response(data=warehouse_document_to_dict(db, wh), request=request)
|
|
|
|
|
|
@router.put("/business/{business_id}/{wh_id}/lines/{line_id}")
|
|
@require_business_access("business_id")
|
|
def update_warehouse_doc_line(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
line_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""بهروزرسانی یک خط حواله."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
wline = update_warehouse_document_line(db, business_id, wh_id, line_id, payload)
|
|
db.commit()
|
|
return success_response(data={
|
|
"id": wline.id,
|
|
"product_id": wline.product_id,
|
|
"warehouse_id": wline.warehouse_id,
|
|
"movement": wline.movement,
|
|
"quantity": float(wline.quantity),
|
|
"extra_info": wline.extra_info,
|
|
}, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/search")
|
|
@require_business_access("business_id")
|
|
def search_warehouse_docs(
|
|
request: Request,
|
|
business_id: int,
|
|
body: WarehouseDocListQueryBody,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""جستجو و فیلتر حوالهها."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
from app.core.cache import get_cache
|
|
|
|
body_dict = warehouse_doc_list_to_dict(body, request=request)
|
|
|
|
# استخراج پارامترهای فیلتر برای tag
|
|
doc_type = body_dict.get("doc_type")
|
|
if isinstance(doc_type, list) and doc_type:
|
|
doc_type = doc_type[0] # برای tag فقط اولین نوع را میگیریم
|
|
elif not isinstance(doc_type, str):
|
|
doc_type = None
|
|
|
|
status = body_dict.get("status")
|
|
if isinstance(status, list) and status:
|
|
status = status[0] # برای tag فقط اولین status را میگیریم
|
|
elif not isinstance(status, str):
|
|
status = None
|
|
|
|
warehouse_id = body_dict.get("warehouse_id")
|
|
warehouse_ids = body_dict.get("warehouse_ids")
|
|
if not warehouse_id and isinstance(warehouse_ids, list) and warehouse_ids:
|
|
warehouse_id = warehouse_ids[0] # برای tag فقط اولین انبار را میگیریم
|
|
|
|
fiscal_year_id = body_dict.get("fiscal_year_id")
|
|
|
|
# کش نتایج جستجوی اسناد انبار
|
|
cache = get_cache()
|
|
cache_key = None
|
|
|
|
if cache.enabled:
|
|
import json, hashlib
|
|
key_payload = {
|
|
"business_id": business_id,
|
|
"query": body_dict,
|
|
}
|
|
key_str = json.dumps(key_payload, sort_keys=True, ensure_ascii=False)
|
|
key_hash = hashlib.sha256(key_str.encode("utf-8")).hexdigest()[:16]
|
|
cache_key = f"warehouse_docs_list:{key_hash}"
|
|
cached = cache.get(cache_key)
|
|
if cached is not None:
|
|
return success_response(data=cached, request=request)
|
|
|
|
q = warehouse_documents_filtered_query(db, business_id, body_dict)
|
|
q = apply_warehouse_documents_sort(q, body_dict)
|
|
|
|
# Pagination
|
|
take = int(body_dict.get("take") or 20)
|
|
skip = int(body_dict.get("skip") or 0)
|
|
total = q.count()
|
|
items = q.offset(skip).limit(take).all()
|
|
|
|
result = {
|
|
"items": [warehouse_document_to_dict(db, wh) for wh in items],
|
|
"total": total,
|
|
"page": (skip // max(1, take)) + 1,
|
|
"limit": take,
|
|
"total_pages": (total + take - 1) // take,
|
|
}
|
|
|
|
# ذخیره در cache با tag-based caching
|
|
if cache.enabled and cache_key:
|
|
cache.set_with_warehouse_docs_tag(
|
|
key=cache_key,
|
|
value=result,
|
|
business_id=business_id,
|
|
fiscal_year_id=fiscal_year_id,
|
|
doc_type=doc_type,
|
|
warehouse_id=int(warehouse_id) if warehouse_id else None,
|
|
status=status,
|
|
ttl=60
|
|
)
|
|
|
|
return success_response(data=result, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/export/excel")
|
|
@require_business_access("business_id")
|
|
def export_warehouse_docs_excel_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
body: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
"""خروجی Excel لیست حوالههای انبار با همان فیلترهای جستجو (حداکثر ۱۰٬۰۰۰ ردیف)."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
excel_data = export_warehouse_documents_excel(db, business_id, body)
|
|
return Response(
|
|
content=excel_data,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename=warehouse_documents_{business_id}.xlsx",
|
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
},
|
|
)
|
|
|
|
|
|
@router.delete("/business/{business_id}/{wh_id}")
|
|
@require_business_access("business_id")
|
|
def delete_warehouse_doc(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""حذف حواله انبار (فقط draft)."""
|
|
if not ctx.has_business_permission("inventory", "delete"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
|
|
deleted = delete_warehouse_document(db, business_id, wh_id)
|
|
db.commit()
|
|
return success_response(data={"deleted": deleted}, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/bulk-delete")
|
|
@require_business_access("business_id")
|
|
def bulk_delete_warehouse_docs(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""حذف گروهی حوالههای انبار (فقط draft)."""
|
|
if not ctx.has_business_permission("inventory", "delete"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
|
|
doc_ids = payload.get("ids") or payload.get("doc_ids") or []
|
|
if not isinstance(doc_ids, list) or not doc_ids:
|
|
raise ApiError("INVALID_PAYLOAD", "ids list is required", http_status=400)
|
|
doc_ids = [int(d) for d in doc_ids if d]
|
|
result = bulk_delete_warehouse_documents(db, business_id, doc_ids)
|
|
db.commit()
|
|
return success_response(data=result, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/{wh_id}/cancel")
|
|
@require_business_access("business_id")
|
|
def cancel_warehouse_doc(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""لغو حواله posted با ایجاد حواله معکوس."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
cancel_wh = cancel_warehouse_document(db, business_id, wh_id, ctx.get_user_id())
|
|
db.commit()
|
|
return success_response(data=warehouse_document_to_dict(db, cancel_wh), request=request)
|
|
|
|
|
|
@router.get("/business/{business_id}/{wh_id}/pdf")
|
|
@require_business_access("business_id")
|
|
async def get_warehouse_doc_pdf(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
"""چاپ حواله انبار به صورت PDF."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
from weasyprint import HTML
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
from app.core.i18n import negotiate_locale
|
|
from html import escape
|
|
import datetime
|
|
import re
|
|
|
|
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
|
|
if not wh or wh.business_id != business_id:
|
|
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
|
|
|
|
# دریافت اطلاعات کامل حواله
|
|
doc_data = warehouse_document_to_dict(db, wh)
|
|
|
|
# اطلاعات کسبوکار
|
|
business_name = ""
|
|
try:
|
|
from adapters.db.models.business import Business
|
|
b = db.query(Business).filter(Business.id == business_id).first()
|
|
if b is not None:
|
|
business_name = b.name or ""
|
|
except Exception:
|
|
business_name = ""
|
|
|
|
# Locale
|
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
|
is_fa = locale == "fa"
|
|
now = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
|
|
|
|
# تبدیل تاریخ
|
|
document_date_jalali = None
|
|
if doc_data.get("document_date"):
|
|
try:
|
|
from app.core.calendar import CalendarConverter
|
|
dt = datetime.datetime.fromisoformat(str(doc_data.get("document_date")).replace("Z", "+00:00"))
|
|
formatted = CalendarConverter.format_datetime(dt, "jalali")
|
|
if isinstance(formatted, dict):
|
|
document_date_jalali = formatted.get("formatted") or formatted.get("date_only")
|
|
elif formatted:
|
|
document_date_jalali = str(formatted)
|
|
except Exception:
|
|
document_date_jalali = None
|
|
|
|
# نام نوع حواله
|
|
doc_type_names = {
|
|
"receipt": "حواله ورود",
|
|
"issue": "حواله خروج",
|
|
"transfer": "انتقال بین انبارها",
|
|
"adjustment": "تعدیل موجودی",
|
|
"production_in": "ورود تولید",
|
|
"production_out": "خروج تولید",
|
|
}
|
|
doc_type_name = doc_type_names.get(doc_data.get("doc_type"), doc_data.get("doc_type", ""))
|
|
|
|
# نام وضعیت
|
|
status_names = {
|
|
"draft": "پیشنویس",
|
|
"posted": "پست شده",
|
|
"cancelled": "لغو شده",
|
|
}
|
|
status_name = status_names.get(doc_data.get("status"), doc_data.get("status", ""))
|
|
|
|
# اطلاعات انبارها
|
|
warehouse_from_name = ""
|
|
warehouse_to_name = ""
|
|
if doc_data.get("warehouse_id_from"):
|
|
try:
|
|
from adapters.db.models.warehouse import Warehouse
|
|
w = db.query(Warehouse).filter(Warehouse.id == doc_data.get("warehouse_id_from")).first()
|
|
if w:
|
|
warehouse_from_name = f"{w.code} - {w.name}"
|
|
except Exception:
|
|
pass
|
|
if doc_data.get("warehouse_id_to"):
|
|
try:
|
|
from adapters.db.models.warehouse import Warehouse
|
|
w = db.query(Warehouse).filter(Warehouse.id == doc_data.get("warehouse_id_to")).first()
|
|
if w:
|
|
warehouse_to_name = f"{w.code} - {w.name}"
|
|
except Exception:
|
|
pass
|
|
|
|
# تابع کمکی برای ساخت HTML اطلاعات ارسال
|
|
def _build_delivery_info_html(doc_data: Dict[str, Any]) -> str:
|
|
has_delivery_info = any([
|
|
doc_data.get('description'),
|
|
doc_data.get('delivery_method'),
|
|
doc_data.get('carrier_name'),
|
|
doc_data.get('recipient_name'),
|
|
doc_data.get('recipient_phone'),
|
|
doc_data.get('tracking_number'),
|
|
])
|
|
if not has_delivery_info:
|
|
return ''
|
|
|
|
delivery_method_names = {
|
|
'warehouse_door': 'تحویل درب انبار',
|
|
'post_regular': 'پست عادی',
|
|
'post_express': 'پست پیشتاز',
|
|
'freight': 'باربری',
|
|
'bus': 'اتوبوس',
|
|
'tipax': 'تیپاکس',
|
|
'courier': 'پیک',
|
|
}
|
|
|
|
rows = []
|
|
if doc_data.get('description'):
|
|
rows.append(f"<tr><td>شرح/توضیحات:</td><td>{escape(str(doc_data.get('description', '')))}</td></tr>")
|
|
if doc_data.get('delivery_method'):
|
|
method_name = delivery_method_names.get(doc_data.get('delivery_method', ''), doc_data.get('delivery_method', '-'))
|
|
rows.append(f"<tr><td>روش ارسال:</td><td>{escape(method_name)}</td></tr>")
|
|
if doc_data.get('carrier_name'):
|
|
rows.append(f"<tr><td>نام باربری/حمل و نقل:</td><td>{escape(str(doc_data.get('carrier_name', '')))}</td></tr>")
|
|
if doc_data.get('recipient_name'):
|
|
rows.append(f"<tr><td>تحویل گیرنده:</td><td>{escape(str(doc_data.get('recipient_name', '')))}</td></tr>")
|
|
if doc_data.get('recipient_phone'):
|
|
rows.append(f"<tr><td>تلفن تحویل گیرنده:</td><td>{escape(str(doc_data.get('recipient_phone', '')))}</td></tr>")
|
|
if doc_data.get('tracking_number'):
|
|
rows.append(f"<tr><td>شماره پیگیری/بارنامه/قبض:</td><td>{escape(str(doc_data.get('tracking_number', '')))}</td></tr>")
|
|
|
|
if rows:
|
|
return f'<h3>اطلاعات ارسال:</h3><table class="info-table">{"".join(rows)}</table>'
|
|
return ''
|
|
|
|
def _to_display_str(value: Any, default: str = "-") -> str:
|
|
if value in (None, ""):
|
|
return default
|
|
if isinstance(value, dict):
|
|
for key in ("formatted", "date_only", "value", "gregorian"):
|
|
date_value = value.get(key)
|
|
if date_value:
|
|
return str(date_value)
|
|
return default
|
|
return str(value)
|
|
|
|
document_date_display = _to_display_str(document_date_jalali or doc_data.get("document_date"))
|
|
|
|
# اطلاعات محصولات در خطوط
|
|
lines_data = doc_data.get("lines", [])
|
|
lines_html = ""
|
|
for i, line in enumerate(lines_data, 1):
|
|
product_name = f"محصول {line.get('product_id', '-')}"
|
|
try:
|
|
from adapters.db.models.product import Product
|
|
p = db.query(Product).filter(Product.id == line.get("product_id")).first()
|
|
if p:
|
|
product_name = f"{p.code or ''} - {p.name or ''}".strip(" -")
|
|
except Exception:
|
|
pass
|
|
|
|
warehouse_name = ""
|
|
if line.get("warehouse_id"):
|
|
try:
|
|
from adapters.db.models.warehouse import Warehouse
|
|
w = db.query(Warehouse).filter(Warehouse.id == line.get("warehouse_id")).first()
|
|
if w:
|
|
warehouse_name = f"{w.code} - {w.name}"
|
|
except Exception:
|
|
pass
|
|
|
|
movement_name = "ورود" if line.get("movement") == "in" else "خروج"
|
|
quantity = line.get("quantity", 0)
|
|
|
|
lines_html += f"""
|
|
<tr>
|
|
<td>{i}</td>
|
|
<td>{escape(product_name)}</td>
|
|
<td>{escape(warehouse_name)}</td>
|
|
<td>{escape(movement_name)}</td>
|
|
<td>{quantity}</td>
|
|
</tr>
|
|
"""
|
|
|
|
# HTML template
|
|
html_content = f"""
|
|
<!DOCTYPE html>
|
|
<html dir="rtl" lang="fa">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body {{ font-family: 'Tahoma', 'Arial', sans-serif; padding: 20px; direction: rtl; }}
|
|
.header {{ text-align: center; margin-bottom: 30px; }}
|
|
.info-table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; }}
|
|
.info-table td {{ padding: 8px; border: 1px solid #ddd; }}
|
|
.info-table td:first-child {{ background-color: #f5f5f5; font-weight: bold; width: 150px; }}
|
|
.lines-table {{ width: 100%; border-collapse: collapse; }}
|
|
.lines-table th, .lines-table td {{ padding: 8px; border: 1px solid #ddd; text-align: center; }}
|
|
.lines-table th {{ background-color: #f5f5f5; font-weight: bold; }}
|
|
.footer {{ margin-top: 30px; text-align: center; font-size: 12px; color: #666; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>{escape(doc_type_name)}</h1>
|
|
<h2>{escape(business_name)}</h2>
|
|
</div>
|
|
|
|
<table class="info-table">
|
|
<tr>
|
|
<td>کد حواله:</td>
|
|
<td>{escape(doc_data.get('code', '-'))}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>نوع حواله:</td>
|
|
<td>{escape(doc_type_name)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>تاریخ:</td>
|
|
<td>{escape(document_date_display)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>وضعیت:</td>
|
|
<td>{escape(status_name)}</td>
|
|
</tr>
|
|
{('<tr><td>انبار مبدا:</td><td>' + escape(warehouse_from_name) + '</td></tr>') if warehouse_from_name else ''}
|
|
{('<tr><td>انبار مقصد:</td><td>' + escape(warehouse_to_name) + '</td></tr>') if warehouse_to_name else ''}
|
|
</table>
|
|
|
|
<!-- اطلاعات ارسال -->
|
|
{_build_delivery_info_html(doc_data)}
|
|
|
|
<h3>خطوط حواله:</h3>
|
|
<table class="lines-table">
|
|
<thead>
|
|
<tr>
|
|
<th>ردیف</th>
|
|
<th>محصول</th>
|
|
<th>انبار</th>
|
|
<th>نوع حرکت</th>
|
|
<th>تعداد</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines_html}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="footer">
|
|
<p>تولید شده در: {now}</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# تابع کمکی برای slugify
|
|
def _slugify(text: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "warehouse_doc"
|
|
|
|
# تولید PDF
|
|
try:
|
|
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"PDF generation failed: {e}", exc_info=True)
|
|
raise ApiError("PDF_GENERATION_ERROR", "خطا در تولید فایل PDF", http_status=500)
|
|
|
|
filename = f"warehouse_doc_{_slugify(doc_data.get('code', ''))}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"Content-Length": str(len(pdf_bytes)),
|
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/business/{business_id}/{wh_id}/postal-label.pdf")
|
|
@require_business_access("business_id")
|
|
async def get_warehouse_postal_label_pdf(
|
|
request: Request,
|
|
business_id: int,
|
|
wh_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
"""برگه مرسوله پستی (PDF) با قالب گزارش warehouse_documents/postal_label و پارامترهای چاپ."""
|
|
if not ctx.can_read_section("inventory"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
|
|
from weasyprint import HTML
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
from app.core.i18n import negotiate_locale
|
|
from app.core.calendar import get_calendar_type_from_header
|
|
from app.services.pdf.template_renderer import render_template
|
|
from app.services.report_template_service import ReportTemplateService
|
|
from app.services.warehouse_postal_label_service import (
|
|
WAREHOUSE_POSTAL_LABEL_MODULE_KEY,
|
|
WAREHOUSE_POSTAL_LABEL_SUBTYPE,
|
|
build_warehouse_postal_label_context,
|
|
parse_label_field_flags,
|
|
)
|
|
import base64
|
|
import datetime
|
|
import logging
|
|
import re
|
|
from uuid import UUID
|
|
|
|
from adapters.db.models.business import Business
|
|
from app.services.file_storage_service import FileStorageService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
|
|
if not wh or wh.business_id != business_id:
|
|
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
|
|
|
|
qp = request.query_params
|
|
field_flags = parse_label_field_flags(qp)
|
|
paper_size = (qp.get("paper_size") or "A6").strip() or "A6"
|
|
orientation = (qp.get("orientation") or "portrait").strip().lower()
|
|
if orientation not in ("portrait", "landscape"):
|
|
orientation = "portrait"
|
|
disposition = (qp.get("disposition") or "attachment").strip().lower()
|
|
if disposition not in ("attachment", "inline"):
|
|
disposition = "attachment"
|
|
|
|
template_id_raw = qp.get("template_id")
|
|
explicit_template_id = None
|
|
if template_id_raw is not None and str(template_id_raw).strip() != "":
|
|
try:
|
|
explicit_template_id = int(template_id_raw)
|
|
except Exception:
|
|
explicit_template_id = None
|
|
|
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
|
is_fa = locale == "fa"
|
|
calendar_type = get_calendar_type_from_header(request.headers.get("X-Calendar-Type"))
|
|
|
|
try:
|
|
template_context = build_warehouse_postal_label_context(
|
|
db,
|
|
business_id=business_id,
|
|
wh=wh,
|
|
calendar_type=calendar_type,
|
|
is_fa=is_fa,
|
|
field_flags=field_flags,
|
|
paper_size=paper_size,
|
|
orientation=orientation,
|
|
)
|
|
except Exception as e:
|
|
logger.exception("postal label context: %s", e)
|
|
raise ApiError("POSTAL_LABEL_CONTEXT", "خطا در آمادهسازی دادهٔ برگه مرسوله", http_status=500)
|
|
|
|
# لوگوی اختیاری کسبوکار (برای قالبهای سفارشی)
|
|
business_logo_data_uri = None
|
|
storage = FileStorageService(db)
|
|
|
|
async def _load_image_data_uri(file_id_str):
|
|
if not file_id_str:
|
|
return None
|
|
try:
|
|
file_data = await storage.download_file(UUID(str(file_id_str)))
|
|
except Exception:
|
|
return None
|
|
try:
|
|
content = file_data.get("content") or b""
|
|
if not content:
|
|
return None
|
|
mime = file_data.get("mime_type") or "image/png"
|
|
b64 = base64.b64encode(content).decode("ascii")
|
|
return f"data:{mime};base64,{b64}"
|
|
except Exception:
|
|
return None
|
|
|
|
try:
|
|
b = db.query(Business).filter(Business.id == int(business_id)).first()
|
|
if b is not None and getattr(b, "logo_file_id", None):
|
|
business_logo_data_uri = await _load_image_data_uri(getattr(b, "logo_file_id", None))
|
|
except Exception:
|
|
business_logo_data_uri = None
|
|
template_context["business_logo_data_uri"] = business_logo_data_uri
|
|
|
|
resolved_html = None
|
|
try:
|
|
resolved_html = ReportTemplateService.try_render_resolved(
|
|
db=db,
|
|
business_id=business_id,
|
|
module_key=WAREHOUSE_POSTAL_LABEL_MODULE_KEY,
|
|
subtype=WAREHOUSE_POSTAL_LABEL_SUBTYPE,
|
|
context=template_context,
|
|
explicit_template_id=explicit_template_id,
|
|
page_paper_size=paper_size,
|
|
page_orientation=orientation,
|
|
)
|
|
except Exception:
|
|
resolved_html = None
|
|
|
|
default_ctx = {**template_context, "paper_size": paper_size, "orientation": orientation}
|
|
html_content = resolved_html or render_template("pdf/warehouse/postal_label.html", default_ctx)
|
|
|
|
try:
|
|
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
|
|
except Exception as e:
|
|
logger.error("postal label PDF failed: %s", e, exc_info=True)
|
|
raise ApiError("PDF_GENERATION_ERROR", "خطا در تولید فایل PDF", http_status=500)
|
|
|
|
def _slugify(text: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "postal_label"
|
|
|
|
code = ""
|
|
try:
|
|
code = str((template_context.get("document") or {}).get("code") or "")
|
|
except Exception:
|
|
code = ""
|
|
filename = f"postal_label_{_slugify(code)}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
|
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": f"{disposition}; filename={filename}",
|
|
"Content-Length": str(len(pdf_bytes)),
|
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/business/{business_id}/stock-count/start")
|
|
@require_business_access("business_id")
|
|
def start_stock_count_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""شروع انبار گردانی: دریافت لیست محصولات با موجودی سیستم."""
|
|
if not ctx.has_business_permission("inventory", "read"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
|
|
from app.services.transfer_service import _parse_iso_date as _parse_date
|
|
from datetime import date as date_type
|
|
|
|
warehouse_id = payload.get("warehouse_id")
|
|
if warehouse_id:
|
|
try:
|
|
warehouse_id = int(warehouse_id)
|
|
except Exception:
|
|
warehouse_id = None
|
|
|
|
product_ids = payload.get("product_ids")
|
|
if product_ids and isinstance(product_ids, list):
|
|
product_ids = [int(p) for p in product_ids if p]
|
|
else:
|
|
product_ids = None
|
|
|
|
as_of_date_str = payload.get("as_of_date")
|
|
as_of_date = None
|
|
if as_of_date_str:
|
|
try:
|
|
as_of_date = _parse_date(as_of_date_str)
|
|
except Exception:
|
|
as_of_date = None
|
|
|
|
only_with_warehouse_history = bool(payload.get("only_with_warehouse_history", False))
|
|
|
|
result = start_stock_count(
|
|
db,
|
|
business_id,
|
|
warehouse_id,
|
|
product_ids,
|
|
as_of_date,
|
|
only_with_warehouse_history=only_with_warehouse_history,
|
|
)
|
|
return success_response(data=result, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/stock-count/calculate")
|
|
@require_business_access("business_id")
|
|
def calculate_stock_count_differences_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""محاسبه تفاوتهای انبار گردانی."""
|
|
if not ctx.has_business_permission("inventory", "read"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
|
|
|
|
items = payload.get("items", [])
|
|
if not isinstance(items, list) or not items:
|
|
raise ApiError("INVALID_PAYLOAD", "items list is required", http_status=400)
|
|
|
|
from app.services.transfer_service import _parse_iso_date as _parse_date
|
|
|
|
as_of_date_str = payload.get("as_of_date")
|
|
as_of_date = None
|
|
if as_of_date_str:
|
|
try:
|
|
as_of_date = _parse_date(as_of_date_str)
|
|
except Exception:
|
|
as_of_date = None
|
|
|
|
result = calculate_stock_count_differences(db, business_id, items, as_of_date=as_of_date)
|
|
return success_response(data=result, request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/stock-count/create-adjustment")
|
|
@require_business_access("business_id")
|
|
def create_stock_count_adjustment_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""ایجاد حواله تعدیل یا اسناد کالای هزینه/درآمد از تفاوتهای انبار گردانی."""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
|
|
from app.services.transfer_service import _parse_iso_date as _parse_date
|
|
from datetime import date as date_type
|
|
from adapters.db.models.business import Business
|
|
from app.services.goods_expense_income_service import (
|
|
STOCK_COUNT_ASK,
|
|
STOCK_COUNT_GOODS_DOCS,
|
|
STOCK_COUNT_PHYSICAL,
|
|
create_from_stock_count,
|
|
get_workflow_settings,
|
|
)
|
|
from app.core.permissions import has_business_permission_for_business
|
|
|
|
stock_count_code = payload.get("stock_count_code", "").strip()
|
|
if not stock_count_code:
|
|
stock_count_code = f"INV-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
|
|
stock_count_date_str = payload.get("stock_count_date")
|
|
if not stock_count_date_str:
|
|
stock_count_date = datetime.now().date()
|
|
else:
|
|
try:
|
|
stock_count_date = _parse_date(stock_count_date_str)
|
|
except Exception:
|
|
stock_count_date = datetime.now().date()
|
|
|
|
items = payload.get("items", [])
|
|
if not isinstance(items, list):
|
|
raise ApiError("INVALID_PAYLOAD", "items list is required", http_status=400)
|
|
|
|
notes = payload.get("notes")
|
|
|
|
biz = db.query(Business).filter(Business.id == int(business_id)).first()
|
|
settings = get_workflow_settings(biz) if biz else {"stock_count_mode": STOCK_COUNT_GOODS_DOCS}
|
|
mode = str(payload.get("result_mode") or settings.get("stock_count_mode") or STOCK_COUNT_GOODS_DOCS).strip().lower()
|
|
if mode == STOCK_COUNT_ASK:
|
|
# کلاینت باید صریحاً result_mode بفرستد
|
|
explicit = str(payload.get("result_mode") or "").strip().lower()
|
|
if explicit not in (STOCK_COUNT_GOODS_DOCS, STOCK_COUNT_PHYSICAL):
|
|
return success_response(
|
|
data={
|
|
"needs_choice": True,
|
|
"options": [STOCK_COUNT_GOODS_DOCS, STOCK_COUNT_PHYSICAL],
|
|
"settings": settings,
|
|
},
|
|
request=request,
|
|
message="STOCK_COUNT_RESULT_MODE_REQUIRED",
|
|
)
|
|
mode = explicit
|
|
|
|
if mode == STOCK_COUNT_GOODS_DOCS:
|
|
if not has_business_permission_for_business(
|
|
ctx, db, business_id, "goods_expense_income", "add"
|
|
):
|
|
raise ApiError(
|
|
"FORBIDDEN",
|
|
"برای ثبت کالای هزینه/درآمد از انبارگردانی، مجوز goods_expense_income.add لازم است",
|
|
http_status=403,
|
|
)
|
|
created = create_from_stock_count(
|
|
db,
|
|
business_id,
|
|
ctx.get_user_id(),
|
|
stock_count_code=stock_count_code,
|
|
stock_count_date=stock_count_date,
|
|
items=items,
|
|
notes=notes,
|
|
user_can_allocate=has_business_permission_for_business(
|
|
ctx, db, business_id, "goods_expense_income", "allocate"
|
|
),
|
|
user_can_post=False,
|
|
commit=False,
|
|
)
|
|
db.commit()
|
|
return success_response(
|
|
data={"mode": STOCK_COUNT_GOODS_DOCS, **created},
|
|
request=request,
|
|
message="STOCK_COUNT_GOODS_DOCS_CREATED",
|
|
)
|
|
|
|
# physical_adjustment (سازگاری با رفتار قبلی)
|
|
wh = create_stock_count_adjustment(
|
|
db,
|
|
business_id,
|
|
ctx.get_user_id(),
|
|
stock_count_code,
|
|
stock_count_date,
|
|
items,
|
|
notes,
|
|
)
|
|
db.commit()
|
|
return success_response(data=warehouse_document_to_dict(db, wh), request=request)
|
|
|
|
|
|
@router.post("/business/{business_id}/invoices/bulk-warehouse-operations")
|
|
@require_business_access("business_id")
|
|
def bulk_warehouse_invoice_operations(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(default={}),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
عملیات گروهی روی فاکتورهای انتخابشده (هر فاکتور در تراکنش جدا).
|
|
operation: create_draft | remove_linked | post_drafts
|
|
برای post_drafts: existing_posted_policy = skip | post_drafts_only | remove_all_then_create_and_post
|
|
"""
|
|
if not ctx.has_business_permission("inventory", "write"):
|
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
|
|
|
body = payload or {}
|
|
operation = str(body.get("operation") or "").strip().lower()
|
|
existing_posted_policy = str(body.get("existing_posted_policy") or "skip").strip().lower()
|
|
if existing_posted_policy not in ("skip", "post_drafts_only", "remove_all_then_create_and_post"):
|
|
existing_posted_policy = "skip"
|
|
raw_ids = body.get("invoice_ids")
|
|
if not isinstance(raw_ids, list) or not raw_ids:
|
|
raise ApiError("INVALID_BODY", "invoice_ids (لیست شناسه فاکتور) الزامی است", http_status=400)
|
|
|
|
invoice_ids: List[int] = []
|
|
for x in raw_ids:
|
|
try:
|
|
invoice_ids.append(int(x))
|
|
except Exception:
|
|
continue
|
|
|
|
seen: set[int] = set()
|
|
uniq: List[int] = []
|
|
for iid in invoice_ids:
|
|
if iid not in seen:
|
|
seen.add(iid)
|
|
uniq.append(iid)
|
|
invoice_ids = uniq[:200]
|
|
|
|
user_id = ctx.get_user_id()
|
|
if user_id is None:
|
|
raise ApiError("UNAUTHORIZED", "کاربر نامعتبر", http_status=401)
|
|
|
|
results: List[Dict[str, Any]] = []
|
|
for iid in invoice_ids:
|
|
sess = SessionLocal()
|
|
try:
|
|
out = run_bulk_warehouse_invoice_operation(
|
|
sess,
|
|
business_id,
|
|
iid,
|
|
operation,
|
|
int(user_id),
|
|
existing_posted_policy=existing_posted_policy,
|
|
)
|
|
if out.get("ok"):
|
|
sess.commit()
|
|
else:
|
|
sess.rollback()
|
|
results.append({"invoice_id": iid, **out})
|
|
except Exception as exc:
|
|
sess.rollback()
|
|
results.append({
|
|
"invoice_id": iid,
|
|
"ok": False,
|
|
"code": "EXCEPTION",
|
|
"message": str(exc),
|
|
})
|
|
finally:
|
|
sess.close()
|
|
|
|
return success_response(
|
|
data={
|
|
"results": results,
|
|
"operation": operation,
|
|
"existing_posted_policy": existing_posted_policy,
|
|
},
|
|
request=request,
|
|
)
|
|
|
|
|