Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/warehouse_docs.py

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,
)