"""工作区任务与事实存取:与原有 worker 共用执行锁和持久化队列。""" import hashlib import json import logging from datetime import datetime from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import defer from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.core.tplus_lock import TPlusBusyError, sync_lock from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun from app.schemas.tplus_workbench import WorkCreateIn from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value from app.services.tplus.task_service import _source_database, new_batch_no RULE_VERSION = "2026-10-30.2" logger = logging.getLogger(__name__) def digest(data): """稳定指纹用于固定规则、事实和来源版本。""" return hashlib.sha256( json.dumps( data, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":") ).encode() ).hexdigest() def latest_facts(session, source): """每个事实键只读取最近版本,历史行保持可追溯。""" ids = ( select(func.max(TPlusFact.id)) .where(TPlusFact.source_database == source) .group_by(TPlusFact.kind, TPlusFact.fact_key) ) return { (f.kind, f.fact_key): f for f in session.scalars( select(TPlusFact).where(TPlusFact.id.in_(ids)).order_by(TPlusFact.id) ) } def save_fact(session, payload, actor_id): """与导入共用锁,乐观版本不匹配时拒绝覆盖。""" source = _source_database() try: with sync_lock(session): session.rollback() current = latest_facts(session, source).get((payload.kind, payload.fact_key)) if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"): raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本") if (current.version if current else 0) != payload.expected_version: raise BadRequestError("补充资料已经变化,请刷新后再保存") fact = TPlusFact( source_database=source, kind=payload.kind, fact_key=payload.fact_key, version=payload.expected_version + 1, data_json=payload.data, evidence=payload.evidence, requested_by=actor_id, ) session.add(fact) session.commit() return fact_out(fact) except (TPlusBusyError, IntegrityError): session.rollback() raise BadRequestError("资料或任务正在更新,请刷新后重试") from None def fact_out(fact): """仅工作区授权接口展示核算必需事实,不返回连接凭据。""" return { "id": fact.id, "kind": fact.kind, "fact_key": fact.fact_key, "version": fact.version, "data": fact.data_json, "evidence": fact.evidence, "requested_by": fact.requested_by, "create_time": fact.create_time, } def list_facts(session, query): source = _source_database() ids = ( select(func.max(TPlusFact.id)) .where(TPlusFact.source_database == source) .group_by(TPlusFact.kind, TPlusFact.fact_key) ) condition = [TPlusFact.id.in_(ids)] if query.kind: condition.append(TPlusFact.kind == query.kind) total = session.scalar(select(func.count()).select_from(TPlusFact).where(*condition)) rows = session.scalars( select(TPlusFact) .where(*condition) .order_by(TPlusFact.id) .offset(query.offset) .limit(query.page_size) ) return PageResult( records=[fact_out(f) for f in rows], total=total or 0, page_num=query.page_num, page_size=query.page_size, ) def queue_work(session, payload: WorkCreateIn, actor_id): """HTTP 只排队;同幂等键的不同意图必须被拒绝。""" values = payload.model_dump(mode="json", exclude={"request_key"}) source = _source_database() key = str(payload.request_key) found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) if found: if ( found.input_json != values or found.source_database != source or found.requested_by != actor_id ): raise BadRequestError("请求键已用于不同任务") return work_out(found) if payload.kind == "MASTER_APPLY": preview = get_run(session, payload.preview_no, source) if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): raise BadRequestError("必须选择已完成的基础导入预览") if payload.kind.startswith("BUSINESS_"): from app.models import User from app.services.payroll_access import ensure_payroll_writer ensure_payroll_writer(session.get(User, actor_id)) if payload.kind == "BUSINESS_APPLY": preview = get_run(session, payload.preview_no, source) if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): raise BadRequestError("必须选择已完成的正式业务导入预览") if payload.replay_no: previous = get_run(session, payload.replay_no, source) if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"): raise BadRequestError("只能重跑已完成的核算预演") run = TPlusWorkRun( run_no=new_batch_no(), request_key=key, kind=payload.kind, status="QUEUED", source_database=source, requested_by=actor_id, input_json=values, ) try: with session.begin_nested(): session.add(run) session.flush() session.commit() except IntegrityError: session.rollback() return queue_work(session, payload, actor_id) return work_out(run) def get_run(session, number, source=None): source = source or _source_database() run = session.scalar( select(TPlusWorkRun) .where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source) .options(defer(TPlusWorkRun.snapshot_json)) ) if run is None: raise NotFoundError("工作区运行不存在") return run def work_out(run): return { "run_no": run.run_no, "kind": run.kind, "status": run.status, "request_key": run.request_key, "input": run.input_json, "snapshot_hash": run.snapshot_hash, "summary": run.summary_json, "error_code": run.error_code, "create_time": run.create_time, "started_at": run.started_at, "finished_at": run.finished_at, "formal_payroll_written": False, } def list_work(session, query): conditions = [TPlusWorkRun.source_database == _source_database()] if query.kind: conditions.append(TPlusWorkRun.kind == query.kind) total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions)) rows = session.scalars( select(TPlusWorkRun) .options(defer(TPlusWorkRun.snapshot_json)) .where(*conditions) .order_by(TPlusWorkRun.id.desc()) .offset(query.offset) .limit(query.page_size) ) return PageResult( records=[work_out(r) for r in rows], total=total or 0, page_num=query.page_num, page_size=query.page_size, ) def list_lines(session, number, query): get_run(session, number) conditions = [TPlusWorkLine.run_no == number] if query.action: conditions.append(TPlusWorkLine.action == query.action) total = session.scalar(select(func.count()).select_from(TPlusWorkLine).where(*conditions)) rows = session.scalars( select(TPlusWorkLine) .where(*conditions) .order_by(TPlusWorkLine.line_no) .offset(query.offset) .limit(query.page_size) ) return PageResult( records=[ { "line_no": r.line_no, "entity_type": r.entity_type, "source_id": r.source_id, "action": r.action, "reason": r.reason, "data": r.data_json, } for r in rows ], total=total or 0, page_num=query.page_num, page_size=query.page_size, ) def fact_history(session, fact_id, query): """按事实身份分页查看修改历史,不允许跨账套读取。""" 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 PageResult( records=[fact_out(f) for f in rows], total=total or 0, page_num=query.page_num, page_size=query.page_size, ) def source_choices(session, query): """仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。""" from app.services.tplus.snapshots import source_statement try: statement = source_statement(session, _source_database(), query.entity) except 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 PageResult( records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size ) def report_csv(session, run_no): """导出全部预演明细,防止源单号被电子表格当作公式执行。""" import csv import io 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 rows() def execute_next(session): """调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。""" for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")): old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now() session.commit() run = session.scalar( select(TPlusWorkRun) .where(TPlusWorkRun.status == "QUEUED") .order_by(TPlusWorkRun.id) .limit(1) .with_for_update() ) if run is None: session.rollback() return None run.status, run.started_at = "RUNNING", datetime.now() session.commit() try: if run.source_database != _source_database(): raise WorkbenchError("SOURCE_CHANGED") if run.kind.startswith("BUSINESS_"): from app.services.tplus.business_import import execute_business execute_business(session, run) elif run.kind.startswith("MASTER_"): from app.services.tplus.master_import import execute_master execute_master(session, run) else: from app.services.tplus.calculation_preview import execute_preview execute_preview(session, run, customer_scope=True) run.finished_at = datetime.now() session.commit() except Exception as exc: session.rollback() run = get_run(session, run.run_no, run.source_database) run.status, run.finished_at = "FAILED", datetime.now() # 不让驱动异常、SQL 或原始数据进入响应。 run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR" logger.error( "工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__ ) session.commit() return run.run_no class WorkbenchError(ValueError): """可公开的固定失败编码;不得传入客户原文或驱动异常。"""