PayrollManagementSystem/backend/app/api/v1/payroll.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

109 lines
3.4 KiB
Python

"""正式工资资料、FIFO核算和逐段明细入口。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
from app.core.response import PageQuery, ok
from app.schemas.payroll import AllocationAdjustmentIn, PayrollMonthIn, PayrollSetupIn
from app.services import payroll_calculation, payroll_inputs, payroll_months, settlement_service
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
@router.get("/months/{month}")
def month_state(
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
session: DbSession,
_: LoginUser,
):
from sqlalchemy import select
from app.core.month import validate_month
from app.models import MonthlySettlement, PayrollMonth
validate_month(month)
control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
existing = session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
return ok(
{
"month": month,
"allocation_locked": bool(existing or (control and control.generated_at)),
"maker_name": control.maker_name if control else None,
}
)
@router.get("/businesses/{business_id}/input")
def input_detail(business_id: int, session: DbSession, _: LoginUser):
record = payroll_calculation.latest_input(session, business_id)
return ok(
None
if record is None
else {
"id": record.id,
"version": record.version,
"source": record.source_database,
"fingerprint": record.fingerprint,
"data": record.data_json,
}
)
@router.post("/businesses/{business_id}/input")
def save_input(
business_id: int,
payload: PayrollSetupIn,
session: DbSession,
actor: PayrollWriter,
meta: RequestMetaDep,
):
return ok(payroll_inputs.setup(session, business_id, payload, actor, meta))
@router.post("/businesses/{business_id}/calculate")
def calculate(
business_id: int,
payload: PayrollMonthIn,
session: DbSession,
actor: PayrollWriter,
meta: RequestMetaDep,
):
return ok(
payroll_calculation.calculate(
session, business_id, payload.month, operator=actor, meta=meta, reason=payload.reason
)
)
@router.get("/ledger/{ledger_id}/allocations")
def allocations(
ledger_id: int, query: Annotated[PageQuery, Query()], session: DbSession, _: LoginUser
):
return ok(payroll_calculation.allocation_page(session, ledger_id, query))
@router.post("/allocation-adjustments")
def adjustment(
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
):
from app.core.exceptions import BadRequestError
from app.models import PerformanceLedger
from app.schemas.settlement import AdjustmentCreateIn
old = session.get(PerformanceLedger, payload.ledger_id)
if old is None or old.settlement_id is None:
raise BadRequestError("请选择已生成工资的原台账")
data = AdjustmentCreateIn(
settlement_month=payroll_months.current_month(),
personnel_id=payload.personnel_id,
direction=payload.direction,
amount=payload.amount,
reason=payload.reason,
source_ledger_id=old.id,
)
return ok(settlement_service.create_adjustment(session, data, operator=actor, meta=meta))