PayrollManagementSystem/backend/app/api/v1/system/tplus_workbench.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

224 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""T+ 基础导入和预演的管理员入口;HTTP 不执行长查询或核算。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from sqlalchemy import func, select
from starlette.responses import StreamingResponse
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import TPlusSyncRecord, TPlusWorkLine
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
from app.services.tplus import workbench
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.snapshots import source_statement
from app.services.tplus.task_service import _source_database
router = APIRouter()
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""有界查询每项补充资料的当前版本。"""
return ok(workbench.list_facts(session, query))
@router.post("/facts", response_model=ApiResponse[dict])
def save_fact(session: DbSession, admin: PayrollWriter, payload: FactIn):
"""追加有依据的人工事实;旧版本不覆盖。"""
return ok(workbench.save_fact(session, payload, admin.id))
@router.get("/facts/{fact_id}/history", response_model=ApiResponse[PageResult[dict]])
def fact_history(
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
from app.core.exceptions import NotFoundError
from app.models import TPlusFact
fact = session.get(TPlusFact, fact_id)
if fact is None or fact.source_database != _source_database():
raise NotFoundError("补充资料不存在")
conditions = [
TPlusFact.source_database == fact.source_database,
TPlusFact.kind == fact.kind,
TPlusFact.fact_key == fact.fact_key,
]
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*conditions))
rows = session.scalars(
select(TPlusFact)
.where(*conditions)
.order_by(TPlusFact.version.desc())
.offset(query.offset)
.limit(query.page_size)
)
return ok(
PageResult(
records=[workbench.fact_out(f) for f in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
)
@router.post("/work-runs", response_model=ApiResponse[dict])
def create_work(session: DbSession, admin: PayrollWriter, payload: WorkCreateIn):
"""创建导入预览、执行导入或核算预演任务。"""
return ok(workbench.queue_work(session, payload, admin.id))
@router.get("/work-runs", response_model=ApiResponse[PageResult[dict]])
def work_runs(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""分页查询任务,所有金额结果仅为预演。"""
return ok(workbench.list_work(session, query))
@router.get("/work-runs/{run_no}", response_model=ApiResponse[dict])
def work_detail(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""查询规则版本、输入指纹及结果。"""
return ok(workbench.work_out(workbench.get_run(session, run_no)))
@router.get("/work-runs/{run_no}/lines", response_model=ApiResponse[PageResult[dict]])
def work_lines(
session: DbSession, _: WorkbenchReader, run_no: RunNo, query: Annotated[WorkQuery, Query()]
):
"""分页查看新增、待补及分配明细,不公开原始快照 JSON。"""
return ok(workbench.list_lines(session, run_no, query))
@router.get("/source-records", response_model=ApiResponse[PageResult[dict]])
def source_choices(
session: DbSession,
_: WorkbenchReader,
query: Annotated[SourceChoiceQuery, Query()],
):
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
from app.core.exceptions import BadRequestError
try:
statement = source_statement(session, _source_database(), query.entity)
except workbench.WorkbenchError:
raise BadRequestError("请先完整读取相关源数据") from None
if query.search:
escaped = query.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
statement = statement.where(TPlusSyncRecord.source_no.like(f"%{escaped}%", escape="\\"))
total = session.scalar(select(func.count()).select_from(statement.subquery()))
rows = session.scalars(
statement.order_by(TPlusSyncRecord.source_id).offset(query.offset).limit(query.page_size)
)
records = [
{
"source_id": r.source_id,
"source_no": r.source_no,
"name": str(r.payload_json.get("name") or ""),
"parent_id": str(r.payload_json.get("source_parent_id") or ""),
"date": str(
r.payload_json.get("voucherdate") or r.payload_json.get("voucherDate") or ""
)[:10],
}
for r in rows
]
return ok(
PageResult(
records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size
)
)
@router.get("/work-runs/{run_no}/report")
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
import csv
import io
workbench.get_run(session, run_no)
def rows():
buffer = io.StringIO()
writer = csv.writer(buffer)
yield "\ufeff"
writer.writerow(
[
"运行编号",
"行号",
"类型",
"源ID或平账组",
"处理结果",
"源编号",
"名称",
"回款单号",
"关联凭证示例",
"发货单号",
"匹配金额",
"分配后该明细剩余应收",
"回款天数",
"指导成本分摊",
"折算利润",
"利润门槛(百分数)",
"计提比例",
"计提金额",
"归属工号",
"原因",
"说明",
]
)
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
statement = (
select(TPlusWorkLine)
.where(TPlusWorkLine.run_no == run_no)
.order_by(TPlusWorkLine.line_no)
)
for row in session.scalars(statement).yield_per(200):
data = row.data_json
values = data.get("values") or {}
fields = [
run_no,
row.line_no,
row.entity_type,
row.source_id,
ACTION_LABELS.get(row.action, row.action),
data.get("source_no", ""),
values.get("personnel_name")
or values.get("product_name")
or values.get("company_name")
or data.get("source_name")
or "",
*[
data.get(k, "")
for k in (
"receipt_no",
"accounting_voucher_code",
"shipment_no",
"amount",
"shipment_remaining_after",
"days",
"allocated_cost",
"converted_profit",
"profit_threshold",
"rate_percent",
"performance",
"personnel_id",
)
],
reason_label(row.reason)
or ("经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
"仅供核对,未写入正式工资",
]
writer.writerow([spreadsheet_value(v) for v in fields])
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
return StreamingResponse(
rows(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
)