补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
"""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"'},
|
||
)
|