"""工作区任务与事实存取:与原有 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, TPlusWorkLine, TPlusWorkRun from app.schemas.tplus_workbench import WorkCreateIn 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 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): """可公开的固定失败编码;不得传入客户原文或驱动异常。"""