forked from hesabix/arc
326 lines
14 KiB
Python
326 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session, selectinload
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
|
|
from adapters.db.models.document import Document
|
|
from adapters.db.models.inventory_discrepancy import (
|
|
InventoryDiscrepancyCase,
|
|
InventoryDiscrepancyLine,
|
|
)
|
|
from adapters.db.models.invoice_item_line import InvoiceItemLine
|
|
from app.core.responses import ApiError
|
|
|
|
|
|
def _decimal(value: Any) -> Decimal:
|
|
return Decimal(str(value or 0))
|
|
|
|
|
|
def _correction_lines_by_case_line(lines: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
|
|
indexed: dict[int, dict[str, Any]] = {}
|
|
for line in lines:
|
|
extra = line.get("extra_info")
|
|
if not isinstance(extra, dict) or extra.get("inventory_discrepancy_line_id") is None:
|
|
continue
|
|
indexed[int(extra["inventory_discrepancy_line_id"])] = line
|
|
return indexed
|
|
|
|
|
|
def _serialize(case: InventoryDiscrepancyCase) -> dict[str, Any]:
|
|
return {
|
|
"id": case.id,
|
|
"business_id": case.business_id,
|
|
"invoice_document_id": case.invoice_document_id,
|
|
"invoice_code": case.invoice_document.code if case.invoice_document else None,
|
|
"invoice_date": (
|
|
case.invoice_document.document_date.isoformat()
|
|
if case.invoice_document and case.invoice_document.document_date
|
|
else None
|
|
),
|
|
"warehouse_document_id": case.warehouse_document_id,
|
|
"resolution_document_id": case.resolution_document_id,
|
|
"status": case.status,
|
|
"source_type": case.source_type,
|
|
"reason_code": case.reason_code,
|
|
"reason_note": case.reason_note,
|
|
"created_by_user_id": case.created_by_user_id,
|
|
"resolved_by_user_id": case.resolved_by_user_id,
|
|
"resolution_type": case.resolution_type,
|
|
"created_at": case.created_at.isoformat() if case.created_at else None,
|
|
"resolved_at": case.resolved_at.isoformat() if case.resolved_at else None,
|
|
"lines": [
|
|
{
|
|
"id": line.id,
|
|
"product_id": line.product_id,
|
|
"product_name": line.product.name if line.product else None,
|
|
"product_code": line.product.code if line.product else None,
|
|
"warehouse_id": line.warehouse_id,
|
|
"warehouse_name": line.warehouse.name if line.warehouse else None,
|
|
"invoice_item_line_id": line.invoice_item_line_id,
|
|
"available_quantity": float(line.available_quantity or 0),
|
|
"requested_quantity": float(line.requested_quantity or 0),
|
|
"shortage_quantity": float(line.shortage_quantity or 0),
|
|
"resolved_quantity": float(line.resolved_quantity or 0),
|
|
"resolution_unit_cost": (
|
|
float(line.resolution_unit_cost) if line.resolution_unit_cost is not None else None
|
|
),
|
|
"suggested_unit_cost": (
|
|
float(line.product.base_purchase_price or 0) if line.product else 0
|
|
),
|
|
}
|
|
for line in case.lines
|
|
],
|
|
}
|
|
|
|
|
|
def create_case_for_invoice(
|
|
db: Session,
|
|
*,
|
|
business_id: int,
|
|
document: Document,
|
|
user_id: int,
|
|
warehouse_document_ids: list[int],
|
|
) -> InventoryDiscrepancyCase | None:
|
|
override = (document.extra_info or {}).get("_stock_override")
|
|
if not isinstance(override, dict) or override.get("authorized") is not True:
|
|
return None
|
|
shortages = override.get("shortages")
|
|
if not isinstance(shortages, list) or not shortages:
|
|
return None
|
|
token = str(override.get("request_token") or "").strip()
|
|
existing = db.query(InventoryDiscrepancyCase).filter(
|
|
InventoryDiscrepancyCase.business_id == int(business_id),
|
|
InventoryDiscrepancyCase.request_token == token,
|
|
).first()
|
|
if existing:
|
|
return existing
|
|
|
|
wh_id = int(warehouse_document_ids[0]) if warehouse_document_ids else None
|
|
case = InventoryDiscrepancyCase(
|
|
business_id=int(business_id),
|
|
invoice_document_id=int(document.id),
|
|
warehouse_document_id=wh_id,
|
|
status="open",
|
|
source_type="quick_sale",
|
|
request_token=token,
|
|
reason_code=str(override.get("reason_code") or "other"),
|
|
reason_note=override.get("reason_note"),
|
|
created_by_user_id=int(user_id),
|
|
)
|
|
db.add(case)
|
|
db.flush()
|
|
|
|
invoice_lines = db.query(InvoiceItemLine).filter(
|
|
InvoiceItemLine.document_id == int(document.id)
|
|
).all()
|
|
for shortage in shortages:
|
|
pid = int(shortage["product_id"])
|
|
warehouse_id = shortage.get("warehouse_id")
|
|
if warehouse_id is None:
|
|
continue
|
|
invoice_line = next((x for x in invoice_lines if int(x.product_id) == pid), None)
|
|
db.add(
|
|
InventoryDiscrepancyLine(
|
|
case_id=case.id,
|
|
product_id=pid,
|
|
warehouse_id=int(warehouse_id),
|
|
invoice_item_line_id=int(invoice_line.id) if invoice_line else None,
|
|
available_quantity=_decimal(shortage.get("available_quantity")),
|
|
requested_quantity=_decimal(shortage.get("requested_quantity")),
|
|
shortage_quantity=_decimal(shortage.get("shortage_quantity")),
|
|
resolved_quantity=Decimal("0"),
|
|
)
|
|
)
|
|
db.flush()
|
|
extra = dict(document.extra_info or {})
|
|
override = dict(override)
|
|
override["discrepancy_case_id"] = case.id
|
|
extra["_stock_override"] = override
|
|
document.extra_info = extra
|
|
flag_modified(document, "extra_info")
|
|
db.flush()
|
|
return case
|
|
|
|
|
|
def list_cases(db: Session, business_id: int, status: str | None = None) -> dict[str, Any]:
|
|
q = db.query(InventoryDiscrepancyCase).options(
|
|
selectinload(InventoryDiscrepancyCase.invoice_document),
|
|
selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.product),
|
|
selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.warehouse),
|
|
).filter(InventoryDiscrepancyCase.business_id == int(business_id))
|
|
if status:
|
|
q = q.filter(InventoryDiscrepancyCase.status == str(status))
|
|
rows = q.order_by(InventoryDiscrepancyCase.created_at.desc()).limit(200).all()
|
|
return {"items": [_serialize(row) for row in rows], "total": len(rows)}
|
|
|
|
|
|
def get_case(db: Session, business_id: int, case_id: int) -> InventoryDiscrepancyCase:
|
|
row = db.query(InventoryDiscrepancyCase).options(
|
|
selectinload(InventoryDiscrepancyCase.invoice_document),
|
|
selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.product),
|
|
selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.warehouse),
|
|
).filter(
|
|
InventoryDiscrepancyCase.id == int(case_id),
|
|
InventoryDiscrepancyCase.business_id == int(business_id),
|
|
).first()
|
|
if not row:
|
|
raise ApiError("INVENTORY_DISCREPANCY_NOT_FOUND", "پرونده مغایرت یافت نشد", http_status=404)
|
|
return row
|
|
|
|
|
|
def create_goods_income_correction(
|
|
db: Session,
|
|
*,
|
|
business_id: int,
|
|
user_id: int,
|
|
document_date: date,
|
|
lines: list[dict[str, Any]],
|
|
description: str,
|
|
user_can_allocate: bool,
|
|
user_can_post: bool,
|
|
user_can_change_unit_cost: bool,
|
|
case_id: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""کسری ناشی از موجودی فیزیکی ثبتنشده را با تاریخ واقعی کشف اصلاح میکند."""
|
|
if not user_can_post:
|
|
raise ApiError(
|
|
"GOODS_INCOME_POST_PERMISSION_REQUIRED",
|
|
"برای اصلاح فوری موجودی، مجوز قطعیکردن کالای درآمدشده لازم است",
|
|
http_status=403,
|
|
)
|
|
from app.services.goods_expense_income_service import create_document, post_document
|
|
|
|
payload = {
|
|
"doc_kind": "goods_income",
|
|
"document_date": document_date.isoformat(),
|
|
"description": description,
|
|
"auto_post": True,
|
|
"extra_info": {
|
|
"source": "quick_sales_stock_correction",
|
|
"inventory_discrepancy_case_id": case_id,
|
|
},
|
|
"lines": lines,
|
|
}
|
|
result = create_document(
|
|
db,
|
|
business_id,
|
|
user_id,
|
|
payload,
|
|
user_can_allocate=user_can_allocate,
|
|
user_can_post=user_can_post,
|
|
user_can_change_unit_cost=user_can_change_unit_cost,
|
|
commit=False,
|
|
)
|
|
if result.get("status") != "posted":
|
|
result = post_document(db, business_id, user_id, int(result["id"]), commit=False)
|
|
return result
|
|
|
|
|
|
def resolve_case_with_goods_income(
|
|
db: Session,
|
|
*,
|
|
business_id: int,
|
|
case_id: int,
|
|
user_id: int,
|
|
lines: list[dict[str, Any]],
|
|
document_date: date,
|
|
user_can_allocate: bool,
|
|
user_can_post: bool,
|
|
user_can_change_unit_cost: bool,
|
|
) -> dict[str, Any]:
|
|
case = get_case(db, business_id, case_id)
|
|
if case.status != "open":
|
|
raise ApiError("INVENTORY_DISCREPANCY_ALREADY_RESOLVED", "این پرونده قبلاً رسیدگی شده است", http_status=409)
|
|
by_line_id = {int(x.get("case_line_id")): x for x in lines if x.get("case_line_id")}
|
|
correction_lines: list[dict[str, Any]] = []
|
|
for case_line in case.lines:
|
|
requested = by_line_id.get(int(case_line.id), {})
|
|
remaining = _decimal(case_line.shortage_quantity) - _decimal(case_line.resolved_quantity)
|
|
qty = _decimal(requested.get("quantity", remaining))
|
|
if qty <= 0 or qty != remaining:
|
|
raise ApiError(
|
|
"INVALID_RESOLUTION_QUANTITY",
|
|
"برای بستن پرونده باید تمام مقدار باقیمانده اصلاح شود",
|
|
http_status=400,
|
|
)
|
|
correction_lines.append(
|
|
{
|
|
"product_id": case_line.product_id,
|
|
"warehouse_id": case_line.warehouse_id,
|
|
"quantity": float(qty),
|
|
**({"unit_cost": requested["unit_cost"]} if requested.get("unit_cost") is not None else {}),
|
|
"description": f"رفع مغایرت فروش سریع، پرونده {case.id}",
|
|
"extra_info": {"inventory_discrepancy_line_id": case_line.id},
|
|
}
|
|
)
|
|
correction = create_goods_income_correction(
|
|
db,
|
|
business_id=business_id,
|
|
user_id=user_id,
|
|
document_date=document_date,
|
|
lines=correction_lines,
|
|
description=f"اصلاح موجودی واقعی بابت پرونده مغایرت فروش سریع {case.id}",
|
|
user_can_allocate=user_can_allocate,
|
|
user_can_post=user_can_post,
|
|
user_can_change_unit_cost=user_can_change_unit_cost,
|
|
case_id=case.id,
|
|
)
|
|
actual_lines = correction.get("lines") or []
|
|
actual_by_case_line = _correction_lines_by_case_line(actual_lines)
|
|
for case_line in case.lines:
|
|
actual = actual_by_case_line.get(int(case_line.id))
|
|
if not actual:
|
|
raise ApiError(
|
|
"INVENTORY_RESOLUTION_LINE_MISMATCH",
|
|
"ردیف سند اصلاح با پرونده مغایرت تطبیق ندارد",
|
|
http_status=500,
|
|
)
|
|
qty = _decimal(actual.get("quantity"))
|
|
unit_cost = _decimal(actual.get("unit_cost"))
|
|
case_line.resolved_quantity = _decimal(case_line.resolved_quantity) + qty
|
|
case_line.resolution_unit_cost = unit_cost
|
|
if case_line.invoice_item_line_id:
|
|
sale_line = db.query(InvoiceItemLine).filter(
|
|
InvoiceItemLine.id == int(case_line.invoice_item_line_id)
|
|
).first()
|
|
if sale_line:
|
|
extra = dict(sale_line.extra_info or {})
|
|
allocations = list(extra.get("stock_shortage_resolutions") or [])
|
|
allocations.append(
|
|
{
|
|
"case_line_id": case_line.id,
|
|
"quantity": float(qty),
|
|
"unit_cost": float(unit_cost),
|
|
"resolution_document_id": correction["id"],
|
|
"resolved_at": datetime.utcnow().isoformat(),
|
|
}
|
|
)
|
|
extra["stock_shortage_resolutions"] = allocations
|
|
sale_line.extra_info = extra
|
|
flag_modified(sale_line, "extra_info")
|
|
sale_qty = _decimal(sale_line.quantity)
|
|
if sale_qty > 0:
|
|
previous_unit = _decimal(sale_line.ledger_unit_cogs)
|
|
# اگر موجودی پیش از این فروش منفی بوده، مقدار اصلاح میتواند از
|
|
# تعداد همین فاکتور بیشتر باشد؛ فقط سهم همین ردیف به COGS آن تخصیص مییابد.
|
|
allocated_qty = min(_decimal(case_line.shortage_quantity), sale_qty)
|
|
normal_qty = sale_qty - allocated_qty
|
|
new_total = normal_qty * previous_unit + allocated_qty * unit_cost
|
|
sale_line.ledger_unit_cogs = new_total / sale_qty
|
|
sale_line.ledger_line_cogs = new_total
|
|
sale_amount = _decimal(extra.get("line_total"))
|
|
sale_line.ledger_line_gross_profit = sale_amount - new_total
|
|
sale_line.ledger_recognized_at = datetime.utcnow()
|
|
sale_line.ledger_recognition_event = "inventory_discrepancy_resolution"
|
|
case.status = "resolved"
|
|
case.resolution_document_id = int(correction["id"])
|
|
case.resolution_type = "goods_income_unexplained_surplus"
|
|
case.resolved_by_user_id = int(user_id)
|
|
case.resolved_at = datetime.utcnow()
|
|
db.flush()
|
|
db.commit()
|
|
return {"case": _serialize(get_case(db, business_id, case.id)), "correction_document": correction}
|