forked from hesabix/arc
337 lines
12 KiB
Python
Executable file
337 lines
12 KiB
Python
Executable file
"""
|
|
ایجاد/ویرایش گروهی اشخاص برای ادغام (مثل ووکامرس / مهاجرت هلو) —
|
|
هر آیتم جدا خطا؛ کش در انتهای موفقیت یکبار invalidate.
|
|
مانده افتتاحیه در صورت ارسال، از مسیر create/update_with_opening_balance اعمال میشود.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from pydantic import ValidationError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from adapters.api.v1.schema_models.person import PersonCreateRequest, PersonUpdateRequest
|
|
from adapters.db.models.person import Person
|
|
from app.core.auth_dependency import AuthContext
|
|
from app.core.permissions import has_business_permission_for_business
|
|
from app.core.responses import ApiError
|
|
from app.services.person_opening_balance_service import (
|
|
create_person_with_opening_balance,
|
|
update_person_with_opening_balance,
|
|
)
|
|
from app.services.person_service import (
|
|
create_person,
|
|
delete_person,
|
|
invalidate_persons_cache,
|
|
update_person,
|
|
)
|
|
|
|
MAX_BULK_PERSON_ITEMS = 1000
|
|
|
|
|
|
def api_error_detail(err: ApiError) -> Dict[str, str]:
|
|
d = getattr(err, "detail", None)
|
|
if isinstance(d, dict):
|
|
payload = d.get("error") or {}
|
|
code = payload.get("code") or getattr(err, "status_code", "API_ERROR")
|
|
msg = payload.get("message") or str(err)
|
|
return {"code": str(code), "message": str(msg)}
|
|
return {"code": "API_ERROR", "message": str(err)}
|
|
|
|
|
|
def _payload_has_opening_balance(payload: Dict[str, Any]) -> bool:
|
|
ob = payload.get("opening_balance")
|
|
return isinstance(ob, dict)
|
|
|
|
|
|
def bulk_upsert_persons_integration(
|
|
db: Session,
|
|
business_id: int,
|
|
auth_context: AuthContext,
|
|
body: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
items = body.get("items")
|
|
if not isinstance(items, list):
|
|
raise ApiError("INVALID_REQUEST", "items باید آرایه باشد", http_status=400)
|
|
|
|
if len(items) > MAX_BULK_PERSON_ITEMS:
|
|
raise ApiError(
|
|
"BULK_TOO_LARGE",
|
|
f"حداکثر {MAX_BULK_PERSON_ITEMS} آیتم در هر درخواست مجاز است",
|
|
http_status=400,
|
|
)
|
|
|
|
create_if_update_missing = bool(body.get("create_if_update_missing", True))
|
|
|
|
can_add = has_business_permission_for_business(auth_context, db, business_id, "people", "add")
|
|
can_edit = has_business_permission_for_business(auth_context, db, business_id, "people", "edit")
|
|
can_edit_ob = has_business_permission_for_business(
|
|
auth_context, db, business_id, "opening_balance", "edit"
|
|
)
|
|
user_id = auth_context.get_user_id()
|
|
|
|
def _create_person_deferred(session: Session, bid: int, pdata: Any) -> Dict[str, Any]:
|
|
return create_person(session, bid, pdata, defer_cache_invalidation=True)
|
|
|
|
def _update_person_deferred(
|
|
session: Session, pid: int, bid: int, pdata: Any
|
|
) -> Optional[Dict[str, Any]]:
|
|
return update_person(session, pid, bid, pdata, defer_cache_invalidation=True)
|
|
|
|
results: List[Dict[str, Any]] = []
|
|
any_success = False
|
|
|
|
for idx, raw in enumerate(items):
|
|
row_base = {"index": idx}
|
|
|
|
def _cref(c: Any) -> Optional[str]:
|
|
if c is None:
|
|
return None
|
|
s = str(c).strip()
|
|
return s or None
|
|
|
|
if not isinstance(raw, dict):
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"client_ref": None,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "INVALID_ITEM",
|
|
"message": "آیتم باید یک شیٔ JSON باشد",
|
|
}
|
|
)
|
|
continue
|
|
|
|
client_ref = _cref(raw.get("client_ref"))
|
|
row_base["client_ref"] = client_ref
|
|
|
|
payload = raw.get("payload")
|
|
if not isinstance(payload, dict):
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "INVALID_PAYLOAD",
|
|
"message": "فیلد payload الزامی است و باید شیٔ باشد",
|
|
}
|
|
)
|
|
continue
|
|
|
|
wants_ob = _payload_has_opening_balance(payload)
|
|
if wants_ob and not can_edit_ob:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "OPENING_BALANCE_PERMISSION_REQUIRED",
|
|
"message": "برای ثبت مانده افتتاحیه به دسترسی ویرایش تراز افتتاحیه نیاز است",
|
|
}
|
|
)
|
|
continue
|
|
|
|
raw_pid = raw.get("person_id")
|
|
person_id: Optional[int] = None
|
|
if raw_pid not in (None, ""):
|
|
try:
|
|
person_id = int(raw_pid)
|
|
except (ValueError, TypeError):
|
|
person_id = None
|
|
if person_id is not None and person_id <= 0:
|
|
person_id = None
|
|
|
|
resolved_to_create = person_id is None
|
|
|
|
# --- مسیر بهروزرسانی ---
|
|
if not resolved_to_create:
|
|
if not can_edit:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "FORBIDDEN",
|
|
"message": "مجوز ویرایش اشخاص (people.edit) وجود ندارد",
|
|
}
|
|
)
|
|
continue
|
|
|
|
exists_here = (
|
|
db.query(Person.id)
|
|
.filter(Person.id == person_id, Person.business_id == business_id)
|
|
.first()
|
|
)
|
|
if not exists_here:
|
|
if create_if_update_missing:
|
|
resolved_to_create = True
|
|
else:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "PERSON_NOT_FOUND",
|
|
"message": "شخص برای بهروزرسانی یافت نشد یا متعلق به این کسبوکار نیست",
|
|
}
|
|
)
|
|
continue
|
|
else:
|
|
try:
|
|
p_update = PersonUpdateRequest.model_validate(payload)
|
|
except ValidationError as ve:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "VALIDATION_ERROR",
|
|
"message": str(ve),
|
|
}
|
|
)
|
|
continue
|
|
try:
|
|
if wants_ob:
|
|
updated = update_person_with_opening_balance(
|
|
db,
|
|
business_id,
|
|
user_id,
|
|
int(person_id),
|
|
p_update,
|
|
update_person_fn=_update_person_deferred,
|
|
)
|
|
else:
|
|
updated = update_person(
|
|
db,
|
|
int(person_id),
|
|
business_id,
|
|
p_update,
|
|
defer_cache_invalidation=True,
|
|
)
|
|
except ApiError as ae:
|
|
de = api_error_detail(ae)
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": de["code"],
|
|
"message": de["message"],
|
|
}
|
|
)
|
|
continue
|
|
except ValueError as ve:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "VALUE_ERROR",
|
|
"message": str(ve),
|
|
}
|
|
)
|
|
continue
|
|
|
|
if updated and isinstance(updated.get("data"), dict):
|
|
nid = updated["data"].get("id")
|
|
any_success = True
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "updated",
|
|
"person_id": int(nid)
|
|
if nid is not None
|
|
else int(person_id),
|
|
}
|
|
)
|
|
continue
|
|
elif updated is None and create_if_update_missing:
|
|
resolved_to_create = True
|
|
else:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "UPDATE_FAILED",
|
|
"message": "بهروزرسانی شخص ناموفق بود",
|
|
}
|
|
)
|
|
continue
|
|
|
|
# --- مسیر ایجاد ---
|
|
if resolved_to_create:
|
|
if not can_add:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "FORBIDDEN",
|
|
"message": "مجوز ایجاد اشخاص (people.add) وجود ندارد",
|
|
}
|
|
)
|
|
continue
|
|
|
|
try:
|
|
p_create = PersonCreateRequest.model_validate(payload)
|
|
except ValidationError as ve:
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": "VALIDATION_ERROR",
|
|
"message": str(ve),
|
|
}
|
|
)
|
|
continue
|
|
|
|
try:
|
|
if wants_ob:
|
|
cr = create_person_with_opening_balance(
|
|
db,
|
|
business_id,
|
|
user_id,
|
|
p_create,
|
|
create_person_fn=_create_person_deferred,
|
|
delete_person_fn=delete_person,
|
|
)
|
|
else:
|
|
cr = create_person(
|
|
db, business_id, p_create, defer_cache_invalidation=True
|
|
)
|
|
pdata = cr.get("data") or {}
|
|
nid = pdata.get("id")
|
|
any_success = True
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "created",
|
|
"person_id": int(nid) if nid is not None else None,
|
|
}
|
|
)
|
|
except ApiError as ae:
|
|
de = api_error_detail(ae)
|
|
results.append(
|
|
{
|
|
**row_base,
|
|
"status": "failed",
|
|
"person_id": None,
|
|
"error_code": de["code"],
|
|
"message": de["message"],
|
|
}
|
|
)
|
|
|
|
if any_success:
|
|
invalidate_persons_cache(business_id, fiscal_year_id=None)
|
|
|
|
summary = {
|
|
"total": len(items),
|
|
"created": sum(1 for r in results if r.get("status") == "created"),
|
|
"updated": sum(1 for r in results if r.get("status") == "updated"),
|
|
"failed": sum(1 for r in results if r.get("status") == "failed"),
|
|
"skipped": sum(1 for r in results if r.get("status") == "skipped"),
|
|
}
|
|
|
|
return {"results": results, "summary": summary}
|