forked from hesabix/arc
2108 lines
68 KiB
Python
2108 lines
68 KiB
Python
"""
|
|
سرویس افزونه حقوق و دستمزد.
|
|
|
|
پوشش: تنظیمات، دستهبندی و آیتمهای حقوق، پرسنل، بخشها، دورهها و اجرای حقوق.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import json
|
|
import re
|
|
from datetime import date, datetime
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from sqlalchemy import and_, desc, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from adapters.db.models.account import Account
|
|
from adapters.db.models.business import Business
|
|
from adapters.db.models.payroll import (
|
|
PayrollAuditLog,
|
|
PayrollDepartment,
|
|
PayrollDocumentLink,
|
|
PayrollEmployee,
|
|
PayrollItemCategory,
|
|
PayrollItemDefinition,
|
|
PayrollPeriod,
|
|
PayrollRun,
|
|
PayrollRunLine,
|
|
PayrollRunLineItem,
|
|
PayrollSettings,
|
|
)
|
|
from adapters.db.models.person import Person, PersonType
|
|
from adapters.db.models.document import Document
|
|
from app.core.calendar import CalendarConverter, CalendarType
|
|
from app.core.payroll_plugin_dependency import check_payroll_plugin_active
|
|
from app.core.responses import ApiError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ITEM_KINDS = frozenset({"earning", "deduction", "employer_cost", "informational"})
|
|
_CALCULATION_TYPES = frozenset(
|
|
{"manual", "fixed", "percent_of_base", "percent_of_gross", "formula", "statutory"}
|
|
)
|
|
_STATUTORY_ITEM_CODES = frozenset({"insurance_employee", "insurance_employer", "tax"})
|
|
_EMPLOYMENT_TYPES = frozenset({"full_time", "part_time", "contract", "daily", "seasonal"})
|
|
_RUN_STATUSES = frozenset({"draft", "pending_approval", "approved", "finalized", "posted", "cancelled"})
|
|
_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
|
|
|
|
def _format_payroll_date_for_display(value: Any, calendar_type: CalendarType = "jalali") -> str:
|
|
"""تبدیل تاریخ برای نمایش در PDF/گزارش مطابق تقویم کاربر."""
|
|
if value is None or value == "":
|
|
return "-"
|
|
if isinstance(value, str):
|
|
try:
|
|
dt = datetime.fromisoformat(value.split("T")[0])
|
|
except ValueError:
|
|
return value
|
|
elif isinstance(value, date) and not isinstance(value, datetime):
|
|
dt = datetime.combine(value, datetime.min.time())
|
|
elif isinstance(value, datetime):
|
|
dt = value
|
|
else:
|
|
return str(value)
|
|
if calendar_type == "jalali":
|
|
return CalendarConverter.to_jalali(dt)["date_only"]
|
|
return dt.date().isoformat()
|
|
|
|
|
|
def _ensure_plugin(db: Session, business_id: int) -> None:
|
|
if not check_payroll_plugin_active(db, business_id):
|
|
raise ApiError(
|
|
"PAYROLL_PLUGIN_NOT_ACTIVE",
|
|
"افزونه حقوق و دستمزد برای این کسبوکار فعال نیست.",
|
|
http_status=403,
|
|
details={"plugin_code": "payroll"},
|
|
)
|
|
|
|
|
|
def _decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal:
|
|
if value is None or value == "":
|
|
return default
|
|
try:
|
|
return Decimal(str(value))
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
raise ApiError("INVALID_AMOUNT", f"مقدار عددی نامعتبر: {value}", http_status=400)
|
|
|
|
|
|
def _person_has_type(person: Person, type_value: str) -> bool:
|
|
raw = person.person_types
|
|
if not raw:
|
|
return False
|
|
try:
|
|
types = json.loads(raw)
|
|
if isinstance(types, list):
|
|
return type_value in types
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
return f'"{type_value}"' in raw or type_value in raw
|
|
|
|
|
|
def _validate_code(code: str) -> str:
|
|
c = str(code or "").strip().lower()
|
|
if not c or not _CODE_RE.match(c):
|
|
raise ApiError(
|
|
"INVALID_CODE",
|
|
"کد باید با حرف انگلیسی شروع شود و فقط شامل حروف کوچک، اعداد و _ باشد.",
|
|
http_status=400,
|
|
)
|
|
return c
|
|
|
|
|
|
def _validate_account(db: Session, business_id: int, account_id: Optional[int]) -> None:
|
|
if account_id is None:
|
|
return
|
|
acc = (
|
|
db.query(Account)
|
|
.filter(
|
|
Account.id == int(account_id),
|
|
or_(Account.business_id == business_id, Account.business_id.is_(None)), # noqa: E711
|
|
)
|
|
.first()
|
|
)
|
|
if not acc:
|
|
raise ApiError("ACCOUNT_NOT_FOUND", "حساب یافت نشد یا متعلق به این کسبوکار نیست.", http_status=404)
|
|
|
|
|
|
def _json_safe(value: Any) -> Any:
|
|
"""تبدیل مقادیر غیرقابل سریالایز JSON برای ذخیره در audit log."""
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
if isinstance(value, Decimal):
|
|
return str(value)
|
|
if isinstance(value, dict):
|
|
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_json_safe(v) for v in value]
|
|
return str(value)
|
|
|
|
|
|
def _audit(
|
|
db: Session,
|
|
business_id: int,
|
|
entity_type: str,
|
|
entity_id: int,
|
|
action: str,
|
|
user_id: Optional[int],
|
|
old_values: Optional[dict] = None,
|
|
new_values: Optional[dict] = None,
|
|
) -> None:
|
|
db.add(
|
|
PayrollAuditLog(
|
|
business_id=business_id,
|
|
entity_type=entity_type,
|
|
entity_id=entity_id,
|
|
action=action,
|
|
user_id=user_id,
|
|
old_values=_json_safe(old_values) if old_values is not None else None,
|
|
new_values=_json_safe(new_values) if new_values is not None else None,
|
|
)
|
|
)
|
|
|
|
|
|
# ─── تنظیمات ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _settings_to_dict(row: PayrollSettings) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"enabled": bool(row.enabled),
|
|
"document_code_format": row.document_code_format,
|
|
"document_code_prefix": row.document_code_prefix,
|
|
"wages_payable_account_id": row.wages_payable_account_id,
|
|
"payroll_expense_account_id": row.payroll_expense_account_id,
|
|
"tax_payable_account_id": row.tax_payable_account_id,
|
|
"insurance_payable_account_id": row.insurance_payable_account_id,
|
|
"default_currency_id": row.default_currency_id,
|
|
"calculation_mode": row.calculation_mode,
|
|
"require_approval": bool(row.require_approval),
|
|
"auto_post_on_finalize": bool(row.auto_post_on_finalize),
|
|
"allow_negative_net": bool(row.allow_negative_net),
|
|
"round_amounts_to": int(row.round_amounts_to or 0),
|
|
"payslip_template_json": row.payslip_template_json,
|
|
"extra_settings": row.extra_settings,
|
|
"created_at": row.created_at,
|
|
"updated_at": row.updated_at,
|
|
}
|
|
|
|
|
|
_DEFAULT_CATEGORIES: Tuple[Tuple[str, str, str, int], ...] = (
|
|
("earnings", "مزایا و درآمد", "earning", 1),
|
|
("deductions", "کسورات", "deduction", 2),
|
|
("employer_costs", "هزینه کارفرما", "employer_cost", 3),
|
|
)
|
|
|
|
_DEFAULT_ITEMS: Tuple[Tuple[str, str, str, str, int, bool, bool], ...] = (
|
|
("base_salary", "حقوق پایه", "earnings", "earning", 1, True, True),
|
|
("overtime", "اضافهکاری", "earnings", "earning", 2, True, True),
|
|
("housing_allowance", "حق مسکن", "earnings", "earning", 3, True, False),
|
|
("child_allowance", "حق اولاد", "earnings", "earning", 4, True, False),
|
|
("bonus", "پاداش", "earnings", "earning", 5, True, True),
|
|
("insurance_employee", "بیمه سهم کارگر", "deductions", "deduction", 10, False, True),
|
|
("tax", "مالیات حقوق", "deductions", "deduction", 11, False, False),
|
|
("loan", "وام / مساعده", "deductions", "deduction", 12, False, False),
|
|
("insurance_employer", "بیمه سهم کارفرما", "employer_costs", "employer_cost", 20, False, True),
|
|
)
|
|
|
|
|
|
def _seed_defaults(db: Session, business_id: int) -> None:
|
|
existing_cat = (
|
|
db.query(PayrollItemCategory.id)
|
|
.filter(PayrollItemCategory.business_id == business_id)
|
|
.limit(1)
|
|
.first()
|
|
)
|
|
if existing_cat:
|
|
return
|
|
|
|
cat_map: Dict[str, int] = {}
|
|
for code, name, kind, sort_order in _DEFAULT_CATEGORIES:
|
|
cat = PayrollItemCategory(
|
|
business_id=business_id,
|
|
code=code,
|
|
name=name,
|
|
item_kind=kind,
|
|
sort_order=sort_order,
|
|
is_system=True,
|
|
is_active=True,
|
|
)
|
|
db.add(cat)
|
|
db.flush()
|
|
cat_map[code] = cat.id
|
|
|
|
for code, name, cat_code, kind, sort_order, taxable, insurable in _DEFAULT_ITEMS:
|
|
calc_type = "statutory" if code in _STATUTORY_ITEM_CODES else "manual"
|
|
db.add(
|
|
PayrollItemDefinition(
|
|
business_id=business_id,
|
|
category_id=cat_map.get(cat_code),
|
|
code=code,
|
|
name=name,
|
|
item_kind=kind,
|
|
calculation_type=calc_type,
|
|
affects_gross=kind == "earning",
|
|
affects_taxable=taxable,
|
|
affects_insurance=insurable,
|
|
is_taxable=taxable and kind == "earning",
|
|
is_insurable=insurable,
|
|
show_on_payslip=True,
|
|
sort_order=sort_order,
|
|
is_system=True,
|
|
is_active=True,
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
|
|
def _get_or_create_settings(db: Session, business_id: int) -> PayrollSettings:
|
|
row = db.query(PayrollSettings).filter(PayrollSettings.business_id == business_id).first()
|
|
if row:
|
|
return row
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise ApiError("BUSINESS_NOT_FOUND", "کسبوکار یافت نشد.", http_status=404)
|
|
row = PayrollSettings(
|
|
business_id=business_id,
|
|
enabled=True,
|
|
default_currency_id=business.default_currency_id,
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
_seed_defaults(db, business_id)
|
|
return row
|
|
|
|
|
|
def get_settings(db: Session, business_id: int) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = _get_or_create_settings(db, business_id)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _settings_to_dict(row)
|
|
|
|
|
|
def update_settings(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = _get_or_create_settings(db, business_id)
|
|
old = _settings_to_dict(row)
|
|
|
|
for field in (
|
|
"enabled",
|
|
"document_code_format",
|
|
"document_code_prefix",
|
|
"calculation_mode",
|
|
"require_approval",
|
|
"auto_post_on_finalize",
|
|
"allow_negative_net",
|
|
"payslip_template_json",
|
|
"extra_settings",
|
|
):
|
|
if field in payload:
|
|
setattr(row, field, payload[field])
|
|
|
|
if "round_amounts_to" in payload:
|
|
row.round_amounts_to = int(payload["round_amounts_to"] or 0)
|
|
|
|
for acct_field in (
|
|
"wages_payable_account_id",
|
|
"payroll_expense_account_id",
|
|
"tax_payable_account_id",
|
|
"insurance_payable_account_id",
|
|
):
|
|
if acct_field in payload:
|
|
val = payload[acct_field]
|
|
aid = int(val) if val is not None else None
|
|
_validate_account(db, business_id, aid)
|
|
setattr(row, acct_field, aid)
|
|
|
|
if "default_currency_id" in payload:
|
|
val = payload["default_currency_id"]
|
|
row.default_currency_id = int(val) if val is not None else None
|
|
|
|
row.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_settings", row.id, "update", user_id, old, _settings_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _settings_to_dict(row)
|
|
|
|
|
|
# ─── دستهبندی آیتمها ─────────────────────────────────────────────────────
|
|
|
|
|
|
def _category_to_dict(row: PayrollItemCategory) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"code": row.code,
|
|
"name": row.name,
|
|
"item_kind": row.item_kind,
|
|
"sort_order": row.sort_order,
|
|
"is_system": bool(row.is_system),
|
|
"is_active": bool(row.is_active),
|
|
"description": row.description,
|
|
}
|
|
|
|
|
|
def list_item_categories(db: Session, business_id: int, include_inactive: bool = False) -> List[Dict[str, Any]]:
|
|
_ensure_plugin(db, business_id)
|
|
_get_or_create_settings(db, business_id)
|
|
q = db.query(PayrollItemCategory).filter(PayrollItemCategory.business_id == business_id)
|
|
if not include_inactive:
|
|
q = q.filter(PayrollItemCategory.is_active == True) # noqa: E712
|
|
rows = q.order_by(PayrollItemCategory.sort_order, PayrollItemCategory.id).all()
|
|
return [_category_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_item_category(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
code = _validate_code(payload.get("code"))
|
|
kind = str(payload.get("item_kind") or "earning")
|
|
if kind not in _ITEM_KINDS:
|
|
raise ApiError("INVALID_ITEM_KIND", f"نوع آیتم نامعتبر: {kind}", http_status=400)
|
|
dup = (
|
|
db.query(PayrollItemCategory)
|
|
.filter(PayrollItemCategory.business_id == business_id, PayrollItemCategory.code == code)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("DUPLICATE_CODE", f"دسته با کد {code} از قبل وجود دارد.", http_status=409)
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
raise ApiError("NAME_REQUIRED", "نام دسته الزامی است.", http_status=400)
|
|
row = PayrollItemCategory(
|
|
business_id=business_id,
|
|
code=code,
|
|
name=name,
|
|
item_kind=kind,
|
|
sort_order=int(payload.get("sort_order") or 0),
|
|
is_system=False,
|
|
is_active=bool(payload.get("is_active", True)),
|
|
description=payload.get("description"),
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
_audit(db, business_id, "payroll_item_category", row.id, "create", user_id, None, _category_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _category_to_dict(row)
|
|
|
|
|
|
def update_item_category(
|
|
db: Session,
|
|
business_id: int,
|
|
category_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollItemCategory)
|
|
.filter(PayrollItemCategory.business_id == business_id, PayrollItemCategory.id == category_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "دسته یافت نشد.", http_status=404)
|
|
old = _category_to_dict(row)
|
|
if row.is_system and payload.get("code") and payload["code"] != row.code:
|
|
raise ApiError("SYSTEM_CATEGORY_LOCKED", "کد دسته سیستمی قابل تغییر نیست.", http_status=400)
|
|
for field in ("name", "description", "sort_order", "is_active"):
|
|
if field in payload:
|
|
setattr(row, field, payload[field])
|
|
if "item_kind" in payload and not row.is_system:
|
|
kind = str(payload["item_kind"])
|
|
if kind not in _ITEM_KINDS:
|
|
raise ApiError("INVALID_ITEM_KIND", f"نوع آیتم نامعتبر: {kind}", http_status=400)
|
|
row.item_kind = kind
|
|
row.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_item_category", row.id, "update", user_id, old, _category_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _category_to_dict(row)
|
|
|
|
|
|
def delete_item_category(
|
|
db: Session,
|
|
business_id: int,
|
|
category_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> None:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollItemCategory)
|
|
.filter(PayrollItemCategory.business_id == business_id, PayrollItemCategory.id == category_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "دسته یافت نشد.", http_status=404)
|
|
if row.is_system:
|
|
raise ApiError("SYSTEM_CATEGORY_LOCKED", "دسته سیستمی قابل حذف نیست.", http_status=400)
|
|
has_items = (
|
|
db.query(PayrollItemDefinition.id)
|
|
.filter(PayrollItemDefinition.category_id == row.id)
|
|
.limit(1)
|
|
.first()
|
|
)
|
|
if has_items:
|
|
raise ApiError("CATEGORY_HAS_ITEMS", "ابتدا آیتمهای این دسته را حذف یا منتقل کنید.", http_status=409)
|
|
old = _category_to_dict(row)
|
|
db.delete(row)
|
|
_audit(db, business_id, "payroll_item_category", category_id, "delete", user_id, old, None)
|
|
db.commit()
|
|
|
|
|
|
# ─── آیتمهای حقوق ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _item_to_dict(row: PayrollItemDefinition) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"category_id": row.category_id,
|
|
"code": row.code,
|
|
"name": row.name,
|
|
"item_kind": row.item_kind,
|
|
"account_id": row.account_id,
|
|
"counter_account_id": row.counter_account_id,
|
|
"calculation_type": row.calculation_type,
|
|
"default_amount": float(row.default_amount) if row.default_amount is not None else None,
|
|
"percent_value": float(row.percent_value) if row.percent_value is not None else None,
|
|
"formula_expression": row.formula_expression,
|
|
"affects_gross": bool(row.affects_gross),
|
|
"affects_taxable": bool(row.affects_taxable),
|
|
"affects_insurance": bool(row.affects_insurance),
|
|
"is_taxable": bool(row.is_taxable),
|
|
"is_insurable": bool(row.is_insurable),
|
|
"show_on_payslip": bool(row.show_on_payslip),
|
|
"sort_order": row.sort_order,
|
|
"is_system": bool(row.is_system),
|
|
"is_active": bool(row.is_active),
|
|
"extra_config": row.extra_config,
|
|
}
|
|
|
|
|
|
def list_item_definitions(
|
|
db: Session,
|
|
business_id: int,
|
|
*,
|
|
item_kind: Optional[str] = None,
|
|
include_inactive: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
_ensure_plugin(db, business_id)
|
|
_get_or_create_settings(db, business_id)
|
|
q = db.query(PayrollItemDefinition).filter(PayrollItemDefinition.business_id == business_id)
|
|
if not include_inactive:
|
|
q = q.filter(PayrollItemDefinition.is_active == True) # noqa: E712
|
|
if item_kind:
|
|
q = q.filter(PayrollItemDefinition.item_kind == item_kind)
|
|
rows = q.order_by(PayrollItemDefinition.sort_order, PayrollItemDefinition.id).all()
|
|
return [_item_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_item_definition(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
code = _validate_code(payload.get("code"))
|
|
kind = str(payload.get("item_kind") or "earning")
|
|
if kind not in _ITEM_KINDS:
|
|
raise ApiError("INVALID_ITEM_KIND", f"نوع آیتم نامعتبر: {kind}", http_status=400)
|
|
calc = str(payload.get("calculation_type") or "manual")
|
|
if calc not in _CALCULATION_TYPES:
|
|
raise ApiError("INVALID_CALCULATION_TYPE", f"نوع محاسبه نامعتبر: {calc}", http_status=400)
|
|
dup = (
|
|
db.query(PayrollItemDefinition)
|
|
.filter(PayrollItemDefinition.business_id == business_id, PayrollItemDefinition.code == code)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("DUPLICATE_CODE", f"آیتم با کد {code} از قبل وجود دارد.", http_status=409)
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
raise ApiError("NAME_REQUIRED", "نام آیتم الزامی است.", http_status=400)
|
|
|
|
category_id = payload.get("category_id")
|
|
if category_id is not None:
|
|
cat = (
|
|
db.query(PayrollItemCategory)
|
|
.filter(
|
|
PayrollItemCategory.business_id == business_id,
|
|
PayrollItemCategory.id == int(category_id),
|
|
)
|
|
.first()
|
|
)
|
|
if not cat:
|
|
raise ApiError("CATEGORY_NOT_FOUND", "دسته یافت نشد.", http_status=404)
|
|
|
|
account_id = payload.get("account_id")
|
|
counter_account_id = payload.get("counter_account_id")
|
|
_validate_account(db, business_id, int(account_id) if account_id is not None else None)
|
|
_validate_account(db, business_id, int(counter_account_id) if counter_account_id is not None else None)
|
|
|
|
row = PayrollItemDefinition(
|
|
business_id=business_id,
|
|
category_id=int(category_id) if category_id is not None else None,
|
|
code=code,
|
|
name=name,
|
|
item_kind=kind,
|
|
account_id=int(account_id) if account_id is not None else None,
|
|
counter_account_id=int(counter_account_id) if counter_account_id is not None else None,
|
|
calculation_type=calc,
|
|
default_amount=_decimal(payload["default_amount"]) if payload.get("default_amount") is not None else None,
|
|
percent_value=_decimal(payload["percent_value"]) if payload.get("percent_value") is not None else None,
|
|
formula_expression=payload.get("formula_expression"),
|
|
affects_gross=bool(payload.get("affects_gross", kind == "earning")),
|
|
affects_taxable=bool(payload.get("affects_taxable", True)),
|
|
affects_insurance=bool(payload.get("affects_insurance", True)),
|
|
is_taxable=bool(payload.get("is_taxable", False)),
|
|
is_insurable=bool(payload.get("is_insurable", True)),
|
|
show_on_payslip=bool(payload.get("show_on_payslip", True)),
|
|
sort_order=int(payload.get("sort_order") or 0),
|
|
is_system=False,
|
|
is_active=bool(payload.get("is_active", True)),
|
|
extra_config=payload.get("extra_config"),
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
_audit(db, business_id, "payroll_item_definition", row.id, "create", user_id, None, _item_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _item_to_dict(row)
|
|
|
|
|
|
def update_item_definition(
|
|
db: Session,
|
|
business_id: int,
|
|
item_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollItemDefinition)
|
|
.filter(PayrollItemDefinition.business_id == business_id, PayrollItemDefinition.id == item_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "آیتم یافت نشد.", http_status=404)
|
|
old = _item_to_dict(row)
|
|
if row.is_system and payload.get("code") and payload["code"] != row.code:
|
|
raise ApiError("SYSTEM_ITEM_LOCKED", "کد آیتم سیستمی قابل تغییر نیست.", http_status=400)
|
|
|
|
for field in (
|
|
"name",
|
|
"formula_expression",
|
|
"affects_gross",
|
|
"affects_taxable",
|
|
"affects_insurance",
|
|
"is_taxable",
|
|
"is_insurable",
|
|
"show_on_payslip",
|
|
"sort_order",
|
|
"is_active",
|
|
"extra_config",
|
|
):
|
|
if field in payload:
|
|
setattr(row, field, payload[field])
|
|
|
|
if "category_id" in payload:
|
|
cid = payload["category_id"]
|
|
if cid is not None:
|
|
cat = (
|
|
db.query(PayrollItemCategory)
|
|
.filter(PayrollItemCategory.business_id == business_id, PayrollItemCategory.id == int(cid))
|
|
.first()
|
|
)
|
|
if not cat:
|
|
raise ApiError("CATEGORY_NOT_FOUND", "دسته یافت نشد.", http_status=404)
|
|
row.category_id = int(cid) if cid is not None else None
|
|
|
|
if "account_id" in payload:
|
|
aid = payload["account_id"]
|
|
_validate_account(db, business_id, int(aid) if aid is not None else None)
|
|
row.account_id = int(aid) if aid is not None else None
|
|
|
|
if "counter_account_id" in payload:
|
|
caid = payload["counter_account_id"]
|
|
_validate_account(db, business_id, int(caid) if caid is not None else None)
|
|
row.counter_account_id = int(caid) if caid is not None else None
|
|
|
|
if "default_amount" in payload:
|
|
val = payload["default_amount"]
|
|
row.default_amount = _decimal(val) if val is not None else None
|
|
|
|
if "percent_value" in payload:
|
|
val = payload["percent_value"]
|
|
row.percent_value = _decimal(val) if val is not None else None
|
|
|
|
if not row.is_system:
|
|
if "item_kind" in payload:
|
|
kind = str(payload["item_kind"])
|
|
if kind not in _ITEM_KINDS:
|
|
raise ApiError("INVALID_ITEM_KIND", f"نوع آیتم نامعتبر: {kind}", http_status=400)
|
|
row.item_kind = kind
|
|
if "calculation_type" in payload:
|
|
calc = str(payload["calculation_type"])
|
|
if calc not in _CALCULATION_TYPES:
|
|
raise ApiError("INVALID_CALCULATION_TYPE", f"نوع محاسبه نامعتبر: {calc}", http_status=400)
|
|
row.calculation_type = calc
|
|
|
|
row.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_item_definition", row.id, "update", user_id, old, _item_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _item_to_dict(row)
|
|
|
|
|
|
def delete_item_definition(
|
|
db: Session,
|
|
business_id: int,
|
|
item_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> None:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollItemDefinition)
|
|
.filter(PayrollItemDefinition.business_id == business_id, PayrollItemDefinition.id == item_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "آیتم یافت نشد.", http_status=404)
|
|
if row.is_system:
|
|
raise ApiError("SYSTEM_ITEM_LOCKED", "آیتم سیستمی قابل حذف نیست؛ میتوانید آن را غیرفعال کنید.", http_status=400)
|
|
old = _item_to_dict(row)
|
|
row.is_active = False
|
|
row.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_item_definition", row.id, "deactivate", user_id, old, _item_to_dict(row))
|
|
db.commit()
|
|
|
|
|
|
# ─── بخشها ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _department_to_dict(row: PayrollDepartment) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"parent_id": row.parent_id,
|
|
"code": row.code,
|
|
"name": row.name,
|
|
"is_active": bool(row.is_active),
|
|
"sort_order": row.sort_order,
|
|
}
|
|
|
|
|
|
def list_departments(db: Session, business_id: int, include_inactive: bool = False) -> List[Dict[str, Any]]:
|
|
_ensure_plugin(db, business_id)
|
|
q = db.query(PayrollDepartment).filter(PayrollDepartment.business_id == business_id)
|
|
if not include_inactive:
|
|
q = q.filter(PayrollDepartment.is_active == True) # noqa: E712
|
|
rows = q.order_by(PayrollDepartment.sort_order, PayrollDepartment.name).all()
|
|
return [_department_to_dict(r) for r in rows]
|
|
|
|
|
|
def create_department(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
code = _validate_code(payload.get("code"))
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
raise ApiError("NAME_REQUIRED", "نام بخش الزامی است.", http_status=400)
|
|
dup = (
|
|
db.query(PayrollDepartment)
|
|
.filter(PayrollDepartment.business_id == business_id, PayrollDepartment.code == code)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("DUPLICATE_CODE", f"بخش با کد {code} از قبل وجود دارد.", http_status=409)
|
|
parent_id = payload.get("parent_id")
|
|
if parent_id is not None:
|
|
parent = (
|
|
db.query(PayrollDepartment)
|
|
.filter(PayrollDepartment.business_id == business_id, PayrollDepartment.id == int(parent_id))
|
|
.first()
|
|
)
|
|
if not parent:
|
|
raise ApiError("PARENT_NOT_FOUND", "بخش والد یافت نشد.", http_status=404)
|
|
row = PayrollDepartment(
|
|
business_id=business_id,
|
|
parent_id=int(parent_id) if parent_id is not None else None,
|
|
code=code,
|
|
name=name,
|
|
is_active=bool(payload.get("is_active", True)),
|
|
sort_order=int(payload.get("sort_order") or 0),
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
_audit(db, business_id, "payroll_department", row.id, "create", user_id, None, _department_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _department_to_dict(row)
|
|
|
|
|
|
# ─── پرسنل ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _person_display_name(person: Person) -> str:
|
|
"""نام نمایشی شخص (مدل Person فیلد name ندارد)."""
|
|
parts = [person.first_name, person.last_name]
|
|
full = " ".join(p for p in parts if p)
|
|
return (
|
|
person.alias_name
|
|
or full.strip()
|
|
or person.company_name
|
|
or ""
|
|
)
|
|
|
|
|
|
def _employee_to_dict(row: PayrollEmployee, person: Optional[Person] = None) -> Dict[str, Any]:
|
|
data: Dict[str, Any] = {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"person_id": row.person_id,
|
|
"department_id": row.department_id,
|
|
"employee_code": row.employee_code,
|
|
"job_title": row.job_title,
|
|
"employment_type": row.employment_type,
|
|
"hire_date": row.hire_date,
|
|
"termination_date": row.termination_date,
|
|
"base_salary": float(row.base_salary) if row.base_salary is not None else None,
|
|
"insurance_number": row.insurance_number,
|
|
"tax_id": row.tax_id,
|
|
"bank_account_info": row.bank_account_info,
|
|
"is_active": bool(row.is_active),
|
|
"extra_info": row.extra_info,
|
|
}
|
|
if person:
|
|
data["person_name"] = _person_display_name(person)
|
|
data["person_code"] = person.code
|
|
return data
|
|
|
|
|
|
def list_employees(
|
|
db: Session,
|
|
business_id: int,
|
|
*,
|
|
include_inactive: bool = False,
|
|
department_id: Optional[int] = None,
|
|
limit: int = 100,
|
|
skip: int = 0,
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
_ensure_plugin(db, business_id)
|
|
q = (
|
|
db.query(PayrollEmployee, Person)
|
|
.join(Person, Person.id == PayrollEmployee.person_id)
|
|
.filter(PayrollEmployee.business_id == business_id)
|
|
)
|
|
if not include_inactive:
|
|
q = q.filter(PayrollEmployee.is_active == True) # noqa: E712
|
|
if department_id is not None:
|
|
q = q.filter(PayrollEmployee.department_id == int(department_id))
|
|
total = q.count()
|
|
rows = q.order_by(PayrollEmployee.employee_code).offset(skip).limit(limit).all()
|
|
return [_employee_to_dict(emp, person) for emp, person in rows], total
|
|
|
|
|
|
def create_employee(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
person_id = payload.get("person_id")
|
|
if person_id is None:
|
|
raise ApiError("PERSON_REQUIRED", "انتخاب شخص الزامی است.", http_status=400)
|
|
person = (
|
|
db.query(Person)
|
|
.filter(Person.business_id == business_id, Person.id == int(person_id))
|
|
.first()
|
|
)
|
|
if not person:
|
|
raise ApiError("PERSON_NOT_FOUND", "شخص یافت نشد.", http_status=404)
|
|
if not _person_has_type(person, PersonType.EMPLOYEE.value):
|
|
raise ApiError(
|
|
"PERSON_NOT_EMPLOYEE",
|
|
"شخص انتخابشده باید از نوع کارمند باشد.",
|
|
http_status=400,
|
|
)
|
|
emp_code = str(payload.get("employee_code") or "").strip()
|
|
if not emp_code:
|
|
emp_code = person.code or f"EMP{person.id}"
|
|
dup = (
|
|
db.query(PayrollEmployee)
|
|
.filter(
|
|
PayrollEmployee.business_id == business_id,
|
|
or_(
|
|
PayrollEmployee.employee_code == emp_code,
|
|
PayrollEmployee.person_id == int(person_id),
|
|
),
|
|
)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("EMPLOYEE_EXISTS", "این شخص یا کد پرسنلی قبلاً ثبت شده است.", http_status=409)
|
|
emp_type = str(payload.get("employment_type") or "full_time")
|
|
if emp_type not in _EMPLOYMENT_TYPES:
|
|
raise ApiError("INVALID_EMPLOYMENT_TYPE", f"نوع استخدام نامعتبر: {emp_type}", http_status=400)
|
|
row = PayrollEmployee(
|
|
business_id=business_id,
|
|
person_id=int(person_id),
|
|
department_id=int(payload["department_id"]) if payload.get("department_id") is not None else None,
|
|
employee_code=emp_code,
|
|
job_title=payload.get("job_title"),
|
|
employment_type=emp_type,
|
|
hire_date=date.fromisoformat(payload["hire_date"]) if payload.get("hire_date") else None,
|
|
base_salary=_decimal(payload["base_salary"]) if payload.get("base_salary") is not None else None,
|
|
insurance_number=payload.get("insurance_number"),
|
|
tax_id=payload.get("tax_id"),
|
|
bank_account_info=payload.get("bank_account_info"),
|
|
is_active=bool(payload.get("is_active", True)),
|
|
extra_info=payload.get("extra_info"),
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
data = _employee_to_dict(row, person)
|
|
_audit(db, business_id, "payroll_employee", row.id, "create", user_id, None, data)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return data
|
|
|
|
|
|
# ─── دورهها ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _period_to_dict(row: PayrollPeriod) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"year": row.year,
|
|
"month": row.month,
|
|
"title": row.title,
|
|
"start_date": row.start_date,
|
|
"end_date": row.end_date,
|
|
"status": row.status,
|
|
"closed_at": row.closed_at,
|
|
}
|
|
|
|
|
|
def list_periods(db: Session, business_id: int, limit: int = 24, skip: int = 0) -> Tuple[List[Dict[str, Any]], int]:
|
|
_ensure_plugin(db, business_id)
|
|
q = db.query(PayrollPeriod).filter(PayrollPeriod.business_id == business_id)
|
|
total = q.count()
|
|
rows = q.order_by(desc(PayrollPeriod.year), desc(PayrollPeriod.month)).offset(skip).limit(limit).all()
|
|
return [_period_to_dict(r) for r in rows], total
|
|
|
|
|
|
def create_period(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
year = int(payload.get("year") or 0)
|
|
month = int(payload.get("month") or 0)
|
|
if year < 1300 or year > 1500 or month < 1 or month > 12:
|
|
raise ApiError("INVALID_PERIOD", "سال یا ماه دوره نامعتبر است.", http_status=400)
|
|
dup = (
|
|
db.query(PayrollPeriod)
|
|
.filter(PayrollPeriod.business_id == business_id, PayrollPeriod.year == year, PayrollPeriod.month == month)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("PERIOD_EXISTS", "دوره این ماه از قبل وجود دارد.", http_status=409)
|
|
title = str(payload.get("title") or "").strip() or f"حقوق {year}/{month:02d}"
|
|
row = PayrollPeriod(
|
|
business_id=business_id,
|
|
year=year,
|
|
month=month,
|
|
title=title,
|
|
start_date=date.fromisoformat(payload["start_date"]) if payload.get("start_date") else None,
|
|
end_date=date.fromisoformat(payload["end_date"]) if payload.get("end_date") else None,
|
|
status="open",
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
data = _period_to_dict(row)
|
|
_audit(db, business_id, "payroll_period", row.id, "create", user_id, None, data)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return data
|
|
|
|
|
|
# ─── اجرای حقوق (لیست — فاز بعدی تکمیل CRUD) ───────────────────────────────
|
|
|
|
|
|
def _run_to_dict(row: PayrollRun) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"business_id": row.business_id,
|
|
"period_id": row.period_id,
|
|
"code": row.code,
|
|
"title": row.title,
|
|
"run_date": row.run_date,
|
|
"description": row.description,
|
|
"status": row.status,
|
|
"gross_total": float(row.gross_total),
|
|
"deduction_total": float(row.deduction_total),
|
|
"net_total": float(row.net_total),
|
|
"employer_cost_total": float(row.employer_cost_total),
|
|
"finalized_at": row.finalized_at,
|
|
"approved_at": row.approved_at,
|
|
"posted_at": row.posted_at,
|
|
"extra_info": row.extra_info,
|
|
"created_at": row.created_at,
|
|
"updated_at": row.updated_at,
|
|
}
|
|
|
|
|
|
def list_runs(
|
|
db: Session,
|
|
business_id: int,
|
|
*,
|
|
status: Optional[str] = None,
|
|
period_id: Optional[int] = None,
|
|
limit: int = 50,
|
|
skip: int = 0,
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
_ensure_plugin(db, business_id)
|
|
q = db.query(PayrollRun).filter(PayrollRun.business_id == business_id)
|
|
if status:
|
|
q = q.filter(PayrollRun.status == status)
|
|
if period_id is not None:
|
|
q = q.filter(PayrollRun.period_id == int(period_id))
|
|
total = q.count()
|
|
rows = q.order_by(desc(PayrollRun.run_date), desc(PayrollRun.id)).offset(skip).limit(limit).all()
|
|
return [_run_to_dict(r) for r in rows], total
|
|
|
|
|
|
def get_dashboard_summary(db: Session, business_id: int) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
_get_or_create_settings(db, business_id)
|
|
emp_count = (
|
|
db.query(func.count(PayrollEmployee.id))
|
|
.filter(PayrollEmployee.business_id == business_id, PayrollEmployee.is_active == True) # noqa: E712
|
|
.scalar()
|
|
or 0
|
|
)
|
|
item_count = (
|
|
db.query(func.count(PayrollItemDefinition.id))
|
|
.filter(
|
|
PayrollItemDefinition.business_id == business_id,
|
|
PayrollItemDefinition.is_active == True, # noqa: E712
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
run_count = (
|
|
db.query(func.count(PayrollRun.id)).filter(PayrollRun.business_id == business_id).scalar() or 0
|
|
)
|
|
draft_runs = (
|
|
db.query(func.count(PayrollRun.id))
|
|
.filter(PayrollRun.business_id == business_id, PayrollRun.status == "draft")
|
|
.scalar()
|
|
or 0
|
|
)
|
|
pending_approvals = (
|
|
db.query(func.count(PayrollRun.id))
|
|
.filter(PayrollRun.business_id == business_id, PayrollRun.status == "pending_approval")
|
|
.scalar()
|
|
or 0
|
|
)
|
|
db.commit()
|
|
return {
|
|
"active_employees": int(emp_count),
|
|
"active_items": int(item_count),
|
|
"total_runs": int(run_count),
|
|
"draft_runs": int(draft_runs),
|
|
"pending_approvals": int(pending_approvals),
|
|
}
|
|
|
|
|
|
# ─── محاسبات و کدگذاری اجرای حقوق ─────────────────────────────────────────
|
|
|
|
|
|
def _round_amount(amount: Decimal, round_to: int) -> Decimal:
|
|
if round_to <= 0:
|
|
return amount.quantize(Decimal("1"))
|
|
step = Decimal(10) ** (-int(round_to))
|
|
return amount.quantize(step)
|
|
|
|
|
|
def _generate_run_code(db: Session, business_id: int, settings: PayrollSettings) -> str:
|
|
import secrets
|
|
|
|
prefix = (settings.document_code_prefix or "PAY").strip() or "PAY"
|
|
fmt = settings.document_code_format or "sequential"
|
|
if fmt == "random":
|
|
for _ in range(20):
|
|
code = f"{prefix}-{secrets.token_hex(4).upper()}"
|
|
exists = (
|
|
db.query(PayrollRun.id)
|
|
.filter(PayrollRun.business_id == business_id, PayrollRun.code == code)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
return code
|
|
raise ApiError("CODE_GENERATION_FAILED", "تولید کد یکتا ناموفق بود.", http_status=500)
|
|
year = datetime.utcnow().year
|
|
count = (
|
|
db.query(func.count(PayrollRun.id)).filter(PayrollRun.business_id == business_id).scalar() or 0
|
|
)
|
|
return f"{prefix}-{year}-{int(count) + 1:04d}"
|
|
|
|
|
|
def _get_active_items_map(db: Session, business_id: int) -> Dict[int, PayrollItemDefinition]:
|
|
rows = (
|
|
db.query(PayrollItemDefinition)
|
|
.filter(
|
|
PayrollItemDefinition.business_id == business_id,
|
|
PayrollItemDefinition.is_active == True, # noqa: E712
|
|
)
|
|
.order_by(PayrollItemDefinition.sort_order, PayrollItemDefinition.id)
|
|
.all()
|
|
)
|
|
return {r.id: r for r in rows}
|
|
|
|
|
|
def _resolve_item_amount(
|
|
item_def: PayrollItemDefinition,
|
|
amount_override: Any,
|
|
base_salary: Decimal,
|
|
gross_earnings: Decimal,
|
|
) -> Tuple[Decimal, bool]:
|
|
"""برگرداند (مبلغ، محاسبهشده؟)."""
|
|
if amount_override is not None and amount_override != "":
|
|
return _decimal(amount_override), False
|
|
calc = item_def.calculation_type or "manual"
|
|
if calc == "fixed" and item_def.default_amount is not None:
|
|
return item_def.default_amount, True
|
|
if calc == "percent_of_base" and item_def.percent_value is not None:
|
|
return (base_salary * item_def.percent_value / Decimal("100")), True
|
|
if calc == "percent_of_gross" and item_def.percent_value is not None:
|
|
return (gross_earnings * item_def.percent_value / Decimal("100")), True
|
|
if calc == "statutory" or item_def.code in _STATUTORY_ITEM_CODES:
|
|
return Decimal("0"), False
|
|
return Decimal("0"), False
|
|
|
|
|
|
def _compute_amounts_from_items(
|
|
items: List[Tuple[PayrollItemDefinition, Decimal]],
|
|
settings: PayrollSettings,
|
|
) -> Tuple[Decimal, Decimal, Decimal, Decimal]:
|
|
gross = Decimal("0")
|
|
deductions = Decimal("0")
|
|
employer = Decimal("0")
|
|
rt = int(settings.round_amounts_to or 0)
|
|
|
|
for item_def, amount in items:
|
|
amt = _round_amount(_decimal(amount), rt)
|
|
kind = item_def.item_kind
|
|
if kind == "earning" and item_def.affects_gross:
|
|
gross += amt
|
|
elif kind == "deduction":
|
|
deductions += amt
|
|
elif kind == "employer_cost":
|
|
employer += amt
|
|
|
|
gross = _round_amount(gross, rt)
|
|
deductions = _round_amount(deductions, rt)
|
|
employer = _round_amount(employer, rt)
|
|
net = _round_amount(gross - deductions, rt)
|
|
return gross, deductions, net, employer
|
|
|
|
|
|
def _build_default_line_items(
|
|
employee: PayrollEmployee,
|
|
items_map: Dict[int, PayrollItemDefinition],
|
|
explicit_items: Optional[List[Dict[str, Any]]] = None,
|
|
settings: Optional[PayrollSettings] = None,
|
|
) -> List[Tuple[PayrollItemDefinition, Decimal, bool]]:
|
|
base_salary = _decimal(employee.base_salary) if employee.base_salary is not None else Decimal("0")
|
|
explicit_by_id: Dict[int, Any] = {}
|
|
if explicit_items:
|
|
for row in explicit_items:
|
|
iid = row.get("item_definition_id")
|
|
if iid is not None:
|
|
explicit_by_id[int(iid)] = row.get("amount")
|
|
|
|
# پاس اول: درآمدها برای محاسبه percent_of_gross
|
|
gross_pass = Decimal("0")
|
|
pending: List[Tuple[PayrollItemDefinition, Decimal, bool]] = []
|
|
for item_def in items_map.values():
|
|
if item_def.item_kind != "earning" or not item_def.affects_gross:
|
|
continue
|
|
amt, computed = _resolve_item_amount(
|
|
item_def, explicit_by_id.get(item_def.id), base_salary, gross_pass
|
|
)
|
|
if item_def.code == "base_salary" and amt == 0 and base_salary > 0:
|
|
amt = base_salary
|
|
computed = True
|
|
pending.append((item_def, amt, computed))
|
|
gross_pass += amt
|
|
|
|
# پاس دوم: سایر آیتمها
|
|
for item_def in items_map.values():
|
|
if item_def.item_kind == "earning" and item_def.affects_gross:
|
|
continue
|
|
amt, computed = _resolve_item_amount(
|
|
item_def, explicit_by_id.get(item_def.id), base_salary, gross_pass
|
|
)
|
|
pending.append((item_def, amt, computed))
|
|
|
|
if settings is not None:
|
|
from app.services.payroll_statutory import apply_statutory_rules
|
|
|
|
pending = apply_statutory_rules(pending, explicit_by_id, settings)
|
|
return pending
|
|
|
|
|
|
def _line_item_to_dict(
|
|
line_item: PayrollRunLineItem, item_def: Optional[PayrollItemDefinition] = None
|
|
) -> Dict[str, Any]:
|
|
data = {
|
|
"id": line_item.id,
|
|
"item_definition_id": line_item.item_definition_id,
|
|
"amount": float(line_item.amount),
|
|
"quantity": float(line_item.quantity) if line_item.quantity is not None else None,
|
|
"is_computed": bool(line_item.is_computed),
|
|
"notes": line_item.notes,
|
|
}
|
|
if item_def:
|
|
data["item_code"] = item_def.code
|
|
data["item_name"] = item_def.name
|
|
data["item_kind"] = item_def.item_kind
|
|
return data
|
|
|
|
|
|
def _line_to_dict(
|
|
line: PayrollRunLine,
|
|
employee: Optional[PayrollEmployee] = None,
|
|
person: Optional[Person] = None,
|
|
items: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, Any]:
|
|
data: Dict[str, Any] = {
|
|
"id": line.id,
|
|
"run_id": line.run_id,
|
|
"employee_id": line.employee_id,
|
|
"person_id": line.person_id,
|
|
"line_number": line.line_number,
|
|
"notes": line.notes,
|
|
"gross_amount": float(line.gross_amount),
|
|
"deduction_amount": float(line.deduction_amount),
|
|
"net_amount": float(line.net_amount),
|
|
"employer_cost_amount": float(line.employer_cost_amount),
|
|
}
|
|
if employee:
|
|
data["employee_code"] = employee.employee_code
|
|
data["job_title"] = employee.job_title
|
|
if person:
|
|
data["person_name"] = _person_display_name(person)
|
|
if items is not None:
|
|
data["items"] = items
|
|
return data
|
|
|
|
|
|
def _document_link_to_dict(link: PayrollDocumentLink, document: Optional[Document] = None) -> Dict[str, Any]:
|
|
data: Dict[str, Any] = {
|
|
"id": link.id,
|
|
"run_id": link.run_id,
|
|
"document_id": link.document_id,
|
|
"link_type": link.link_type,
|
|
"created_at": link.created_at,
|
|
}
|
|
if document:
|
|
data["document_code"] = document.code
|
|
data["document_date"] = document.document_date
|
|
return data
|
|
|
|
|
|
def _load_document_links(db: Session, run_id: int) -> List[Dict[str, Any]]:
|
|
rows = (
|
|
db.query(PayrollDocumentLink, Document)
|
|
.join(Document, Document.id == PayrollDocumentLink.document_id)
|
|
.filter(PayrollDocumentLink.run_id == run_id)
|
|
.order_by(PayrollDocumentLink.id)
|
|
.all()
|
|
)
|
|
return [_document_link_to_dict(link, doc) for link, doc in rows]
|
|
|
|
|
|
def _run_to_detail_dict(
|
|
run: PayrollRun,
|
|
period: Optional[PayrollPeriod] = None,
|
|
lines: Optional[List[Dict[str, Any]]] = None,
|
|
document_links: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, Any]:
|
|
data = _run_to_dict(run)
|
|
if period:
|
|
data["period"] = _period_to_dict(period)
|
|
if lines is not None:
|
|
data["lines"] = lines
|
|
if document_links is not None:
|
|
data["document_links"] = document_links
|
|
return data
|
|
|
|
|
|
def _ensure_run_editable(run: PayrollRun) -> None:
|
|
if run.status not in ("draft",):
|
|
raise ApiError(
|
|
"RUN_NOT_EDITABLE",
|
|
f"اجرای حقوق در وضعیت {run.status} قابل ویرایش نیست.",
|
|
http_status=409,
|
|
)
|
|
|
|
|
|
def _get_run_row(db: Session, business_id: int, run_id: int) -> PayrollRun:
|
|
run = (
|
|
db.query(PayrollRun)
|
|
.filter(PayrollRun.business_id == business_id, PayrollRun.id == run_id)
|
|
.first()
|
|
)
|
|
if not run:
|
|
raise ApiError("NOT_FOUND", "اجرای حقوق یافت نشد.", http_status=404)
|
|
return run
|
|
|
|
|
|
def _persist_run_lines(
|
|
db: Session,
|
|
business_id: int,
|
|
run: PayrollRun,
|
|
lines_payload: List[Dict[str, Any]],
|
|
settings: PayrollSettings,
|
|
) -> Tuple[Decimal, Decimal, Decimal, Decimal]:
|
|
items_map = _get_active_items_map(db, business_id)
|
|
if not items_map:
|
|
raise ApiError("NO_PAYROLL_ITEMS", "هیچ آیتم حقوق فعالی تعریف نشده است.", http_status=400)
|
|
|
|
# حذف ردیفهای قبلی
|
|
db.query(PayrollRunLine).filter(PayrollRunLine.run_id == run.id).delete(synchronize_session=False)
|
|
db.flush()
|
|
|
|
total_gross = Decimal("0")
|
|
total_deduction = Decimal("0")
|
|
total_net = Decimal("0")
|
|
total_employer = Decimal("0")
|
|
|
|
for idx, line_payload in enumerate(lines_payload, start=1):
|
|
employee_id = line_payload.get("employee_id")
|
|
if employee_id is None:
|
|
raise ApiError("EMPLOYEE_REQUIRED", "employee_id در هر ردیف الزامی است.", http_status=400)
|
|
employee = (
|
|
db.query(PayrollEmployee)
|
|
.filter(
|
|
PayrollEmployee.business_id == business_id,
|
|
PayrollEmployee.id == int(employee_id),
|
|
PayrollEmployee.is_active == True, # noqa: E712
|
|
)
|
|
.first()
|
|
)
|
|
if not employee:
|
|
raise ApiError("EMPLOYEE_NOT_FOUND", f"پرسنل {employee_id} یافت نشد یا غیرفعال است.", http_status=404)
|
|
|
|
explicit_items = line_payload.get("items")
|
|
built = _build_default_line_items(
|
|
employee,
|
|
items_map,
|
|
explicit_items if isinstance(explicit_items, list) else None,
|
|
settings=settings,
|
|
)
|
|
amount_pairs = [(d, a) for d, a, _ in built]
|
|
gross, deductions, net, employer = _compute_amounts_from_items(amount_pairs, settings)
|
|
|
|
if not settings.allow_negative_net and net < 0:
|
|
raise ApiError(
|
|
"NEGATIVE_NET_NOT_ALLOWED",
|
|
f"خالص پرداختی پرسنل {employee.employee_code} منفی است.",
|
|
http_status=400,
|
|
)
|
|
|
|
line = PayrollRunLine(
|
|
run_id=run.id,
|
|
employee_id=employee.id,
|
|
person_id=employee.person_id,
|
|
line_number=idx,
|
|
notes=line_payload.get("notes"),
|
|
gross_amount=gross,
|
|
deduction_amount=deductions,
|
|
net_amount=net,
|
|
employer_cost_amount=employer,
|
|
)
|
|
db.add(line)
|
|
db.flush()
|
|
|
|
for item_def, amount, is_computed in built:
|
|
if amount == 0 and item_def.calculation_type == "manual" and not (
|
|
isinstance(explicit_items, list)
|
|
and any(int(x.get("item_definition_id", -1)) == item_def.id for x in explicit_items)
|
|
):
|
|
continue
|
|
db.add(
|
|
PayrollRunLineItem(
|
|
run_line_id=line.id,
|
|
item_definition_id=item_def.id,
|
|
amount=amount,
|
|
is_computed=is_computed,
|
|
notes=None,
|
|
)
|
|
)
|
|
|
|
total_gross += gross
|
|
total_deduction += deductions
|
|
total_net += net
|
|
total_employer += employer
|
|
|
|
rt = int(settings.round_amounts_to or 0)
|
|
run.gross_total = _round_amount(total_gross, rt)
|
|
run.deduction_total = _round_amount(total_deduction, rt)
|
|
run.net_total = _round_amount(total_net, rt)
|
|
run.employer_cost_total = _round_amount(total_employer, rt)
|
|
return run.gross_total, run.deduction_total, run.net_total, run.employer_cost_total
|
|
|
|
|
|
def get_run(db: Session, business_id: int, run_id: int) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
period = None
|
|
if run.period_id:
|
|
period = db.query(PayrollPeriod).filter(PayrollPeriod.id == run.period_id).first()
|
|
|
|
lines_q = (
|
|
db.query(PayrollRunLine, PayrollEmployee, Person)
|
|
.join(PayrollEmployee, PayrollEmployee.id == PayrollRunLine.employee_id)
|
|
.join(Person, Person.id == PayrollRunLine.person_id)
|
|
.filter(PayrollRunLine.run_id == run.id)
|
|
.order_by(PayrollRunLine.line_number)
|
|
.all()
|
|
)
|
|
|
|
line_ids = [ln.id for ln, _, _ in lines_q]
|
|
items_by_line: Dict[int, List[Dict[str, Any]]] = {lid: [] for lid in line_ids}
|
|
if line_ids:
|
|
item_rows = (
|
|
db.query(PayrollRunLineItem, PayrollItemDefinition)
|
|
.join(
|
|
PayrollItemDefinition,
|
|
PayrollItemDefinition.id == PayrollRunLineItem.item_definition_id,
|
|
)
|
|
.filter(PayrollRunLineItem.run_line_id.in_(line_ids))
|
|
.all()
|
|
)
|
|
for li, idef in item_rows:
|
|
items_by_line.setdefault(li.run_line_id, []).append(_line_item_to_dict(li, idef))
|
|
|
|
lines_out = [
|
|
_line_to_dict(ln, emp, person, items_by_line.get(ln.id, []))
|
|
for ln, emp, person in lines_q
|
|
]
|
|
doc_links = _load_document_links(db, run.id)
|
|
return _run_to_detail_dict(run, period, lines_out, doc_links)
|
|
|
|
|
|
def create_run(
|
|
db: Session,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
if not settings.enabled:
|
|
raise ApiError("PAYROLL_DISABLED", "ماژول حقوق غیرفعال است.", http_status=400)
|
|
|
|
period_id = payload.get("period_id")
|
|
period = None
|
|
if period_id is not None:
|
|
period = (
|
|
db.query(PayrollPeriod)
|
|
.filter(PayrollPeriod.business_id == business_id, PayrollPeriod.id == int(period_id))
|
|
.first()
|
|
)
|
|
if not period:
|
|
raise ApiError("PERIOD_NOT_FOUND", "دوره حقوق یافت نشد.", http_status=404)
|
|
if period.status == "closed":
|
|
raise ApiError("PERIOD_CLOSED", "دوره بسته شده و قابل ثبت نیست.", http_status=400)
|
|
|
|
run_date_raw = payload.get("run_date")
|
|
run_date = date.fromisoformat(run_date_raw) if run_date_raw else date.today()
|
|
title = str(payload.get("title") or "").strip()
|
|
if not title and period:
|
|
title = period.title
|
|
if not title:
|
|
title = f"حقوق {run_date.isoformat()}"
|
|
|
|
code = str(payload.get("code") or "").strip() or _generate_run_code(db, business_id, settings)
|
|
|
|
run = PayrollRun(
|
|
business_id=business_id,
|
|
period_id=int(period_id) if period_id is not None else None,
|
|
code=code,
|
|
title=title,
|
|
run_date=run_date,
|
|
description=payload.get("description"),
|
|
status="draft",
|
|
created_by_user_id=user_id,
|
|
)
|
|
db.add(run)
|
|
db.flush()
|
|
|
|
lines_payload = payload.get("lines")
|
|
if not lines_payload:
|
|
employee_ids = payload.get("employee_ids")
|
|
q = db.query(PayrollEmployee).filter(
|
|
PayrollEmployee.business_id == business_id,
|
|
PayrollEmployee.is_active == True, # noqa: E712
|
|
)
|
|
if employee_ids:
|
|
q = q.filter(PayrollEmployee.id.in_([int(x) for x in employee_ids]))
|
|
employees = q.order_by(PayrollEmployee.employee_code).all()
|
|
if not employees:
|
|
raise ApiError("NO_EMPLOYEES", "هیچ پرسنل فعالی برای ثبت حقوق یافت نشد.", http_status=400)
|
|
lines_payload = [{"employee_id": e.id} for e in employees]
|
|
|
|
if not isinstance(lines_payload, list) or not lines_payload:
|
|
raise ApiError("LINES_REQUIRED", "حداقل یک ردیف پرسنل الزامی است.", http_status=400)
|
|
|
|
_persist_run_lines(db, business_id, run, lines_payload, settings)
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "create", user_id, None, _run_to_dict(run))
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def update_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
_ensure_run_editable(run)
|
|
old = _run_to_dict(run)
|
|
|
|
for field in ("title", "description"):
|
|
if field in payload:
|
|
setattr(run, field, payload[field])
|
|
if "run_date" in payload and payload["run_date"]:
|
|
run.run_date = date.fromisoformat(payload["run_date"])
|
|
if "period_id" in payload:
|
|
pid = payload["period_id"]
|
|
if pid is not None:
|
|
period = (
|
|
db.query(PayrollPeriod)
|
|
.filter(PayrollPeriod.business_id == business_id, PayrollPeriod.id == int(pid))
|
|
.first()
|
|
)
|
|
if not period:
|
|
raise ApiError("PERIOD_NOT_FOUND", "دوره حقوق یافت نشد.", http_status=404)
|
|
run.period_id = int(pid) if pid is not None else None
|
|
|
|
if "lines" in payload:
|
|
lines_payload = payload["lines"]
|
|
if not isinstance(lines_payload, list) or not lines_payload:
|
|
raise ApiError("LINES_REQUIRED", "حداقل یک ردیف پرسنل الزامی است.", http_status=400)
|
|
_persist_run_lines(db, business_id, run, lines_payload, settings)
|
|
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "update", user_id, old, _run_to_dict(run))
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def delete_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> None:
|
|
_ensure_plugin(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
_ensure_run_editable(run)
|
|
old = _run_to_dict(run)
|
|
db.delete(run)
|
|
_audit(db, business_id, "payroll_run", run_id, "delete", user_id, old, None)
|
|
db.commit()
|
|
|
|
|
|
def finalize_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
if run.status != "draft":
|
|
raise ApiError("INVALID_STATUS", "فقط پیشنویس قابل قطعیسازی است.", http_status=400)
|
|
|
|
line_count = db.query(func.count(PayrollRunLine.id)).filter(PayrollRunLine.run_id == run.id).scalar() or 0
|
|
if line_count == 0:
|
|
raise ApiError("NO_LINES", "سند حقوق بدون ردیف پرسنل قابل قطعیسازی نیست.", http_status=400)
|
|
|
|
old = _run_to_dict(run)
|
|
if settings.require_approval:
|
|
run.status = "pending_approval"
|
|
else:
|
|
run.status = "finalized"
|
|
run.finalized_at = datetime.utcnow()
|
|
run.finalized_by_user_id = user_id
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "finalize", user_id, old, _run_to_dict(run))
|
|
db.flush()
|
|
|
|
if run.status == "finalized" and settings.auto_post_on_finalize and user_id is not None:
|
|
try:
|
|
post_run_to_accounting(db, business_id, run.id, user_id=user_id, auto=True)
|
|
except ApiError as exc:
|
|
logger.warning(
|
|
"payroll_auto_post_failed",
|
|
extra={"run_id": run.id, "business_id": business_id, "error": exc.message},
|
|
)
|
|
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def cancel_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
if run.status not in ("draft", "pending_approval"):
|
|
raise ApiError("INVALID_STATUS", "این اجرا در وضعیت فعلی قابل لغو نیست.", http_status=400)
|
|
if _load_document_links(db, run.id):
|
|
raise ApiError("RUN_HAS_ACCOUNTING", "سند دارای پیوند حسابداری است و قابل لغو نیست.", http_status=409)
|
|
old = _run_to_dict(run)
|
|
run.status = "cancelled"
|
|
run.cancelled_at = datetime.utcnow()
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "cancel", user_id, old, _run_to_dict(run))
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def update_employee(
|
|
db: Session,
|
|
business_id: int,
|
|
employee_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollEmployee)
|
|
.filter(PayrollEmployee.business_id == business_id, PayrollEmployee.id == employee_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "پرسنل یافت نشد.", http_status=404)
|
|
person = db.query(Person).filter(Person.id == row.person_id).first()
|
|
old = _employee_to_dict(row, person)
|
|
|
|
for field in ("job_title", "insurance_number", "tax_id", "bank_account_info", "extra_info", "is_active"):
|
|
if field in payload:
|
|
setattr(row, field, payload[field])
|
|
if "department_id" in payload:
|
|
did = payload["department_id"]
|
|
row.department_id = int(did) if did is not None else None
|
|
if "employment_type" in payload:
|
|
et = str(payload["employment_type"])
|
|
if et not in _EMPLOYMENT_TYPES:
|
|
raise ApiError("INVALID_EMPLOYMENT_TYPE", f"نوع استخدام نامعتبر: {et}", http_status=400)
|
|
row.employment_type = et
|
|
if "hire_date" in payload:
|
|
val = payload["hire_date"]
|
|
row.hire_date = date.fromisoformat(val) if val else None
|
|
if "termination_date" in payload:
|
|
val = payload["termination_date"]
|
|
row.termination_date = date.fromisoformat(val) if val else None
|
|
if "base_salary" in payload:
|
|
val = payload["base_salary"]
|
|
row.base_salary = _decimal(val) if val is not None else None
|
|
if "employee_code" in payload:
|
|
code = str(payload["employee_code"] or "").strip()
|
|
if code and code != row.employee_code:
|
|
dup = (
|
|
db.query(PayrollEmployee)
|
|
.filter(
|
|
PayrollEmployee.business_id == business_id,
|
|
PayrollEmployee.employee_code == code,
|
|
PayrollEmployee.id != row.id,
|
|
)
|
|
.first()
|
|
)
|
|
if dup:
|
|
raise ApiError("DUPLICATE_CODE", "کد پرسنلی تکراری است.", http_status=409)
|
|
row.employee_code = code
|
|
|
|
row.updated_at = datetime.utcnow()
|
|
data = _employee_to_dict(row, person)
|
|
_audit(db, business_id, "payroll_employee", row.id, "update", user_id, old, data)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return data
|
|
|
|
|
|
def update_department(
|
|
db: Session,
|
|
business_id: int,
|
|
department_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollDepartment)
|
|
.filter(PayrollDepartment.business_id == business_id, PayrollDepartment.id == department_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "بخش یافت نشد.", http_status=404)
|
|
old = _department_to_dict(row)
|
|
for field in ("name", "sort_order", "is_active"):
|
|
if field in payload:
|
|
setattr(row, field, payload[field])
|
|
if "parent_id" in payload:
|
|
pid = payload["parent_id"]
|
|
row.parent_id = int(pid) if pid is not None else None
|
|
row.updated_at = datetime.utcnow()
|
|
data = _department_to_dict(row)
|
|
_audit(db, business_id, "payroll_department", row.id, "update", user_id, old, data)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return data
|
|
|
|
|
|
# ─── فاز ۲: بستن دوره، کپی سند، فیش حقوق، گزارش ───────────────────────────
|
|
|
|
|
|
def close_period(
|
|
db: Session,
|
|
business_id: int,
|
|
period_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
row = (
|
|
db.query(PayrollPeriod)
|
|
.filter(PayrollPeriod.business_id == business_id, PayrollPeriod.id == period_id)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ApiError("NOT_FOUND", "دوره حقوق یافت نشد.", http_status=404)
|
|
if row.status == "closed":
|
|
raise ApiError("PERIOD_ALREADY_CLOSED", "این دوره قبلاً بسته شده است.", http_status=409)
|
|
|
|
draft_count = (
|
|
db.query(func.count(PayrollRun.id))
|
|
.filter(
|
|
PayrollRun.business_id == business_id,
|
|
PayrollRun.period_id == period_id,
|
|
PayrollRun.status == "draft",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
if draft_count > 0:
|
|
raise ApiError(
|
|
"DRAFT_RUNS_EXIST",
|
|
"قبل از بستن دوره، پیشنویسهای حقوق این دوره را قطعی یا لغو کنید.",
|
|
http_status=400,
|
|
)
|
|
|
|
old = _period_to_dict(row)
|
|
row.status = "closed"
|
|
row.closed_at = datetime.utcnow()
|
|
row.closed_by_user_id = user_id
|
|
_audit(db, business_id, "payroll_period", row.id, "close", user_id, old, _period_to_dict(row))
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _period_to_dict(row)
|
|
|
|
|
|
def copy_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
payload: Dict[str, Any],
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
source = _get_run_row(db, business_id, run_id)
|
|
|
|
target_period_id = payload.get("period_id", source.period_id)
|
|
period = None
|
|
if target_period_id is not None:
|
|
period = (
|
|
db.query(PayrollPeriod)
|
|
.filter(PayrollPeriod.business_id == business_id, PayrollPeriod.id == int(target_period_id))
|
|
.first()
|
|
)
|
|
if not period:
|
|
raise ApiError("PERIOD_NOT_FOUND", "دوره حقوق یافت نشد.", http_status=404)
|
|
if period.status == "closed":
|
|
raise ApiError("PERIOD_CLOSED", "دوره بسته شده و قابل ثبت نیست.", http_status=400)
|
|
|
|
source_lines = (
|
|
db.query(PayrollRunLine)
|
|
.filter(PayrollRunLine.run_id == source.id)
|
|
.order_by(PayrollRunLine.line_number)
|
|
.all()
|
|
)
|
|
if not source_lines:
|
|
raise ApiError("NO_LINES", "سند مبدأ بدون ردیف پرسنل است.", http_status=400)
|
|
|
|
line_ids = [ln.id for ln in source_lines]
|
|
item_rows = (
|
|
db.query(PayrollRunLineItem)
|
|
.filter(PayrollRunLineItem.run_line_id.in_(line_ids))
|
|
.all()
|
|
)
|
|
items_by_line: Dict[int, List[Dict[str, Any]]] = {}
|
|
for li in item_rows:
|
|
items_by_line.setdefault(li.run_line_id, []).append(
|
|
{"item_definition_id": li.item_definition_id, "amount": float(li.amount)}
|
|
)
|
|
|
|
lines_payload = [
|
|
{
|
|
"employee_id": ln.employee_id,
|
|
"notes": ln.notes,
|
|
"items": items_by_line.get(ln.id, []),
|
|
}
|
|
for ln in source_lines
|
|
]
|
|
|
|
title = str(payload.get("title") or "").strip()
|
|
if not title:
|
|
title = f"کپی {source.title or source.code}"
|
|
run_date_raw = payload.get("run_date")
|
|
run_date = date.fromisoformat(run_date_raw) if run_date_raw else date.today()
|
|
|
|
new_run = PayrollRun(
|
|
business_id=business_id,
|
|
period_id=int(target_period_id) if target_period_id is not None else None,
|
|
code=_generate_run_code(db, business_id, settings),
|
|
title=title,
|
|
run_date=run_date,
|
|
description=payload.get("description") or source.description,
|
|
status="draft",
|
|
created_by_user_id=user_id,
|
|
)
|
|
db.add(new_run)
|
|
db.flush()
|
|
_persist_run_lines(db, business_id, new_run, lines_payload, settings)
|
|
new_run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", new_run.id, "copy", user_id, None, _run_to_dict(new_run))
|
|
db.commit()
|
|
return get_run(db, business_id, new_run.id)
|
|
|
|
|
|
def build_payslip_render_context(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
*,
|
|
line_id: Optional[int] = None,
|
|
calendar_type: CalendarType = "jalali",
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
run_detail = get_run(db, business_id, run_id)
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
business_name = business.name if business else ""
|
|
|
|
lines = run_detail.get("lines") or []
|
|
if line_id is not None:
|
|
lines = [ln for ln in lines if ln.get("id") == int(line_id)]
|
|
if not lines:
|
|
raise ApiError("LINE_NOT_FOUND", "ردیف پرسنل در این سند یافت نشد.", http_status=404)
|
|
|
|
payslips: List[Dict[str, Any]] = []
|
|
for ln in lines:
|
|
earnings: List[Dict[str, Any]] = []
|
|
deductions: List[Dict[str, Any]] = []
|
|
employer_costs: List[Dict[str, Any]] = []
|
|
for it in ln.get("items") or []:
|
|
kind = it.get("item_kind")
|
|
entry = {
|
|
"name": it.get("item_name"),
|
|
"code": it.get("item_code"),
|
|
"amount": it.get("amount"),
|
|
}
|
|
if kind == "earning":
|
|
earnings.append(entry)
|
|
elif kind == "deduction":
|
|
deductions.append(entry)
|
|
elif kind == "employer_cost":
|
|
employer_costs.append(entry)
|
|
|
|
payslips.append(
|
|
{
|
|
"employee_code": ln.get("employee_code"),
|
|
"person_name": ln.get("person_name"),
|
|
"job_title": ln.get("job_title"),
|
|
"earnings": earnings,
|
|
"deductions": deductions,
|
|
"employer_costs": employer_costs,
|
|
"gross_amount": ln.get("gross_amount"),
|
|
"deduction_amount": ln.get("deduction_amount"),
|
|
"net_amount": ln.get("net_amount"),
|
|
"employer_cost_amount": ln.get("employer_cost_amount"),
|
|
}
|
|
)
|
|
|
|
period = run_detail.get("period") or {}
|
|
period_display = dict(period)
|
|
if period:
|
|
for key in ("start_date", "end_date"):
|
|
if period.get(key) is not None:
|
|
period_display[key] = _format_payroll_date_for_display(period.get(key), calendar_type)
|
|
return {
|
|
"business_name": business_name,
|
|
"run": {
|
|
"code": run_detail.get("code"),
|
|
"title": run_detail.get("title"),
|
|
"run_date": _format_payroll_date_for_display(run_detail.get("run_date"), calendar_type),
|
|
"status": run_detail.get("status"),
|
|
"gross_total": run_detail.get("gross_total"),
|
|
"deduction_total": run_detail.get("deduction_total"),
|
|
"net_total": run_detail.get("net_total"),
|
|
},
|
|
"period": period_display,
|
|
"payslips": payslips,
|
|
}
|
|
|
|
|
|
def get_run_department_summary(
|
|
db: Session,
|
|
business_id: int,
|
|
*,
|
|
period_id: Optional[int] = None,
|
|
run_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
if period_id is None and run_id is None:
|
|
raise ApiError("FILTER_REQUIRED", "period_id یا run_id الزامی است.", http_status=400)
|
|
|
|
q = (
|
|
db.query(
|
|
PayrollDepartment.id,
|
|
PayrollDepartment.name,
|
|
PayrollDepartment.code,
|
|
func.count(PayrollRunLine.id),
|
|
func.coalesce(func.sum(PayrollRunLine.gross_amount), 0),
|
|
func.coalesce(func.sum(PayrollRunLine.deduction_amount), 0),
|
|
func.coalesce(func.sum(PayrollRunLine.net_amount), 0),
|
|
)
|
|
.select_from(PayrollRunLine)
|
|
.join(PayrollRun, PayrollRun.id == PayrollRunLine.run_id)
|
|
.join(PayrollEmployee, PayrollEmployee.id == PayrollRunLine.employee_id)
|
|
.outerjoin(PayrollDepartment, PayrollDepartment.id == PayrollEmployee.department_id)
|
|
.filter(PayrollRun.business_id == business_id)
|
|
)
|
|
if run_id is not None:
|
|
q = q.filter(PayrollRun.id == int(run_id))
|
|
if period_id is not None:
|
|
q = q.filter(PayrollRun.period_id == int(period_id))
|
|
q = q.filter(PayrollRun.status.notin_(["cancelled", "draft"]))
|
|
|
|
rows = q.group_by(PayrollDepartment.id, PayrollDepartment.name, PayrollDepartment.code).all()
|
|
items = []
|
|
total_gross = Decimal("0")
|
|
total_deduction = Decimal("0")
|
|
total_net = Decimal("0")
|
|
for dept_id, name, code, cnt, gross, ded, net in rows:
|
|
g = _decimal(gross)
|
|
d = _decimal(ded)
|
|
n = _decimal(net)
|
|
total_gross += g
|
|
total_deduction += d
|
|
total_net += n
|
|
items.append(
|
|
{
|
|
"department_id": dept_id,
|
|
"department_name": name or ("بدون بخش" if dept_id is None else name),
|
|
"department_code": code,
|
|
"employee_count": int(cnt),
|
|
"gross_total": float(g),
|
|
"deduction_total": float(d),
|
|
"net_total": float(n),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"period_id": period_id,
|
|
"run_id": run_id,
|
|
"items": items,
|
|
"totals": {
|
|
"gross_total": float(total_gross),
|
|
"deduction_total": float(total_deduction),
|
|
"net_total": float(total_net),
|
|
},
|
|
}
|
|
|
|
|
|
# ─── فاز ۳: تأیید و ثبت حسابداری ───────────────────────────────────────────
|
|
|
|
|
|
def approve_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
if run.status != "pending_approval":
|
|
raise ApiError("INVALID_STATUS", "فقط اجراهای در انتظار تأیید قابل تأیید هستند.", http_status=400)
|
|
|
|
old = _run_to_dict(run)
|
|
run.status = "finalized"
|
|
run.approved_at = datetime.utcnow()
|
|
run.approved_by_user_id = user_id
|
|
run.finalized_at = datetime.utcnow()
|
|
run.finalized_by_user_id = user_id
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "approve", user_id, old, _run_to_dict(run))
|
|
db.flush()
|
|
|
|
if settings.auto_post_on_finalize and user_id is not None:
|
|
try:
|
|
post_run_to_accounting(db, business_id, run.id, user_id=user_id, auto=True)
|
|
except ApiError as exc:
|
|
logger.warning(
|
|
"payroll_auto_post_failed",
|
|
extra={"run_id": run.id, "business_id": business_id, "error": exc.message},
|
|
)
|
|
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def reject_run(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: Optional[int] = None,
|
|
*,
|
|
reason: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
if run.status != "pending_approval":
|
|
raise ApiError("INVALID_STATUS", "فقط اجراهای در انتظار تأیید قابل رد هستند.", http_status=400)
|
|
|
|
old = _run_to_dict(run)
|
|
extra = dict(run.extra_info or {})
|
|
extra["rejection"] = {
|
|
"reason": (reason or "").strip() or None,
|
|
"rejected_at": datetime.utcnow().isoformat(),
|
|
"rejected_by_user_id": user_id,
|
|
}
|
|
run.extra_info = extra
|
|
run.status = "draft"
|
|
run.approved_at = None
|
|
run.approved_by_user_id = None
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "reject", user_id, old, _run_to_dict(run))
|
|
db.commit()
|
|
return get_run(db, business_id, run.id)
|
|
|
|
|
|
def post_run_to_accounting(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: int,
|
|
*,
|
|
auto: bool = False,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
|
|
if run.status not in ("finalized", "posted"):
|
|
raise ApiError(
|
|
"INVALID_STATUS",
|
|
"فقط اجرای قطعیشده قابل ثبت سند حسابداری است.",
|
|
http_status=400,
|
|
)
|
|
|
|
from app.services.payroll_accounting import post_payroll_accrual_document
|
|
|
|
old = _run_to_dict(run)
|
|
result = post_payroll_accrual_document(db, business_id, user_id, run, settings)
|
|
run.status = "posted"
|
|
run.posted_at = datetime.utcnow()
|
|
run.posted_by_user_id = user_id
|
|
run.updated_at = datetime.utcnow()
|
|
action = "auto_post" if auto else "post"
|
|
_audit(db, business_id, "payroll_run", run.id, action, user_id, old, _run_to_dict(run))
|
|
|
|
if not auto:
|
|
db.commit()
|
|
return {**get_run(db, business_id, run.id), "accounting": result}
|
|
|
|
|
|
def post_run_payment(
|
|
db: Session,
|
|
business_id: int,
|
|
run_id: int,
|
|
user_id: int,
|
|
payment_account_id: int,
|
|
) -> Dict[str, Any]:
|
|
_ensure_plugin(db, business_id)
|
|
settings = _get_or_create_settings(db, business_id)
|
|
run = _get_run_row(db, business_id, run_id)
|
|
|
|
if run.status not in ("posted",):
|
|
accrual = (
|
|
db.query(PayrollDocumentLink)
|
|
.filter(PayrollDocumentLink.run_id == run.id, PayrollDocumentLink.link_type == "accrual")
|
|
.first()
|
|
)
|
|
if not accrual:
|
|
raise ApiError("ACCRUAL_REQUIRED", "ابتدا سند تعهدی حقوق را ثبت کنید.", http_status=400)
|
|
|
|
from app.services.payroll_accounting import post_payroll_payment_document
|
|
|
|
old = _run_to_dict(run)
|
|
result = post_payroll_payment_document(
|
|
db, business_id, user_id, run, settings, int(payment_account_id)
|
|
)
|
|
run.updated_at = datetime.utcnow()
|
|
_audit(db, business_id, "payroll_run", run.id, "post_payment", user_id, old, _run_to_dict(run))
|
|
db.commit()
|
|
return {**get_run(db, business_id, run.id), "accounting": result}
|