"""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"'}, )