diff --git a/.gitignore b/.gitignore index 5b2642d..984cccd 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,9 @@ __MACOSX/ # AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库 .tmpdocx/ +# 本轮原开发环境损坏后的独立恢复运行环境。 +backend/.venv-tplus/ +backend/.venv-workbench/ + +# 本地浏览器验收自动生成的快照 +/.playwright-mcp/ diff --git a/README.md b/README.md index fbbbbbf..b5c8edf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 -> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 +> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。生产自动化默认关闭,尚未安装启用;真实金额与整月工资尚未验收,首组样本缺人工指导成本。审核能力保留但默认关闭,多原单更正仍单独跟踪。最新交付见 [开发记录](docs/同步运维自动化与样本核对开发记录_2026-10-30.md) 和 [Windows 操作说明](scripts/windows/README.md)。 ## 快速开始 @@ -70,7 +70,7 @@ pnpm dev # 后端测试与代码检查 cd backend uv run pytest tests/unit -q # 纯单元,无需容器,秒级 -uv run pytest -q # 全部(集成部分需容器在跑) +python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库 uv run ruff check . # 代码检查 uv run ruff format . # 代码格式化 @@ -134,3 +134,13 @@ python e2e/system_management.py # 系统管理 13 项 | `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 | | `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 | | `docs/` | 需求文档与工资算法方案 | + +## 正式工资闭环 + +- 在现有账号中明确绑定工资制作人:`PAYROLL_MAKER_USER_ID`。未配置时业务只读,不按管理员或岗位自动放行。真实账号绑定需由维护者核实,不在源码或示例中写入实际用户编号。 +- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。 +- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。 +- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。 +- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。测试运行器保留隔离库供复查,不删除原开发数据。 + +审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。 diff --git a/backend/.env.example b/backend/.env.example index 968a71f..6becda2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -46,3 +46,17 @@ T_PLUS_CONNECT_TIMEOUT=10 T_PLUS_QUERY_TIMEOUT=120 # 可选:留空时使用仓库根目录下的 db/queries/tplus/ T_PLUS_QUERY_DIR= + +# 指定实际工资制作人的现有用户 ID;留空时业务只读,不自动授予管理员 +# PAYROLL_MAKER_USER_ID= + +# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行 +PAYROLL_APPROVAL_ENABLED=false + +# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)===== +T_PLUS_AUTOMATION_ENABLED=false +# 0=周一,6=周日;时段均为北京时间 +T_PLUS_SCHEDULE_WEEKDAY=0 +T_PLUS_SCHEDULE_TIME=02:00 +# 托管启动器自动设置;发现标记后停止排队和领取新任务 +T_PLUS_STOP_FILE= diff --git a/backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py b/backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py new file mode 100644 index 0000000..198d8b5 --- /dev/null +++ b/backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py @@ -0,0 +1,27 @@ +"""V17 对齐 V16 已应用工作区约束的完整表名约定。""" + +from alembic import op + +revision: str = "07b8c9d0e1f2" +down_revision: str | None = "f6a708b9c0d1" +branch_labels = None +depends_on = None + +RENAMES = ( + ("tplus_source_heads", "uk_tplus_heads_source_entity_id", "uk_tplus_source_heads_source_entity_id"), + ("tplus_source_heads", "idx_tplus_heads_batch_entity", "idx_tplus_source_heads_batch_entity"), + ("tplus_source_links", "uk_tplus_links_source_entity_id", "uk_tplus_source_links_source_entity_id"), + ("tplus_source_links", "uk_tplus_links_entity_target", "uk_tplus_source_links_entity_target"), +) + + +def upgrade(): + """只重命名固定索引,不改变任何数据。""" + for table, old, new in RENAMES: + op.execute(f"ALTER TABLE {table} RENAME INDEX {old} TO {new}") + + +def downgrade(): + """恢复 V16 固定索引名。""" + for table, old, new in reversed(RENAMES): + op.execute(f"ALTER TABLE {table} RENAME INDEX {new} TO {old}") diff --git a/backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py b/backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py new file mode 100644 index 0000000..07016a3 --- /dev/null +++ b/backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py @@ -0,0 +1,31 @@ +"""正式工资输入、FIFO明细和月份生成锁。""" +from alembic import op + +revision: str = "18c9d0e1f2a3" +down_revision: str | None = "07b8c9d0e1f2" +branch_labels = None +depends_on = None + +def upgrade(): + op.execute('CREATE INDEX idx_performance_ledgers_receipt_id ON performance_ledgers (receipt_id)') + op.execute("ALTER TABLE performance_ledgers ADD COLUMN allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串'") + op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel') + op.execute('ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key)') + + op.execute("CREATE TABLE payroll_months (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n month VARCHAR(7) NOT NULL COMMENT '工资月份', \n maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', \n maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', \n generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_months PRIMARY KEY (id), \n CONSTRAINT uk_payroll_months_month UNIQUE (month)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci") + op.execute("CREATE TABLE payroll_inputs (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n version INTEGER NOT NULL COMMENT '输入版本', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', \n group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', \n fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', \n data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', \n requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), \n CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci") + op.execute('CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key)') + op.execute("CREATE TABLE payroll_allocations (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', \n business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', \n line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', \n input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', \n amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', \n data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), \n CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci") + op.execute('CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id)') + +def downgrade(): + bind = op.get_bind() + if bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_inputs").scalar() or bind.exec_driver_sql("SELECT COUNT(*) FROM payroll_allocations").scalar(): + raise RuntimeError("已有正式核算输入或分配记录,拒绝破坏性回退;请先备份并使用应用回退方案") + op.execute('ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel') + op.execute("ALTER TABLE performance_ledgers DROP COLUMN allocation_key") + op.execute("ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id)") + op.execute("DROP INDEX idx_performance_ledgers_receipt_id ON performance_ledgers") + op.drop_table('payroll_allocations') + op.drop_table('payroll_inputs') + op.drop_table('payroll_months') diff --git a/backend/alembic/versions/19d0e1f2a3b4_allocation_adjustment_attribution.py b/backend/alembic/versions/19d0e1f2a3b4_allocation_adjustment_attribution.py new file mode 100644 index 0000000..d20584d --- /dev/null +++ b/backend/alembic/versions/19d0e1f2a3b4_allocation_adjustment_attribution.py @@ -0,0 +1,18 @@ +"""分配调整的原台账与业务分管归属。""" +from alembic import op +revision: str = "19d0e1f2a3b4" +down_revision: str | None = "18c9d0e1f2a3" +branch_labels = None +depends_on = None + +def upgrade(): + op.execute("ALTER TABLE adjustment_orders ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额'") + op.execute("ALTER TABLE adjustment_orders ADD COLUMN source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID'") + op.execute("ALTER TABLE adjustment_orders ADD COLUMN attribution_json JSON NULL COMMENT '原台账业务与分管归属快照'") + +def downgrade(): + if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM adjustment_orders WHERE source_ledger_id IS NOT NULL").scalar(): + raise RuntimeError("已有分配调整依据,拒绝删除归属信息;请使用应用版本回退") + op.drop_column("adjustment_orders", "attribution_json") + op.drop_column("adjustment_orders", "source_ledger_id") + op.drop_column("adjustment_orders", "adjustment_type") diff --git a/backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py b/backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py new file mode 100644 index 0000000..e8acb43 --- /dev/null +++ b/backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py @@ -0,0 +1,18 @@ +"""自动采集周期、重试与心跳。""" +from alembic import op +revision: str = "20e1f2a3b4c5" +down_revision: str | None = "19d0e1f2a3b4" +branch_labels = None +depends_on = None + +def upgrade(): + op.execute("CREATE TABLE tplus_automation_states (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', \n source_database VARCHAR(128) NOT NULL COMMENT '源账套', \n scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', \n root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', \n current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', \n retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', \n next_retry_at DATETIME COMMENT '下一次自动重试时间', \n last_success_at DATETIME COMMENT '最近成功采集时间', \n maintenance_at DATETIME COMMENT '最近周期检查时间', \n heartbeat_at DATETIME COMMENT '独立进程心跳时间', \n worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', \n worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT '''STOPPED''', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), \n CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci") + op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY'") + op.execute("ALTER TABLE tplus_sync_batches ADD COLUMN automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)'") + +def downgrade(): + if op.get_bind().exec_driver_sql("SELECT COUNT(*) FROM tplus_automation_states").scalar(): + raise RuntimeError("存在调度状态,请先停用并备份后采用应用回退,不删除运行依据") + op.drop_column("tplus_sync_batches", "automatic_attempt") + op.drop_column("tplus_sync_batches", "trigger_type") + op.drop_table("tplus_automation_states") diff --git a/backend/alembic/versions/21f2a3b4c5d6_automation_phase_default.py b/backend/alembic/versions/21f2a3b4c5d6_automation_phase_default.py new file mode 100644 index 0000000..b8d4ee9 --- /dev/null +++ b/backend/alembic/versions/21f2a3b4c5d6_automation_phase_default.py @@ -0,0 +1,17 @@ +"""修正进程状态默认值的多余引号,保留已应用V20。""" + +from alembic import op + +revision: str = "21f2a3b4c5d6" +down_revision: str | None = "20e1f2a3b4c5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT 'STOPPED'") + op.execute("UPDATE tplus_automation_states SET worker_phase='STOPPED' WHERE worker_phase='''STOPPED'''") + + +def downgrade(): + op.execute("ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT '''STOPPED'''") diff --git a/backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py b/backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py new file mode 100644 index 0000000..6041267 --- /dev/null +++ b/backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py @@ -0,0 +1,31 @@ +"""V16 基础导入和核算预演工作区;结构固定,不引用演进中的 ORM。""" + +from alembic import op +import sqlalchemy as sa + +revision: str = "f6a708b9c0d1" +down_revision: str | None = "e5f607a8b9c0" +branch_labels = None +depends_on = None + +STATEMENTS = [ + "CREATE TABLE tplus_source_heads (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\trecord_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', \n\tbatch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', \n\tCONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_heads_source_entity_id UNIQUE (source_database, entity_type, source_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci", + "CREATE INDEX idx_tplus_heads_batch_entity ON tplus_source_heads (batch_no, entity_type)", + "CREATE TABLE tplus_facts (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tkind VARCHAR(32) NOT NULL COMMENT '事实类别', \n\tfact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', \n\tversion INTEGER NOT NULL COMMENT '版本号', \n\tdata_json JSON NOT NULL COMMENT '经校验的事实内容', \n\tevidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_facts PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci", + "CREATE TABLE tplus_source_links (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tsource_database VARCHAR(128) NOT NULL COMMENT '源账套', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体类型', \n\tsource_id VARCHAR(64) NOT NULL COMMENT '源内部ID', \n\ttarget_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_source_links PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_links_source_entity_id UNIQUE (source_database, entity_type, source_id), \n\tCONSTRAINT uk_tplus_links_entity_target UNIQUE (entity_type, target_id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci", + "CREATE TABLE tplus_work_runs (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\trequest_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', \n\tkind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', \n\tstatus VARCHAR(16) NOT NULL COMMENT '运行状态', \n\tsource_database VARCHAR(128) NOT NULL COMMENT '固定源账套', \n\trequested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', \n\tinput_json JSON NOT NULL COMMENT '请求输入', \n\tsnapshot_json JSON COMMENT '规则和补充资料的固定快照', \n\tsnapshot_hash VARCHAR(64) COMMENT '输入指纹', \n\tsummary_json JSON COMMENT '结果汇总', \n\terror_code VARCHAR(64) COMMENT '固定错误编码', \n\tstarted_at DATETIME COMMENT '开始时间', \n\tfinished_at DATETIME COMMENT '结束时间', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), \n\tCONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci", + "CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id)", + "CREATE TABLE tplus_work_lines (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\trun_no VARCHAR(32) NOT NULL COMMENT '运行编号', \n\tline_no INTEGER NOT NULL COMMENT '稳定行序号', \n\tentity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', \n\tsource_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', \n\taction VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', \n\treason VARCHAR(128) COMMENT '固定原因编码', \n\tdata_json JSON NOT NULL COMMENT '最小必要的业务明细', \n\tCONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), \n\tCONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci" +] + + +def upgrade(): + """只新增隔离工作区表,不写已有业务或工资记录。""" + for statement in STATEMENTS: + op.execute(sa.text(statement)) + + +def downgrade(): + """只移除本次工作区表,执行前应保留资料和预演备份。""" + for name in ("tplus_work_lines", "tplus_work_runs", "tplus_source_links", "tplus_facts", "tplus_source_heads"): + op.drop_table(name) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 527742e..6dd4254 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -119,6 +119,26 @@ AdminUser = Annotated[User, Depends(require_admin)] LoginUser = Annotated[User, Depends(get_current_user)] +def require_payroll_writer(current_user: LoginUser) -> User: + from app.services.payroll_access import ensure_payroll_writer + + return ensure_payroll_writer(current_user) + + +PayrollWriter = Annotated[User, Depends(require_payroll_writer)] + + +def require_workbench_reader(current_user: LoginUser) -> User: + from app.services.payroll_access import can_write_payroll + + if ROLE_ADMIN not in user_repository.get_role_codes(current_user) and not can_write_payroll(current_user): + raise ForbiddenError("需要管理员或指定工资制作人权限") + return current_user + + +WorkbenchReader = Annotated[User, Depends(require_workbench_reader)] + + def require_roles(*role_codes: str, message: str = "无权访问审批管理"): """要求当前用户具备任一指定角色(依赖工厂)。 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index c24314f..1d63411 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -18,6 +18,7 @@ from app.api.v1.business import receipts as business_receipts from app.api.v1.business import shipments as business_shipments from app.api.v1.calc import router as calc_router from app.api.v1.dashboard import router as dashboard_router +from app.api.v1.payroll import router as payroll_router from app.api.v1.reports import router as reports_router from app.api.v1.settlements import router as settlements_router from app.api.v1.system import approvals as system_approvals @@ -69,6 +70,7 @@ v1_router.include_router(business_router) # 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤 v1_router.include_router(calc_router) +v1_router.include_router(payroll_router) # 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤 v1_router.include_router(settlements_router) # 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 07aced3..bfc289a 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -5,11 +5,13 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token +from app.core.config import get_settings from app.core.response import ApiResponse, ok from app.models import User from app.repositories.user_repository import get_role_codes from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut from app.services import auth_service, captcha_service +from app.services.payroll_access import can_write_payroll router = APIRouter(prefix="/auth", tags=["认证"]) @@ -71,6 +73,8 @@ def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[ username=current_user.username, real_name=current_user.real_name, roles=get_role_codes(current_user), + can_write_payroll=can_write_payroll(current_user), + payroll_approval_enabled=get_settings().payroll_approval_enabled, last_login_time=current_user.last_login_time, ) ) diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py index 96aa995..0093a53 100644 --- a/backend/app/api/v1/base/customers.py +++ b/backend/app/api/v1/base/customers.py @@ -13,7 +13,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageQuery, PageResult, ok from app.models import User from app.schemas.customer import ( @@ -46,7 +46,7 @@ def list_customers( @router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户") def create_customer( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: CustomerCreateIn, meta: RequestMetaDep, ) -> ApiResponse[CustomerOut]: @@ -70,7 +70,7 @@ def get_customer( @router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户") def update_customer( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, customer_id: Annotated[int, Path(ge=1)], payload: CustomerUpdateIn, meta: RequestMetaDep, @@ -91,7 +91,7 @@ def update_customer( @router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户") def delete_customer( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, customer_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: @@ -110,7 +110,7 @@ def delete_customer( ) def rate_customer( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, customer_id: Annotated[int, Path(ge=1)], payload: CustomerRatingIn, meta: RequestMetaDep, @@ -135,7 +135,7 @@ def rate_customer( ) def change_ownership( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, customer_id: Annotated[int, Path(ge=1)], payload: CustomerOwnershipIn, meta: RequestMetaDep, diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py index 2210514..d5ae808 100644 --- a/backend/app/api/v1/base/personnel.py +++ b/backend/app/api/v1/base/personnel.py @@ -11,7 +11,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok from app.models import User from app.schemas.personnel import ( @@ -44,7 +44,7 @@ def list_personnel( @router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") def create_personnel( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: PersonnelCreateIn, meta: RequestMetaDep, ) -> ApiResponse[PersonnelOut]: @@ -68,7 +68,7 @@ def get_personnel( @router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") def update_personnel( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, personnel_id: Annotated[int, Path(ge=1)], payload: PersonnelCreateIn, meta: RequestMetaDep, @@ -85,7 +85,7 @@ def update_personnel( @router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") def delete_personnel( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, personnel_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: @@ -101,7 +101,7 @@ def delete_personnel( ) def set_status( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, personnel_id: Annotated[int, Path(ge=1)], payload: PersonnelStatusIn, meta: RequestMetaDep, @@ -116,7 +116,7 @@ def set_status( @router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域") def replace_regions( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, personnel_id: Annotated[int, Path(ge=1)], payload: RegionReplaceIn, meta: RequestMetaDep, @@ -131,7 +131,7 @@ def replace_regions( @router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") def replace_plans( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, personnel_id: Annotated[int, Path(ge=1)], payload: PlanReplaceIn, meta: RequestMetaDep, diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py index 4255248..20b22da 100644 --- a/backend/app/api/v1/base/products.py +++ b/backend/app/api/v1/base/products.py @@ -14,7 +14,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok from app.models import User from app.schemas.product import ( @@ -44,7 +44,7 @@ def list_products( @router.post("", response_model=ApiResponse[ProductOut], summary="新增产品") def create_product( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: ProductCreateIn, meta: RequestMetaDep, ) -> ApiResponse[ProductOut]: @@ -68,7 +68,7 @@ def get_product( @router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品") def update_product( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, product_id: Annotated[int, Path(ge=1)], payload: ProductUpdateIn, meta: RequestMetaDep, @@ -85,7 +85,7 @@ def update_product( @router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品") def delete_product( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, product_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: @@ -100,7 +100,7 @@ def delete_product( @router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品") def set_status( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, product_id: Annotated[int, Path(ge=1)], payload: ProductStatusIn, meta: RequestMetaDep, diff --git a/backend/app/api/v1/business/businesses.py b/backend/app/api/v1/business/businesses.py index 440babd..ce8152b 100644 --- a/backend/app/api/v1/business/businesses.py +++ b/backend/app/api/v1/business/businesses.py @@ -9,7 +9,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageQuery, PageResult, ok from app.models import User from app.schemas.business import ( @@ -45,7 +45,7 @@ def list_businesses( @router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务") def create_business( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: BusinessCreateIn, meta: RequestMetaDep, ) -> ApiResponse[BusinessOut]: @@ -69,7 +69,7 @@ def get_business( @router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务") def update_business( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, business_id: Annotated[int, Path(ge=1)], payload: BusinessUpdateIn, meta: RequestMetaDep, @@ -86,7 +86,7 @@ def update_business( @router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务") def delete_business( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, business_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: @@ -98,7 +98,7 @@ def delete_business( @router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转") def change_status( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, business_id: Annotated[int, Path(ge=1)], payload: BusinessStatusIn, meta: RequestMetaDep, @@ -115,7 +115,7 @@ def change_status( @router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属") def change_ownership( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, business_id: Annotated[int, Path(ge=1)], payload: BusinessOwnershipIn, meta: RequestMetaDep, diff --git a/backend/app/api/v1/business/ratings.py b/backend/app/api/v1/business/ratings.py index 540ab4f..f803544 100644 --- a/backend/app/api/v1/business/ratings.py +++ b/backend/app/api/v1/business/ratings.py @@ -13,7 +13,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, ok from app.models import User from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn @@ -36,7 +36,7 @@ def list_grades( @router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级") def create_grade( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: RatingGradeCreateIn, meta: RequestMetaDep, ) -> ApiResponse[RatingGradeOut]: @@ -50,7 +50,7 @@ def create_grade( @router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级") def update_grade( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, grade_id: Annotated[int, Path(ge=1)], payload: RatingGradeUpdateIn, meta: RequestMetaDep, @@ -65,7 +65,7 @@ def update_grade( @router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级") def delete_grade( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, grade_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: diff --git a/backend/app/api/v1/business/receipts.py b/backend/app/api/v1/business/receipts.py index 7b5dc71..3156d74 100644 --- a/backend/app/api/v1/business/receipts.py +++ b/backend/app/api/v1/business/receipts.py @@ -9,7 +9,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok from app.models import User from app.schemas.receipt import ( @@ -39,7 +39,7 @@ def list_receipts( @router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款") def create_receipt( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: ReceiptCreateIn, meta: RequestMetaDep, ) -> ApiResponse[ReceiptOut]: @@ -63,7 +63,7 @@ def get_receipt( @router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款") def update_receipt( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptUpdateIn, meta: RequestMetaDep, @@ -80,7 +80,7 @@ def update_receipt( @router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款") def delete_receipt( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, receipt_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: diff --git a/backend/app/api/v1/business/shipments.py b/backend/app/api/v1/business/shipments.py index bd0680c..5974289 100644 --- a/backend/app/api/v1/business/shipments.py +++ b/backend/app/api/v1/business/shipments.py @@ -8,7 +8,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok from app.models import User from app.schemas.shipment import ( @@ -39,7 +39,7 @@ def list_shipments( @router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") def create_shipment( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, payload: ShipmentCreateIn, meta: RequestMetaDep, ) -> ApiResponse[ShipmentOut]: @@ -63,7 +63,7 @@ def get_shipment( @router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") def update_shipment( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, shipment_id: Annotated[int, Path(ge=1)], payload: ShipmentUpdateIn, meta: RequestMetaDep, @@ -80,7 +80,7 @@ def update_shipment( @router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") def delete_shipment( session: DbSession, - current_user: CurrentUser, + current_user: PayrollWriter, shipment_id: Annotated[int, Path(ge=1)], meta: RequestMetaDep, ) -> ApiResponse[None]: diff --git a/backend/app/api/v1/calc.py b/backend/app/api/v1/calc.py index 3948b44..a9bc366 100644 --- a/backend/app/api/v1/calc.py +++ b/backend/app/api/v1/calc.py @@ -10,18 +10,14 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel, Field -from app.api.deps import DbSession, RequestMetaDep, require_roles -from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok -from app.models import User from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery from app.services import calc_service -router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"]) +router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"]) -_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))] # 重算是授权动作:管理员/财务(营销后勤不可重算) -_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))] @router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账") @@ -50,7 +46,7 @@ def get_ledger( def calc_receipt( receipt_id: Annotated[int, Path(ge=1)], session: DbSession, - current_user: _CalcWriter, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[CalcReceiptOut]: """把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。""" @@ -81,7 +77,7 @@ def recalc_receipt( receipt_id: Annotated[int, Path(ge=1)], payload: RecalcIn, session: DbSession, - current_user: _RecalcUser, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[CalcReceiptOut]: """重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。""" @@ -97,7 +93,7 @@ def recalc_receipt( def batch_calc( payload: BatchIn, session: DbSession, - current_user: _CalcWriter, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[list[dict]]: """批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。""" diff --git a/backend/app/api/v1/payroll.py b/backend/app/api/v1/payroll.py new file mode 100644 index 0000000..6c85992 --- /dev/null +++ b/backend/app/api/v1/payroll.py @@ -0,0 +1,108 @@ +"""正式工资资料、FIFO核算和逐段明细入口。""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep +from app.core.response import PageQuery, ok +from app.schemas.payroll import AllocationAdjustmentIn, PayrollMonthIn, PayrollSetupIn +from app.services import payroll_calculation, payroll_inputs, payroll_months, settlement_service + +router = APIRouter(prefix="/payroll", tags=["正式工资核算"]) + + +@router.get("/months/{month}") +def month_state( + month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")], + session: DbSession, + _: LoginUser, +): + from sqlalchemy import select + + from app.core.month import validate_month + from app.models import MonthlySettlement, PayrollMonth + + validate_month(month) + control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month)) + existing = session.scalar( + select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month) + ) + return ok( + { + "month": month, + "allocation_locked": bool(existing or (control and control.generated_at)), + "maker_name": control.maker_name if control else None, + } + ) + + +@router.get("/businesses/{business_id}/input") +def input_detail(business_id: int, session: DbSession, _: LoginUser): + record = payroll_calculation.latest_input(session, business_id) + return ok( + None + if record is None + else { + "id": record.id, + "version": record.version, + "source": record.source_database, + "fingerprint": record.fingerprint, + "data": record.data_json, + } + ) + + +@router.post("/businesses/{business_id}/input") +def save_input( + business_id: int, + payload: PayrollSetupIn, + session: DbSession, + actor: PayrollWriter, + meta: RequestMetaDep, +): + return ok(payroll_inputs.setup(session, business_id, payload, actor, meta)) + + +@router.post("/businesses/{business_id}/calculate") +def calculate( + business_id: int, + payload: PayrollMonthIn, + session: DbSession, + actor: PayrollWriter, + meta: RequestMetaDep, +): + return ok( + payroll_calculation.calculate( + session, business_id, payload.month, operator=actor, meta=meta, reason=payload.reason + ) + ) + + +@router.get("/ledger/{ledger_id}/allocations") +def allocations( + ledger_id: int, query: Annotated[PageQuery, Query()], session: DbSession, _: LoginUser +): + return ok(payroll_calculation.allocation_page(session, ledger_id, query)) + + +@router.post("/allocation-adjustments") +def adjustment( + payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep +): + from app.core.exceptions import BadRequestError + from app.models import PerformanceLedger + from app.schemas.settlement import AdjustmentCreateIn + + old = session.get(PerformanceLedger, payload.ledger_id) + if old is None or old.settlement_id is None: + raise BadRequestError("请选择已生成工资的原台账") + data = AdjustmentCreateIn( + settlement_month=payroll_months.current_month(), + personnel_id=payload.personnel_id, + direction=payload.direction, + amount=payload.amount, + reason=payload.reason, + source_ledger_id=old.id, + ) + return ok(settlement_service.create_adjustment(session, data, operator=actor, meta=meta)) diff --git a/backend/app/api/v1/settlements.py b/backend/app/api/v1/settlements.py index 427795a..e7c2f83 100644 --- a/backend/app/api/v1/settlements.py +++ b/backend/app/api/v1/settlements.py @@ -8,10 +8,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query -from app.api.deps import DbSession, RequestMetaDep, require_roles -from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok -from app.models import User from app.schemas.settlement import ( AdjustmentCreateIn, SettlementDetailOut, @@ -21,16 +19,15 @@ from app.schemas.settlement import ( ) from app.services import settlement_service -router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"]) +router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"]) -_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))] @router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照") def generate( payload: SettlementGenerateIn, session: DbSession, - current_user: _SettlementWriter, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[SettlementDetailOut]: """按确认回款口径生成指定月份的结算快照(草稿)。""" @@ -66,7 +63,7 @@ def get_settlement( def submit( settlement_id: Annotated[int, Path(ge=1)], session: DbSession, - current_user: _SettlementWriter, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[SettlementDetailOut]: """提交审批:开关关闭=自动通过并锁定;开启=待财务复核。""" @@ -83,7 +80,7 @@ def submit( def create_adjustment( payload: AdjustmentCreateIn, session: DbSession, - current_user: _SettlementWriter, + current_user: PayrollWriter, meta: RequestMetaDep, ) -> ApiResponse[dict]: """新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。""" diff --git a/backend/app/api/v1/system/tplus.py b/backend/app/api/v1/system/tplus.py index 5d4941a..a609d97 100644 --- a/backend/app/api/v1/system/tplus.py +++ b/backend/app/api/v1/system/tplus.py @@ -4,7 +4,8 @@ from typing import Annotated from fastapi import APIRouter, Path, Query -from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchReader +from app.api.v1.system.tplus_workbench import router as workbench_router from app.core.response import ApiResponse, PageResult, ok from app.schemas.tplus import ( TPlusBatchDetailOut, @@ -19,23 +20,24 @@ from app.schemas.tplus import ( from app.services.tplus import query_service, task_service router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"]) +router.include_router(workbench_router) BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")] @router.get("/overview", response_model=ApiResponse[TPlusOverviewOut]) -def overview(session: DbSession, _: AdminUser): +def overview(session: DbSession, _: WorkbenchReader): """查询配置状态、任务统计和最近核验。""" return ok(query_service.overview(session)) @router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]]) -def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]): +def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]): """分页查询批次元数据。""" return ok(query_service.list_batches(session, query)) @router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut]) -def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber): +def detail(session: DbSession, _: WorkbenchReader, batch_no: BatchNumber): """查询批次详情与受控的核验报告。""" return ok(query_service.get_batch(session, batch_no)) @@ -43,7 +45,7 @@ def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber): @router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]]) def errors( session: DbSession, - _: AdminUser, + _: WorkbenchReader, batch_no: BatchNumber, query: Annotated[TPlusErrorQuery, Query()], ): @@ -52,7 +54,7 @@ def errors( @router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut]) -def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn): +def create(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, payload: TPlusCreateIn): """将读取或核验任务持久化排队。""" batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta)) return ok(query_service.get_batch(session, batch.batch_no)) @@ -61,7 +63,7 @@ def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: @router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut]) def retry( session: DbSession, - admin: AdminUser, + admin: PayrollWriter, meta: RequestMetaDep, batch_no: BatchNumber, payload: TPlusRetryIn, diff --git a/backend/app/api/v1/system/tplus_workbench.py b/backend/app/api/v1/system/tplus_workbench.py new file mode 100644 index 0000000..b2abeec --- /dev/null +++ b/backend/app/api/v1/system/tplus_workbench.py @@ -0,0 +1,223 @@ +"""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"'}, + ) diff --git a/backend/app/cli.py b/backend/app/cli.py index 39aa471..f47236f 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -10,9 +10,13 @@ import argparse import getpass +import json +import signal import sys -import time +import threading from datetime import datetime +from pathlib import Path +from uuid import uuid4 from sqlalchemy import select @@ -115,24 +119,79 @@ def _tplus_worker(args: argparse.Namespace) -> int: print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr) return 1 factory = get_session_factory() + settings = get_settings() + stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file + stopped = threading.Event() + owner = str(uuid4()) + previous_handlers = {} + if threading.current_thread() is threading.main_thread(): + for sig in (signal.SIGINT, signal.SIGTERM): + previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set()) try: - while True: + while not stopped.is_set() and not (stop_file and Path(stop_file).exists()): with factory() as session: - batch_no = run_once(session) + batch_no = run_once(session, worker_owner=owner) batch = get_batch(session, batch_no) if batch_no else None + if batch_no and batch is None: + from app.models import TPlusWorkRun + batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no)) status = batch.status if batch else None if batch_no: print(f"任务完成:{batch_no} 状态={status}", flush=True) if not args.watch: return 0 if status in (None, "SUCCEEDED") else 1 if batch_no is None or status != "SUCCEEDED": - time.sleep(args.poll_seconds) + stopped.wait(args.poll_seconds) + print("T+读取服务已停止领取新任务", flush=True) + return 0 except KeyboardInterrupt: print("T+ 读取服务已停止") return 130 except Exception as exc: print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr) return 1 + finally: + for sig, handler in previous_handlers.items(): + signal.signal(sig, handler) + try: + from app.services.tplus.automation import heartbeat + + heartbeat(factory, settings, owner, "STOPPED") + except Exception as exc: + print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr) + + +def _maintenance(args): + from app.services.tplus.automation import tick + + if args.connection_file: + settings = get_settings() + settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file + if args.stop_file: + get_settings().t_plus_stop_file = args.stop_file + with get_session_factory()() as session: + result = tick(session) + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +def _materials(args): + from app.services.tplus.materials import collect_materials, render_markdown + + if args.connection_file: + settings = get_settings() + settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file + output = Path(args.output_dir).resolve() + targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")] + if any(p.exists() for p in targets): + raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据") + with get_tplus_session_factory()() as source: + report = collect_materials(source, args.customer_id, args.month) + output.mkdir(parents=True, exist_ok=True) + targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + targets[1].write_text(render_markdown(report), encoding="utf-8") + print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}") + return 0 def main() -> int: @@ -182,6 +241,17 @@ def main() -> int: modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务") p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON") p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒") + p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务") + + p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资") + p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)") + p_maintenance.add_argument("--connection-file", default=None) + p_maintenance.add_argument("--stop-file", default=None) + p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资") + p_materials.add_argument("--customer-id", type=int, required=True) + p_materials.add_argument("--month", required=True) + p_materials.add_argument("--connection-file", default=None) + p_materials.add_argument("--output-dir", required=True) args = parser.parse_args() @@ -190,7 +260,14 @@ def main() -> int: return _create_admin(args) if args.command == "tplus-worker": return _tplus_worker(args) + if args.command == "tplus-maintain": + return _maintenance(args) + if args.command == "tplus-materials": + return _materials(args) return _stage_tplus(args) + except Exception as exc: + print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr) + return 1 finally: dispose_engine() dispose_tplus_engine() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b8cac5d..7776ab8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -36,6 +36,11 @@ class Settings(BaseSettings): # ===== 应用 ===== app_env: str = "dev" app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符") + payroll_maker_user_id: int | None = Field( + default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读" + ) + payroll_allow_legacy_calculation: bool = False + payroll_approval_enabled: bool = False # ===== 鉴权 ===== # 规范第 13 节:Token 有效期 ≤ 2 小时 @@ -74,6 +79,10 @@ class Settings(BaseSettings): t_plus_connect_timeout: int = 10 t_plus_query_timeout: int = Field(default=120, ge=1, le=600) t_plus_query_dir: str = "" + t_plus_automation_enabled: bool = False + t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6) + t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$") + t_plus_stop_file: str = "" @field_validator("app_secret_key") @classmethod @@ -120,9 +129,7 @@ class Settings(BaseSettings): query = { "driver": self.t_plus_odbc_driver, "Encrypt": "yes" if self.t_plus_encrypt else "no", - "TrustServerCertificate": "yes" - if self.t_plus_trust_server_certificate - else "no", + "TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no", "Connection Timeout": str(self.t_plus_connect_timeout), } return URL.create( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a4175ee..52533b2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from app.models.customer import Customer, OwnershipChangeLog from app.models.login_log import LoginLog from app.models.operation_log import OperationLog from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue +from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth from app.models.performance_ledger import PerformanceLedger from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.product import Product @@ -26,12 +27,20 @@ from app.models.role import Permission, Role, RolePermission from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem from app.models.shipment import Shipment, ShipmentItem from app.models.system_setting import SystemSetting +from app.models.tplus_automation import TPlusAutomationState from app.models.tplus_sync import ( TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord, TPlusSyncWatermark, ) +from app.models.tplus_workbench import ( + TPlusFact, + TPlusSourceHead, + TPlusSourceLink, + TPlusWorkLine, + TPlusWorkRun, +) from app.models.user import User, UserRole __all__ = [ @@ -52,6 +61,9 @@ __all__ = [ "ParamChangeBatch", "ParamDefinition", "ParamValue", + "PayrollAllocation", + "PayrollInput", + "PayrollMonth", "PerformanceLedger", "Permission", "Personnel", @@ -66,10 +78,16 @@ __all__ = [ "Shipment", "ShipmentItem", "SystemSetting", + "TPlusAutomationState", + "TPlusFact", + "TPlusSourceHead", + "TPlusSourceLink", "TPlusSyncBatch", "TPlusSyncError", "TPlusSyncRecord", "TPlusSyncWatermark", + "TPlusWorkLine", + "TPlusWorkRun", "TimestampMixin", "User", "UserRole", diff --git a/backend/app/models/payroll.py b/backend/app/models/payroll.py new file mode 100644 index 0000000..23349dd --- /dev/null +++ b/backend/app/models/payroll.py @@ -0,0 +1,66 @@ +"""正式工资输入、分配过程与月度生成锁,和隔离预演分别留存。""" + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import JSON, DateTime, Index, Integer, Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class PayrollMonth(Base, TimestampMixin): + """先锁月份行再修改方案或生成工资;生成标记不随审批退回清除。""" + + __tablename__ = "payroll_months" + __table_args__ = mysql_table_args("工资月份控制表(首次生成即锁定分配,审批不解锁)") + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + month: Mapped[str] = mapped_column(String(7), unique=True, comment="工资月份") + maker_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="实际工资制作人账号ID") + maker_name: Mapped[str] = mapped_column(String(64), comment="制作人用户名快照") + generated_at: Mapped[datetime | None] = mapped_column( + DateTime, comment="首次成功生成时间(不可解锁)" + ) + + +class PayrollInput(Base, TimestampMixin): + """正式核算输入的版本:来源、成本、归属及期初均有依据,旧版本不覆盖。""" + + __tablename__ = "payroll_inputs" + __table_args__ = mysql_table_args( + "正式工资输入版本表", + UniqueConstraint("business_id", "version", name="uk_payroll_inputs_business_version"), + Index("idx_payroll_inputs_source_group", "source_database", "group_key"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID") + version: Mapped[int] = mapped_column(Integer, comment="输入版本") + source_database: Mapped[str] = mapped_column(String(128), comment="源账套或人工来源") + group_key: Mapped[str] = mapped_column(String(128), comment="独立业务平账组") + fingerprint: Mapped[str] = mapped_column(String(64), comment="来源及事实指纹") + data_json: Mapped[dict] = mapped_column(JSON, comment="经核实的源数据及人工事实快照") + requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="录入人账号ID") + + +class PayrollAllocation(Base, TimestampMixin): + """一次正式计算的逐段过程;版本不可变,生效性由所属台账控制。""" + + __tablename__ = "payroll_allocations" + __table_args__ = mysql_table_args( + "正式FIFO分配明细表", + UniqueConstraint("ledger_id", "line_no", name="uk_payroll_allocations_ledger_line"), + Index("idx_payroll_allocations_business_id", "business_id"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + ledger_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所属正式台账ID") + business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID") + line_no: Mapped[int] = mapped_column(Integer, comment="本台账稳定明细序号") + input_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所用输入版本ID") + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), comment="分配回款金额") + data_json: Mapped[dict] = mapped_column(JSON, comment="单据、成本、归属、参数和逐段计算依据") diff --git a/backend/app/models/performance_ledger.py b/backend/app/models/performance_ledger.py index d080e57..e58b17e 100644 --- a/backend/app/models/performance_ledger.py +++ b/backend/app/models/performance_ledger.py @@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin): "receipt_id", "calc_version", "personnel_id", + "allocation_key", name="uk_performance_ledgers_receipt_id_calc_version_personnel", ), Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"), @@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin): "calc_type", ), Index("idx_performance_ledgers_personnel_id", "personnel_id"), + Index("idx_performance_ledgers_receipt_id", "receipt_id"), Index("idx_performance_ledgers_business_id", "business_id"), ) @@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin): server_default="1", comment="核算版本(重算+1,旧行 is_active=0)", ) + allocation_key: Mapped[str] = mapped_column( + String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串", + ) is_active: Mapped[int] = mapped_column( TINYINT_TYPE, nullable=False, diff --git a/backend/app/models/settlement.py b/backend/app/models/settlement.py index c3832fb..387c20d 100644 --- a/backend/app/models/settlement.py +++ b/backend/app/models/settlement.py @@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin): ) locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)") + @property + def approval_required(self) -> bool: + return bool((self.snapshot_json or {}).get("approval_required", True)) + class SettlementItem(Base): """月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。""" @@ -154,6 +158,9 @@ class AdjustmentOrder(Base, TimestampMixin): id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" ) + adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额") + source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID") + attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照") order_no: Mapped[str] = mapped_column( String(32), nullable=False, diff --git a/backend/app/models/tplus_automation.py b/backend/app/models/tplus_automation.py new file mode 100644 index 0000000..f5b452b --- /dev/null +++ b/backend/app/models/tplus_automation.py @@ -0,0 +1,26 @@ +"""自动采集的周期、重试和进程心跳;不承载业务核算状态。""" + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class TPlusAutomationState(Base, TimestampMixin): + __tablename__ = "tplus_automation_states" + __table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)") + id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID") + source_key: Mapped[str] = mapped_column(String(64), unique=True, comment="源连接身份摘要(不含凭据)") + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + scheduled_period: Mapped[str | None] = mapped_column(String(16), comment="最近已排队ISO周") + root_batch_no: Mapped[str | None] = mapped_column(String(32), comment="本周期原始采集批次") + current_batch_no: Mapped[str | None] = mapped_column(String(32), comment="当前原始或重试批次") + retry_attempts: Mapped[int] = mapped_column(Integer, server_default="0", comment="本周期自动重试次数") + next_retry_at: Mapped[datetime | None] = mapped_column(DateTime, comment="下一次自动重试时间") + last_success_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近成功采集时间") + maintenance_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近周期检查时间") + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="独立进程心跳时间") + worker_owner: Mapped[str | None] = mapped_column(String(36), comment="当前持锁进程身份") + worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="进程状态:IDLE/RUNNING/STOPPED") diff --git a/backend/app/models/tplus_sync.py b/backend/app/models/tplus_sync.py index 0ac7a97..9282f9f 100644 --- a/backend/app/models/tplus_sync.py +++ b/backend/app/models/tplus_sync.py @@ -63,6 +63,8 @@ class TPlusSyncBatch(Base, TimestampMixin): requested_by: Mapped[int | None] = mapped_column( BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键" ) + trigger_type: Mapped[str] = mapped_column(String(16), server_default="'MANUAL'", comment="触发来源:MANUAL/SCHEDULED/AUTO_RETRY") + automatic_attempt: Mapped[int] = mapped_column(Integer, server_default="0", comment="自动重试序号(0为原始任务)") audit_result_json: Mapped[dict | None] = mapped_column( JSON, comment="关联核验结果,只含系统字典和汇总" ) diff --git a/backend/app/models/tplus_workbench.py b/backend/app/models/tplus_workbench.py new file mode 100644 index 0000000..dc9a74b --- /dev/null +++ b/backend/app/models/tplus_workbench.py @@ -0,0 +1,134 @@ +"""T+ 基础导入与试算的隔离工作区,任何表均不关联正式工资台账。""" + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, Index, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class TPlusSourceHead(Base): + """一次完整读取实际观察到的源版本;解决 A→B→A 去重后的旧版本误选。""" + + __tablename__ = "tplus_source_heads" + __table_args__ = mysql_table_args( + "T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)", + UniqueConstraint( + "source_database", + "entity_type", + "source_id", + name="uk_tplus_source_heads_source_entity_id", + ), + Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型") + source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID") + record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID") + batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次") + + +class TPlusFact(Base, TimestampMixin): + """补充资料追加版本,旧版本永久保留,乐观锁防止静默覆盖。""" + + __tablename__ = "tplus_facts" + __table_args__ = mysql_table_args( + "T+ 人工补充事实版本表", + UniqueConstraint( + "source_database", + "kind", + "fact_key", + "version", + name="uk_tplus_facts_source_kind_key_version", + ), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + kind: Mapped[str] = mapped_column(String(32), comment="事实类别") + fact_key: Mapped[str] = mapped_column(String(128), comment="事实业务键") + version: Mapped[int] = mapped_column(Integer, comment="版本号") + data_json: Mapped[dict] = mapped_column(JSON, comment="经校验的事实内容") + evidence: Mapped[str] = mapped_column(String(500), comment="依据和修改原因") + requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID") + + +class TPlusSourceLink(Base, TimestampMixin): + """源稳定身份与业务目标的唯一关联;不按名称猜测合并。""" + + __tablename__ = "tplus_source_links" + __table_args__ = mysql_table_args( + "T+ 基础数据源目标关联", + UniqueConstraint( + "source_database", + "entity_type", + "source_id", + name="uk_tplus_source_links_source_entity_id", + ), + UniqueConstraint("entity_type", "target_id", name="uk_tplus_source_links_entity_target"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型") + source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID") + target_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="业务目标ID") + + +class TPlusWorkRun(Base, TimestampMixin): + """后台导入、预览和试算运行记录,固定输入,保留终态。""" + + __tablename__ = "tplus_work_runs" + __table_args__ = mysql_table_args( + "T+ 导入与预演运行表", + UniqueConstraint("run_no", name="uk_tplus_work_runs_run_no"), + UniqueConstraint("request_key", name="uk_tplus_work_runs_request_key"), + Index("idx_tplus_work_runs_status_id", "status", "id"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + run_no: Mapped[str] = mapped_column(String(32), comment="运行编号") + request_key: Mapped[str] = mapped_column(String(36), comment="请求幂等键") + kind: Mapped[str] = mapped_column( + String(32), comment="MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW" + ) + status: Mapped[str] = mapped_column(String(16), default="QUEUED", comment="运行状态") + source_database: Mapped[str] = mapped_column(String(128), comment="固定源账套") + requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID") + input_json: Mapped[dict] = mapped_column(JSON, comment="请求输入") + snapshot_json: Mapped[dict | None] = mapped_column(JSON, comment="规则和补充资料的固定快照") + snapshot_hash: Mapped[str | None] = mapped_column(String(64), comment="输入指纹") + summary_json: Mapped[dict | None] = mapped_column(JSON, comment="结果汇总") + error_code: Mapped[str | None] = mapped_column(String(64), comment="固定错误编码") + started_at: Mapped[datetime | None] = mapped_column(DateTime, comment="开始时间") + finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间") + + +class TPlusWorkLine(Base): + """固定输入及结果逐行留存,仅经授权的工作区接口可访问。""" + + __tablename__ = "tplus_work_lines" + __table_args__ = mysql_table_args( + "T+ 导入预览与核算预演明细", + UniqueConstraint("run_no", "line_no", name="uk_tplus_work_lines_run_line"), + ) + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + run_no: Mapped[str] = mapped_column(String(32), comment="运行编号") + line_no: Mapped[int] = mapped_column(Integer, comment="稳定行序号") + entity_type: Mapped[str] = mapped_column(String(32), comment="实体或分组类型") + source_id: Mapped[str] = mapped_column(String(128), comment="源ID或平账组") + action: Mapped[str] = mapped_column(String(32), comment="新增/更新/跳过/待补/冲突/分配") + reason: Mapped[str | None] = mapped_column(String(128), comment="固定原因编码") + data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要的业务明细") + + +WORKBENCH_MODELS = (TPlusSourceHead, TPlusFact, TPlusSourceLink, TPlusWorkRun, TPlusWorkLine) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c258d32..adddc2e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -56,6 +56,8 @@ class UserBrief(BaseModel): username: str real_name: str | None = None roles: list[str] = Field(default_factory=list, description="角色编码列表") + can_write_payroll: bool = False + payroll_approval_enabled: bool = False class LoginOut(BaseModel): diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index 4d4d398..63dbb77 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -250,6 +250,8 @@ class BusinessCreateIn(BaseModel): class BusinessUpdateIn(BaseModel): """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。""" + + model_config = ConfigDict(str_strip_whitespace=True) customer_id: int = Field(ge=1, description="客户ID") diff --git a/backend/app/schemas/payroll.py b/backend/app/schemas/payroll.py new file mode 100644 index 0000000..6b198da --- /dev/null +++ b/backend/app/schemas/payroll.py @@ -0,0 +1,59 @@ +"""正式工资核算的最小输入契约;金额使用字符串,资料版本显式校验。""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.month import validate_month +from app.schemas.tplus_workbench import HandoverFact, _decimal + + +class PayrollMonthIn(BaseModel): + month: str + reason: str | None = Field(default=None, min_length=1, max_length=255) + + @field_validator("month") + @classmethod + def check_month(cls, value): + return validate_month(value) + + +class ShipmentOwnershipIn(BaseModel): + model_config = ConfigDict(extra="forbid") + shipment_item_id: int = Field(ge=1) + personnel_id: int = Field(ge=1) + manager_id: int | None = Field(default=None, ge=1) + unit_cost: str + + @field_validator("unit_cost") + @classmethod + def check_cost(cls, value): + _decimal(value, 4) + return value + + +class PayrollSetupIn(PayrollMonthIn): + model_config = ConfigDict(extra="forbid") + expected_version: int = Field(default=0, ge=0) + evidence: str = Field(min_length=1, max_length=500) + opening_balance: str + history_confirmed: Literal[True] + guidance_finalized: Literal[True] + shipment_ownerships: list[ShipmentOwnershipIn] = Field(min_length=1, max_length=10000) + handovers: list[HandoverFact] = Field(default_factory=list, max_length=1000) + + @field_validator("opening_balance") + @classmethod + def check_opening(cls, value): + _decimal(value, 2) + return value + + +class AllocationAdjustmentIn(BaseModel): + """分配差额仅允许归生成当月;关联已结算原台账留痕。""" + + ledger_id: int = Field(ge=1) + personnel_id: int = Field(ge=1) + direction: Literal[1, 2] + amount: str = Field(pattern=r"^\d{1,12}(\.\d{1,2})?$") + reason: str = Field(min_length=1, max_length=255) diff --git a/backend/app/schemas/settlement.py b/backend/app/schemas/settlement.py index 5184c8d..d620070 100644 --- a/backend/app/schemas/settlement.py +++ b/backend/app/schemas/settlement.py @@ -62,6 +62,7 @@ class SettlementOut(BaseModel): total_payable: Decimal | None = None person_count: int approval_order_no: str | None = None + approval_required: bool = False submitter_name: str locked_at: datetime | datetime | None = None locked_by: str | None = None @@ -89,6 +90,7 @@ class AdjustmentCreateIn(BaseModel): description="金额(元,两位小数,恒正)", ) reason: str = Field(min_length=1, max_length=255, description="扣补说明") + source_ledger_id: int | None = Field(default=None, ge=1, description="分配差额所对应的已生成工资台账") @field_validator("settlement_month") @classmethod diff --git a/backend/app/schemas/tplus.py b/backend/app/schemas/tplus.py index 32c82ab..b79780f 100644 --- a/backend/app/schemas/tplus.py +++ b/backend/app/schemas/tplus.py @@ -77,6 +77,8 @@ class TPlusBatchOut(BaseModel): finished_at: datetime | None can_retry: bool error_summary: str | None = None + trigger_type: str = "MANUAL" + automatic_attempt: int = 0 class TPlusBatchDetailOut(TPlusBatchOut): @@ -97,6 +99,25 @@ class TPlusErrorOut(BaseModel): create_time: datetime +class TPlusAutomationAlert(BaseModel): + code: str + message: str + batch_no: str | None = None + + +class TPlusAutomationOverview(BaseModel): + enabled: bool = False + weekday: int = 0 + time: str = "02:00" + timezone: str = "Asia/Shanghai" + worker_phase: str = "UNKNOWN" + heartbeat_at: datetime | None = None + last_success_at: datetime | None = None + next_retry_at: datetime | None = None + stop_requested: bool = False + alerts: list[TPlusAutomationAlert] = Field(default_factory=list) + + class TPlusOverviewOut(BaseModel): """配置就绪不等同于源库连通或兼容验收通过。""" @@ -107,3 +128,4 @@ class TPlusOverviewOut(BaseModel): compatibility_status: str = "PENDING" business_import_enabled: bool = False latest_audit: dict | None = None + automation: TPlusAutomationOverview = Field(default_factory=TPlusAutomationOverview) diff --git a/backend/app/schemas/tplus_workbench.py b/backend/app/schemas/tplus_workbench.py new file mode 100644 index 0000000..5fefd72 --- /dev/null +++ b/backend/app/schemas/tplus_workbench.py @@ -0,0 +1,253 @@ +"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。""" + +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.response import PageQuery + + +class FactData(BaseModel): + """不同类型通过事实入口分别校验,保留未确定值而不猜默认口径。""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +class PersonFact(FactData): + code: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=64) + role_code: Literal["salesperson", "sales_manager"] + status: Literal[0, 1] + + +class ProductFact(FactData): + category: Literal["municipal_water", "rail_transit"] + unit: str = Field(min_length=1, max_length=16) + + +class CustomerFact(FactData): + customer_type: Literal["personal", "manager_personal", "enterprise"] + owner_name: str = Field(min_length=1, max_length=64) + + +class GroupFact(FactData): + customer_id: str = Field(pattern=r"^[0-9]+$", max_length=64) + shipment_ids: list[str] = Field(default_factory=list, max_length=10000) + receipt_ids: list[str] = Field(default_factory=list, max_length=10000) + scope_confirmed: bool = False + opening_confirmed: bool = False + opening_balance: str | None = None + historical_settlement_confirmed: bool = False + excluded_receipt_ids: list[str] = Field(default_factory=list, max_length=10000) + guidance_month_basis: Literal["SHIPMENT", "RECEIPT"] | None = "SHIPMENT" + prepayment_parameter_basis: Literal["SHIPMENT", "RECEIPT"] | None = None + august_confirmed: bool = False + + @model_validator(mode="after") + def check_group(self): + """只允许显式数字源ID,不能通过组成员重叠重复消耗资金。""" + for values in (self.shipment_ids, self.receipt_ids, self.excluded_receipt_ids): + if len(set(values)) != len(values) or any( + not v.isascii() or not v.isdigit() for v in values + ): + raise ValueError("平账组成员必须是无重复的源数字ID") + if set(self.excluded_receipt_ids) - set(self.receipt_ids): + raise ValueError("历史已结算款必须属于本组所选回款") + if self.opening_balance is not None: + _decimal(self.opening_balance, 2) + if self.opening_confirmed and self.opening_balance is None: + raise ValueError("确认期初应收时必须填写余额,已核实无应收请填0") + return self + + +def _decimal(value: str, scale: int): + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError("金额格式不正确") from None + if not amount.is_finite() or amount < 0 or amount > Decimal("999999999999"): + raise ValueError("金额必须是有效的非负数") + if amount.as_tuple().exponent < -scale: + raise ValueError(f"最多保留{scale}位小数") + return amount + + +class GuidanceFact(FactData): + product_id: str = Field(pattern=r"^[0-9]+$", max_length=64) + month: str = Field(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$") + basis: Literal["UNIT", "LINE_TOTAL"] + amount: str + unit: str = Field(min_length=1, max_length=16) + shipment_line_id: str | None = None + is_finalized: bool = False + + @model_validator(mode="after") + def check_amount(self): + _decimal(self.amount, 4 if self.basis == "UNIT" else 2) + if self.basis == "LINE_TOTAL" and not self.shipment_line_id: + raise ValueError("明细总成本必须指定发货明细ID") + return self + + +class ReceiptFact(FactData): + voucher_confirmed: bool + voucher_reference: str = Field(min_length=1, max_length=128) + + +class ShipmentFact(FactData): + """运费是否已剔除须逐明细确认,不将未知运费当作0。""" + + sales_amount_excludes_freight: bool + freight_amount: str | None = None + business_key: str = Field(min_length=1, max_length=64) + personnel_id: str = Field(min_length=1, max_length=64) + date_override: date | None = None + + @model_validator(mode="after") + def check_date_rule(self): + if self.freight_amount is not None: + freight = _decimal(self.freight_amount, 2) + if self.sales_amount_excludes_freight and freight != 0: + raise ValueError("已剔除运费的销售额不能再次扣除运费") + if self.date_override is not None: + raise ValueError("日期更正规则尚未确认,本轮只采用源销货单日期") + return self + + +class HandoverFact(FactData): + business_key: str = Field(min_length=1, max_length=64) + mode: Literal["RESIGNATION", "ACTIVE"] + from_personnel_id: str = Field(min_length=1, max_length=64) + to_personnel_id: str = Field(min_length=1, max_length=64) + effective_date: date + shipping_ownership_confirmed: bool = False + + +class ManagerFact(FactData): + business_key: str = Field(min_length=1, max_length=64) + manager_id: str = Field(min_length=1, max_length=64) + effective_date: date + + +class CorrectionFact(FactData): + """已核对的 T+ 更正单处理事实。 + + 更正单仍保留在源快照中;绩效核算只消费人工明确排除的发货, + 跨客户回款则通过 redirect_business_key 归入原业务,避免在新客户下重复计提。 + """ + + entity: Literal["SHIPMENT", "RECEIPT"] + pair_key: str = Field(min_length=1, max_length=128) + exclude_performance: bool = False + redirect_business_key: str | None = Field(default=None, max_length=128) + + @model_validator(mode="after") + def check_correction(self): + if self.entity == "SHIPMENT" and not self.exclude_performance: + raise ValueError("更正发货必须明确排除绩效核算") + if self.entity == "RECEIPT" and not self.redirect_business_key: + raise ValueError("跨客户回款必须指定归入的原业务标识") + if self.entity == "SHIPMENT" and self.redirect_business_key: + raise ValueError("更正发货不能通过回款归属字段转移业务") + return self + + +FACT_TYPES = { + "PERSONNEL": PersonFact, + "PRODUCT": ProductFact, + "CUSTOMER": CustomerFact, + "GROUP": GroupFact, + "GUIDANCE": GuidanceFact, + "RECEIPT": ReceiptFact, + "SHIPMENT": ShipmentFact, + "HANDOVER": HandoverFact, + "MANAGER": ManagerFact, + "CORRECTION": CorrectionFact, +} + + +class FactIn(BaseModel): + """追加资料版本;expected_version=0 表示首次录入。""" + + model_config = ConfigDict(extra="forbid") + kind: Literal[ + "PERSONNEL", + "PRODUCT", + "CUSTOMER", + "GROUP", + "GUIDANCE", + "RECEIPT", + "SHIPMENT", + "HANDOVER", + "MANAGER", + "CORRECTION", + ] + fact_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$") + expected_version: int = Field(default=0, ge=0) + evidence: str = Field(min_length=1, max_length=500) + data: dict + + @model_validator(mode="after") + def validate_data(self): + self.evidence = self.evidence.strip() + if not self.evidence: + raise ValueError("确认依据不能为空") + self.data = FACT_TYPES[self.kind].model_validate(self.data).model_dump(mode="json") + return self + + +class WorkCreateIn(BaseModel): + """创建异步工作;所有参数固定后再排队,重试须使用新的请求键。""" + + model_config = ConfigDict(extra="forbid") + kind: Literal["MASTER_PREVIEW", "MASTER_APPLY", "CALC_PREVIEW", "BUSINESS_PREVIEW", "BUSINESS_APPLY"] + request_key: UUID + entity_types: list[Literal["personnel", "products", "customers"]] = Field(default_factory=list) + source_ids: list[str] = Field(default_factory=list, max_length=10000) + preview_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$") + replay_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$") + group_keys: list[str] = Field(default_factory=list, max_length=100) + month: str | None = Field(default=None, pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$") + + @model_validator(mode="after") + def check_intent(self): + if self.kind == "MASTER_PREVIEW" and not self.entity_types: + raise ValueError("请选择基础数据类型") + if self.kind == "MASTER_APPLY" and not self.preview_no: + raise ValueError("执行导入前必须完成预览") + if self.kind == "BUSINESS_APPLY" and not self.preview_no: + raise ValueError("正式业务导入必须先预览") + if self.kind == "BUSINESS_PREVIEW" and (not self.month or not self.group_keys): + raise ValueError("请选择正式导入月份及平账组") + if self.replay_no and (self.kind != "CALC_PREVIEW" or self.month or self.group_keys): + raise ValueError("冻结重跑仅适用于核算预演,不能同时指定新月份或平账组") + if ( + self.kind == "CALC_PREVIEW" + and not self.replay_no + and (not self.month or not self.group_keys) + ): + raise ValueError("请选择月份及已登记的平账组") + if len(set(self.entity_types)) != len(self.entity_types) or len( + set(self.group_keys) + ) != len(self.group_keys): + raise ValueError("请求范围不能重复") + if any(not v.isascii() or not v.isdigit() for v in self.source_ids): + raise ValueError("源ID格式不正确") + return self + + +class WorkQuery(PageQuery): + """运行、资料和明细共用的有界分页参数。""" + + kind: str | None = Field(default=None, max_length=32) + action: str | None = Field(default=None, max_length=32) + + +class SourceChoiceQuery(PageQuery): + """将筛选与分页一起声明,保证查询参数使用同一扁平格式。""" + + entity: Literal["personnel", "products", "customers", "shipments", "shipment_items", "receipts"] + search: str = Field(default="", max_length=64) diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py index 08b60a3..bc784db 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -93,6 +93,13 @@ def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]: admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表 的可见性一致);财务/领导只看到本节点可操作的条目。 """ + from app.core.config import get_settings + + if ( + not get_settings().payroll_allow_legacy_calculation + and not get_settings().payroll_approval_enabled + ): + return () codes = set(role_codes) if ROLE_ADMIN in codes: return _PENDING_STATUSES @@ -129,8 +136,14 @@ def submit_order( 落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是 送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。 """ - _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) + from app.core.config import get_settings + + if not get_settings().payroll_allow_legacy_calculation and ( + not get_settings().payroll_approval_enabled or not is_enabled(session, order_type) + ): + raise BadRequestError("审核功能未开启,不创建审批单或自动通过痕迹") + _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) order = ApprovalOrder( order_no=next_no( session, @@ -250,6 +263,13 @@ def act( """ codes = set(user_repository.get_role_codes(operator)) verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT + from app.core.config import get_settings + + if ( + not get_settings().payroll_allow_legacy_calculation + and not get_settings().payroll_approval_enabled + ): + raise BadRequestError("当前工资流程无需系统审批,历史记录仅供查看") with audit_service.audit( session, operator=operator, diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index db5d148..b1d0576 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -11,11 +11,13 @@ from datetime import UTC, datetime from sqlalchemy.orm import Session +from app.core.config import get_settings from app.core.exceptions import AuthError, BadRequestError, ForbiddenError from app.core.security import create_access_token, decode_token, verify_password from app.repositories import login_log_repository, user_repository from app.schemas.auth import LoginOut, UserBrief from app.services import captcha_service, login_limiter, token_blacklist +from app.services.payroll_access import can_write_payroll logger = logging.getLogger(__name__) @@ -143,7 +145,9 @@ def login( access_token=token, expires_in=expires_in, user=UserBrief( - id=user.id, username=user.username, real_name=user.real_name, roles=role_codes + id=user.id, username=user.username, real_name=user.real_name, roles=role_codes, + can_write_payroll=can_write_payroll(user), + payroll_approval_enabled=get_settings().payroll_approval_enabled, ), ) diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 86d604d..7abb11f 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -299,6 +299,10 @@ def _build_allocation_rows( 余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人 自分份额则双重计入) """ + from app.core.config import get_settings + + if items and not old_ids and not get_settings().payroll_allow_legacy_calculation: + raise BadRequestError("新核算按每人发货对应绩效,请在正式核算资料中维护发货归属,不再录入任意比例") business = business_repository.get_by_id(session, business_id) if business is None: raise NotFoundError("业务不存在") @@ -476,6 +480,10 @@ def update_business( :raises NotFoundError: 业务不存在 :raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户 """ + from app.services import payroll_months + + allocation_month = payroll_months.current_month() + payroll_months.lock_month(session, allocation_month, operator) row = business_repository.get_with_names(session, business_id, for_update=True) business = row[0] if row else None with audit_service.audit( @@ -527,6 +535,12 @@ def update_business( payload.secondary_allocations, old_ids={a.personnel_id for a in old_allocations}, ) + if _plan_changed(old_allocations, new_rows): + from app.core.config import get_settings + + if not get_settings().payroll_allow_legacy_calculation: + raise BadRequestError("历史比例方案仅供查看,新分配请维护逐笔发货归属") + payroll_months.ensure_allocation_editable(session, allocation_month, operator) if not payload.secondary_allocations: business_repository.replace_allocations(session, business_id, []) elif _plan_changed(old_allocations, new_rows): @@ -539,7 +553,9 @@ def update_business( # - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存 # 刷出「撤回+新建」的审批留痕噪音) # - 其余 → 提新单(内含撤旧建新) - if payload.secondary_allocations: + from app.core.config import get_settings + + if payload.secondary_allocations and get_settings().payroll_allow_legacy_calculation: snapshot = approval_service.allocation_content(session, business) or None if not approval_service.pending_matches_snapshot( session, @@ -563,7 +579,7 @@ def update_business( if _is_duplicate_entry(exc): raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc raise - else: + elif get_settings().payroll_allow_legacy_calculation: approval_service.withdraw_pending_orders( session, order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index 8fb740c..f94416c 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -95,6 +95,18 @@ def calc_receipt( 本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 → 计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。 """ + from app.core.config import get_settings + from app.services import payroll_calculation + + receipt = session.get(Receipt, receipt_id) + if receipt is None: + raise BadRequestError("回款不存在") + if payroll_calculation.latest_input(session, receipt.business_id): + result = payroll_calculation.calculate(session, receipt.business_id, + receipt.receipt_date.strftime("%Y-%m"), operator=operator, meta=meta, reason=recalc_reason) + return CalcReceiptOut(ledger=_to_out(session.get(PerformanceLedger, result["ledger_ids"][0])), repeated=False) + if not get_settings().payroll_allow_legacy_calculation: + raise BadRequestError("请在业务详情的正式核算入口准备资料;旧单批次核算已停用") is_recalc = recalc_reason is not None action = "重算" if is_recalc else "核算" with audit_service.audit( diff --git a/backend/app/services/manager_share.py b/backend/app/services/manager_share.py index 37cf1fe..e88f3df 100644 --- a/backend/app/services/manager_share.py +++ b/backend/app/services/manager_share.py @@ -98,6 +98,11 @@ def collect_team_buckets( """ managers: dict[int, dict] = {} for row in ledger_rows: + snapshot = getattr(row, "snapshot_json", None) or {} + if snapshot.get("engine") == "FIFO": + if not snapshot.get("manager_own_excluded") and snapshot.get("manager_id") is not None: + add_team_performance(managers, int(snapshot["manager_id"]), row) + continue if row.customer_type == "manager_personal": if include_manager_personal: add_team_performance(managers, row.personnel_id, row) @@ -118,7 +123,8 @@ def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decima ( r.performance_amount or Decimal("0") for r in ledger_rows - if include_manager_personal or r.customer_type != "manager_personal" + if not (getattr(r, "snapshot_json", None) or {}).get("manager_own_excluded") + and (include_manager_personal or r.customer_type != "manager_personal") ), Decimal("0"), ) diff --git a/backend/app/services/payroll_access.py b/backend/app/services/payroll_access.py new file mode 100644 index 0000000..2037576 --- /dev/null +++ b/backend/app/services/payroll_access.py @@ -0,0 +1,16 @@ +"""工资操作按已绑定的账号身份授权,管理员和岗位角色不构成旁路。""" + +from app.core.config import get_settings +from app.core.exceptions import ForbiddenError + + +def can_write_payroll(user) -> bool: + """未绑定制作人时关闭业务写入;停用账号不能操作。""" + maker = get_settings().payroll_maker_user_id + return maker is not None and user is not None and user.id == maker and user.status == 1 + + +def ensure_payroll_writer(user): + if not can_write_payroll(user): + raise ForbiddenError("仅指定的工资制作人可以修改业务数据") + return user diff --git a/backend/app/services/payroll_calculation.py b/backend/app/services/payroll_calculation.py new file mode 100644 index 0000000..96e3ba8 --- /dev/null +++ b/backend/app/services/payroll_calculation.py @@ -0,0 +1,420 @@ +"""正式FIFO核算:按月份固化逐段计提和累计可付差额,不覆盖历史工资。""" + +import copy +from collections import defaultdict +from datetime import date +from decimal import Decimal +from fractions import Fraction + +from sqlalchemy import func, select + +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import ( + Business, + BusinessRole, + PayrollAllocation, + PayrollInput, + PerformanceLedger, + Personnel, + Receipt, + Shipment, + ShipmentItem, +) +from app.services import audit_service, formula, payroll_months +from app.services.audit_service import AuditRecord +from app.services.param_service import resolve_snapshot +from app.services.tplus.calculation_preview import _parameters +from app.services.tplus.fifo import _beneficiary, amount_text, money, preview_group +from app.services.tplus.workbench import digest + + +def latest_input(session, business_id, *, for_update=False): + statement = ( + select(PayrollInput) + .where(PayrollInput.business_id == business_id) + .order_by(PayrollInput.version.desc()) + .limit(1) + ) + if for_update: + statement = statement.with_for_update().execution_options(populate_existing=True) + return session.scalar(statement) + + +def _event_key(line): + return digest([line.get("receipt_id"), line.get("shipment_id"), line.get("line_id")]) + + +def _previous(session, business_id, month): + rows = list( + session.scalars( + select(PerformanceLedger) + .where( + PerformanceLedger.business_id == business_id, + PerformanceLedger.calc_type == 1, + PerformanceLedger.is_active == 1, + ) + .order_by(PerformanceLedger.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ) + if any(r.settlement_month >= month for r in rows): + raise BadRequestError("该业务本月或后续月份已有核算;重算需先处理未生成工资的后续月份") + events = {} + paid = defaultdict(lambda: Decimal(0)) + for row in rows: + snapshot = row.snapshot_json or {} + if snapshot.get("engine") != "FIFO": + raise BadRequestError("该业务存在旧口径台账,请先核对历史承接,不能混算") + key = snapshot["event_key"] + if snapshot.get("first_accrual"): + events[key] = snapshot["event"] + paid[key] += row.payable_amount or Decimal(0) + return events, paid + + +def _inputs(session, record, until): + values = copy.deepcopy(record.data_json) + ships, cash = values["shipments"], values["receipts"] + for row in cash: + if row.get("exclusion"): + continue + current = session.get(Receipt, values["maps"]["receipts"].get(str(row["source_id"]))) + if ( + current is None + or current.amount != money(row["amount"]) + or current.receipt_date.isoformat() != row["date"] + or current.business_id != record.business_id + ): + raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本") + for row in ships: + item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"])) + if item_id is not None: + item = session.get(ShipmentItem, item_id) + head = session.get(Shipment, item.shipment_id) if item else None + if ( + item is None + or head is None + or item.amount != money(row["amount"]) + or head.ship_date.isoformat() != row["date"] + or head.business_id != record.business_id + ): + raise BadRequestError("正式发货与核算资料不一致,请重新核对资料版本") + days = {row["date"] for row in [*ships, *cash] if row["date"] <= until.isoformat()} + params = _parameters(session, days) + params["prepayment_parameter_basis"], params["august_confirmed"] = "RECEIPT", True + result = preview_group(values["group"], ships, cash, values["facts"], params, until) + if result["status"] != "SUCCEEDED": + reasons = set(result.get("reasons", [])) + reasons.update( + reason + for line in result["lines"] + for reason in line.get("reasons", []) + if line["status"] == "PENDING" + ) + raise BadRequestError("核算资料待补:" + ",".join(sorted(reasons))) + return values, result + + +def _post( + session, + business, + record, + event, + key, + month, + owner, + manager, + performance, + payable, + weight_r, + first, + version, + reason=None, +): + person = session.get(Personnel, owner) + if person is None: + raise BadRequestError("正式归属人员不存在") + role = session.get(BusinessRole, person.business_role_id) + if role is None: + raise BadRequestError("正式归属人员岗位缺失") + receipt_id = record.data_json["maps"]["receipts"].get(str(event["receipt_id"])) + if receipt_id is None: + raise BadRequestError("回款尚未正式导入") + row = PerformanceLedger( + receipt_id=receipt_id, + calc_version=version, + allocation_key=key, + is_active=1, + calc_type=1, + business_id=business.id, + business_no=business.business_no, + personnel_id=owner, + personnel_name=person.personnel_name, + role_code=role.role_code, + customer_type=event["customer_type"], + product_category=event["category"], + ship_date=date.fromisoformat(event["shipment_date"]), + receipt_date=date.fromisoformat(event["receipt_date"]), + payment_days=event["days"], + rate_text=event["rate"], + converted_profit=money(event["converted_profit"]) if first else Decimal(0), + performance_amount=performance, + payable_amount=payable, + barter_applied=0, + weight_r=weight_r, + payment_weight=formula.to_decimal(formula.payment_weight(weight_r)), + settlement_month=month, + is_probation=person.is_probation, + missing_cost=0, + calc_reason=reason, + snapshot_json={ + "engine": "FIFO", + "input_id": record.id, + "event_key": key, + "event": event, + "first_accrual": first, + "manager_id": manager, + "manager_own_excluded": event.get("excluded_manager_own", False), + "payable_delta": amount_text(payable), + "rule_version": "2026-10-30.2", + "recalc_reason": reason, + }, + ) + session.add(row) + session.flush() + session.add( + PayrollAllocation( + ledger_id=row.id, + business_id=business.id, + line_no=1, + input_id=record.id, + amount=money(event["amount"]) if first else Decimal(0), + data_json={ + **event, + "event_key": key, + "first_accrual": first, + "performance_delta": amount_text(performance), + "payable_delta": amount_text(payable), + "beneficiary_id": owner, + "manager_id": manager, + "recalc_reason": reason, + }, + ) + ) + return row + + +def calculate(session, business_id, month, *, operator, meta=None, reason=None): + """重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。""" + from app.services.manager_share import month_end + + month_end(month) + if reason is not None and not reason.strip(): + raise BadRequestError("重算原因不能为空") + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module="绩效核算", + action="正式FIFO重算" if reason else "正式FIFO核算", + summary=f"业务 #{business_id} 核算 {month}", + request_data={"business_id": business_id, "month": month, "reason": reason}, + ), + meta=meta, + ): + months = {month} + if reason: + months.update( + session.scalars( + select(PerformanceLedger.settlement_month).where( + PerformanceLedger.business_id == business_id, + PerformanceLedger.calc_type == 1, + PerformanceLedger.settlement_month >= month, + ) + ) + ) + for current_month in sorted(months): + payroll_months.ensure_allocation_editable(session, current_month, operator) + business = session.get(Business, business_id, with_for_update=True) + if business is None: + raise NotFoundError("业务不存在") + record = latest_input(session, business_id, for_update=True) + if record is None: + raise BadRequestError("请先补录该业务的正式核算资料") + if reason: + old_rows = list( + session.scalars( + select(PerformanceLedger) + .where( + PerformanceLedger.business_id == business_id, + PerformanceLedger.is_active == 1, + PerformanceLedger.calc_type == 1, + PerformanceLedger.settlement_month >= month, + ) + .with_for_update() + ) + ) + if not any(r.settlement_month == month for r in old_rows): + raise BadRequestError("该月尚无可重算台账") + if {r.settlement_month for r in old_rows} - months: + raise BadRequestError("后续月份在核算期间变化,请刷新后重试") + for old in old_rows: + if old.settlement_id is not None: + raise BadRequestError("后续月份已有工资生成,原工资保留,请走当月调整单") + old.is_active = 0 + session.flush() + outputs = [ + _calculate_month( + session, business, record, current_month, reason.strip() if reason else None + ) + for current_month in sorted(months) + ] + out = outputs[0] + out["recalculated_months"] = sorted(months) if reason else [] + session.commit() + return out + + +def _calculate_month(session, business, record, month, reason=None): + from app.services.manager_share import month_end + + business_id = business.id + until = month_end(month) + previous, paid = _previous(session, business_id, month) + values, result = _inputs(session, record, until) + active_lines = [line for line in result["lines"] if line["kind"] == "SHIPMENT"] + current = {} + for line in active_lines: + key = _event_key(line) + if key in previous: + if any( + str(line.get(field)) != str(previous[key].get(field)) + for field in ("amount", "shipment_date", "receipt_date") + ): + raise BadRequestError("历史分配金额或日期已变化,请通过更正或调整处理") + current[key] = previous[key] + elif line["effective_date"][:7] < month: + raise BadRequestError(f"请先完成 {line['effective_date'][:7]} 的业务核算") + else: + ship = next(s for s in values["shipments"] if str(s["line_id"]) == str(line["line_id"])) + current[key] = { + **line, + "category": ship["category"], + "customer_type": ship["customer_type"], + "source_owner": ship["personnel_id"], + "business_key": ship["business_key"], + } + if set(previous) - current.keys(): + raise BadRequestError("历史已核算事件发生变化,请核对更正或调整,不可直接覆盖") + sales = sum( + ( + money(s["amount"]) + for s in values["shipments"] + if s["date"] <= until.isoformat() and not s.get("performance_excluded") + ), + Decimal(0), + ) + matched = sum((money(line["amount"]) for line in active_lines), Decimal(0)) + snapshot = resolve_snapshot(session, until) + threshold = Fraction(snapshot.get("payment.start.ratio")) + weight_r = formula.clamp_weight_r(matched, sales) + start_ok = formula.start_payment_ok(matched, sales, threshold) + weight = formula.payment_weight(weight_r) if start_ok else Fraction(0) + rows = [] + receipt_versions = {} + for key, event in current.items(): + first = key not in previous + performance = money(event["performance"]) + desired = formula.to_decimal(Fraction(performance) * weight).quantize( + Decimal("0.01"), rounding="ROUND_HALF_UP" + ) + delta = desired - paid[key] + if not first and delta == 0: + continue + target = _beneficiary( + {"personnel_id": event["personnel_id"], "business_key": event["business_key"]}, + {}, + values["facts"].get("handovers", []), + until.isoformat(), + ) + owner = values["maps"]["personnel"].get(str(target)) + if owner is None: + raise BadRequestError("接手人员尚未正式导入") + source_manager = event.get("manager_id") + manager = values["maps"]["personnel"].get(str(source_manager)) if source_manager else None + if not event.get("excluded_manager_own") and not manager: + raise BadRequestError("请补录该业务在发货时的经理分管关系") + rid = values["maps"]["receipts"].get(str(event["receipt_id"])) + if rid not in receipt_versions: + receipt_versions[rid] = ( + int( + session.scalar( + select(func.max(PerformanceLedger.calc_version)).where( + PerformanceLedger.receipt_id == rid + ) + ) + or 0 + ) + + 1 + ) + rows.append( + _post( + session, + business, + record, + event, + key, + month, + owner, + manager, + performance if first else Decimal(0), + delta, + weight_r, + first, + receipt_versions[rid], + reason=reason, + ) + ) + if not rows and not reason: + raise BadRequestError("本月没有新增计提或应付差额") + for row in rows: + row.calc_reason = reason + if reason: + row.snapshot_json = {**row.snapshot_json, "recalc_reason": reason} + for rid in receipt_versions: + session.get(Receipt, rid).settlement_status = 1 + session.flush() + out = { + "month": month, + "business_id": business_id, + "ledger_ids": [r.id for r in rows], + "performance": amount_text(sum((r.performance_amount for r in rows), Decimal(0))), + "payable": amount_text(sum((r.payable_amount for r in rows), Decimal(0))), + "start_payment_ok": start_ok, + } + return out + + +def allocation_page(session, ledger_id, query): + if session.get(PerformanceLedger, ledger_id) is None: + raise NotFoundError("台账不存在") + condition = PayrollAllocation.ledger_id == ledger_id + rows = session.scalars( + select(PayrollAllocation) + .where(condition) + .order_by(PayrollAllocation.line_no) + .offset(query.offset) + .limit(query.page_size) + ) + return PageResult( + records=[ + {"id": r.id, "input_id": r.input_id, "amount": str(r.amount), **r.data_json} + for r in rows + ], + total=session.scalar(select(func.count()).select_from(PayrollAllocation).where(condition)) + or 0, + page_num=query.page_num, + page_size=query.page_size, + ) diff --git a/backend/app/services/payroll_inputs.py b/backend/app/services/payroll_inputs.py new file mode 100644 index 0000000..59f942c --- /dev/null +++ b/backend/app/services/payroll_inputs.py @@ -0,0 +1,177 @@ +"""人工业务核算准备:复用已有单据,显式确认期初、成本与发货归属。""" + +from decimal import Decimal + +from sqlalchemy import select + +from app.core.exceptions import BadRequestError, NotFoundError +from app.models import ( + Business, + BusinessItem, + BusinessRole, + Customer, + PayrollInput, + Personnel, + Receipt, + Shipment, + ShipmentItem, +) +from app.services import audit_service, payroll_months +from app.services.audit_service import AuditRecord +from app.services.payroll_calculation import latest_input +from app.services.tplus.workbench import digest + + +def setup(session, business_id, payload, operator, meta=None): + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module="绩效核算", + action="核算资料版本", + summary=f"业务 #{business_id} 核算资料", + request_data=payload.model_dump(mode="json"), + ), + meta=meta, + ): + payroll_months.ensure_allocation_editable(session, payload.month, operator) + business = session.get(Business, business_id, with_for_update=True) + if business is None: + raise NotFoundError("业务不存在") + current = latest_input(session, business_id, for_update=True) + if current and current.source_database != "MANUAL": + raise BadRequestError("源导入业务请通过来源差异核对更新,不能用人工资料替换") + if (current.version if current else 0) != payload.expected_version: + raise BadRequestError("核算资料版本已变化,请刷新") + customer = session.get(Customer, business.customer_id) + heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id))) + by_head = {h.id: h for h in heads} + items = list( + session.scalars(select(ShipmentItem).where(ShipmentItem.shipment_id.in_(by_head))) + ) + assignments = {i.shipment_item_id: i for i in payload.shipment_ownerships} + if len(assignments) != len(payload.shipment_ownerships) or set(assignments) != { + i.id for i in items + }: + raise BadRequestError("请逐条核实全部发货明细归属,不可遗漏、重复或跨业务") + business_items = list( + session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)) + ) + roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))} + people = {p.id: p for p in session.scalars(select(Personnel))} + key = business.business_no + ships = [] + for item in items: + owner = assignments[item.id] + if owner.personnel_id not in people or owner.manager_id not in people: + raise BadRequestError("发货归属人员或分管经理不存在") + if roles.get(people[owner.manager_id].business_role_id) != "sales_manager": + raise BadRequestError("分管人员必须为营销经理") + candidates = [ + b + for b in business_items + if ( + b.product_id == item.product_id + if item.product_id + else b.product_name == item.product_name and b.spec_model == item.spec_model + ) + ] + if len(candidates) != 1: + raise BadRequestError("发货产品无法唯一确定核算分类,请先核对业务明细") + head = by_head[item.shipment_id] + if item.amount <= 0: + raise BadRequestError("请先补齐发货销售金额") + ships.append( + { + "source_id": str(head.id), + "line_id": str(item.id), + "source_no": head.shipment_no, + "date": head.ship_date.isoformat(), + "group_key": key, + "business_key": key, + "personnel_id": str(owner.personnel_id), + "manager_id": str(owner.manager_id), + "amount": str(item.amount), + "quantity": str(item.quantity), + "unit": item.unit, + "category": candidates[0].product_category, + "customer_type": customer.customer_type, + "cost_total": str( + (Decimal(owner.unit_cost) * item.quantity).quantize( + Decimal("0.01"), rounding="ROUND_HALF_UP" + ) + ), + "unit_cost": owner.unit_cost, + "cost_month_basis": "SHIPMENT", + "pending": [], + } + ) + receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id))) + if any(r.is_barter for r in receipts): + raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算") + if current: + previous_costs = {s["line_id"]: s for s in current.data_json["shipments"]} + for ship in ships: + old = previous_costs.get(ship["line_id"]) + if old and (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]): + raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改") + handovers = [h.model_dump(mode="json") for h in payload.handovers] + if any(h["business_key"] != key for h in handovers): + raise BadRequestError("交接记录不属于本业务") + values = { + "group": { + "key": key, + "scope_confirmed": True, + "opening_confirmed": True, + "opening_balance": payload.opening_balance, + "historical_settlement_confirmed": True, + }, + "shipments": ships, + "receipts": [ + { + "source_id": str(r.id), + "source_no": r.receipt_batch_no, + "date": r.receipt_date.isoformat(), + "group_key": key, + "amount": str(r.amount), + "voucher_confirmed": True, + "pending": [], + } + for r in receipts + ], + "facts": { + "personnel": { + str(p.id): {"role_code": roles.get(p.business_role_id), "status": p.status} + for p in people.values() + }, + "handovers": handovers, + }, + "maps": { + "receipts": {str(r.id): r.id for r in receipts}, + "personnel": {str(p.id): p.id for p in people.values()}, + "shipments": {str(h.id): h.id for h in heads}, + "shipment_items": {str(i.id): i.id for i in items}, + }, + "evidence": payload.evidence, + "guidance_finalized": True, + "as_of_month": payload.month, + } + # 旧资料包含的单据金额和归属保持稳定,新增版本用于新增发货与接手事实。 + record = PayrollInput( + business_id=business_id, + version=payload.expected_version + 1, + source_database="MANUAL", + group_key=key, + fingerprint=digest(values), + data_json=values, + requested_by=operator.id, + ) + session.add(record) + session.flush() + result = { + "input_id": record.id, + "version": record.version, + "fingerprint": record.fingerprint, + } + session.commit() + return result diff --git a/backend/app/services/payroll_months.py b/backend/app/services/payroll_months.py new file mode 100644 index 0000000..602cd61 --- /dev/null +++ b/backend/app/services/payroll_months.py @@ -0,0 +1,47 @@ +"""月份行锁同时保护工资生成与分配事实修改。""" + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.dialects.mysql import insert + +from app.core.exceptions import BadRequestError, ForbiddenError +from app.core.month import validate_month +from app.models import MonthlySettlement, PayrollMonth +from app.services.payroll_access import ensure_payroll_writer + + +def current_month() -> str: + return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m") + + +def lock_month(session, month, operator): + """INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。""" + ensure_payroll_writer(operator) + validate_month(month) + values = {"month": month, "maker_id": operator.id, "maker_name": operator.username} + if session.bind.dialect.name == "mysql": + stmt = insert(PayrollMonth).values(**values) + session.execute(stmt.on_duplicate_key_update(month=stmt.inserted.month)) + elif session.scalar(select(PayrollMonth.id).where(PayrollMonth.month == month)) is None: + session.add(PayrollMonth(**values)) + session.flush() + control = session.scalar( + select(PayrollMonth).where(PayrollMonth.month == month).with_for_update() + ) + if control.generated_at is not None and control.maker_id != operator.id: + raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成") + if control.generated_at is None: + control.maker_id, control.maker_name = operator.id, operator.username + return control + + +def ensure_allocation_editable(session, month, operator): + control = lock_month(session, month, operator) + # 兼容升级前已有月结;没有控制行不代表历史工资可以修改。 + old = session.scalar( + select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month) + ) + if control.generated_at is not None or old is not None: + raise BadRequestError("该月工资已生成,二次分配已锁定;差额请走当月调整单") + return control diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index ea44a74..c2d6f78 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -154,6 +154,16 @@ def department_summary(session, query) -> dict: manager_of, manager_role, person_info = _team_relations( session, member_map, manager_ids, adjustments ) + attributed_managers = {mid for a in adjustments.values() for mid in a.get("by_manager", {}) if mid is not None} + manager_ids = sorted(set(manager_ids) | attributed_managers) + frozen_rows = rows if any((getattr(r, "snapshot_json", None) or {}).get("engine") == "FIFO" for r in rows) or attributed_managers else None + for mid in attributed_managers: + manager_role[mid] = "sales_manager" + if frozen_rows is not None: + for row in frozen_rows: + snapshot = row.snapshot_json or {} + if snapshot.get("engine") == "FIFO" and snapshot.get("manager_id") is not None: + manager_role[int(snapshot["manager_id"])] = "sales_manager" # 只有调整单、本月无台账行的人:补成零值成员后参与归属 _merge_adjustment_only_members(member_map, adjustments, person_info) @@ -167,6 +177,7 @@ def department_summary(session, query) -> dict: "include_mp": include_mp, "share": share, "company_base": company_base, + "frozen_rows": frozen_rows, } teams_all = _build_teams(session, manager_ids, context) if query.manager_id is None: @@ -186,7 +197,10 @@ def department_summary(session, query) -> dict: for team in teams_all if query.manager_id is None or team["manager_id"] == query.manager_id ] - unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp) + unassigned_all = ( + _frozen_member_rows(None, context) if frozen_rows is not None + else _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp) + ) # 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关 unassigned = [] if query.manager_id is not None else unassigned_all company_share_amount = _company_share(company_base, share.company_pct) @@ -219,6 +233,47 @@ def department_summary(session, query) -> dict: } +def _frozen_member_rows(manager_id, context): + """按台账固化的业务分管拆成员,同一业务员可分别出现在多个团队。""" + targets = {} + source_rows = context["frozen_rows"] + for row in source_rows: + snap = row.snapshot_json or {} + if snap.get("engine") == "FIFO": + target = snap.get("manager_id") if not snap.get("manager_own_excluded") else None + else: + target = _regular_team_of(row.personnel_id, context["manager_of"], context["manager_role"]) + if row.customer_type == "manager_personal": + target = row.personnel_id if context["include_mp"] else None + targets[row.id] = target + selected = [r for r in source_rows if targets[r.id] == manager_id] + members = _member_map(selected) + local_adjustments = {} + for pid, adjustment in context["adjustments"].items(): + candidates = {targets[r.id] for r in source_rows if r.personnel_id == pid} + present = sorted(t for t in candidates if t is not None) + default_target = present[0] if present else ( + None if candidates else _regular_team_of(pid, context["manager_of"], context["manager_role"]) + ) + totals = {"extra": Decimal(0), "deduct": Decimal(0)} + selected_adjustment = adjustment.get("by_manager", {}).get(manager_id, {}) + for field in totals: + totals[field] += selected_adjustment.get(field, Decimal(0)) + if default_target == manager_id: + totals[field] += adjustment.get("unattributed", adjustment)[field] + local_adjustments[pid] = totals + if any(totals.values()) and pid not in members: + members[pid] = context["member_map"][pid] + output = [] + for pid, member in members.items(): + actual = [r for r in selected if r.personnel_id == pid] + output.append(_member_row(pid, member, local_adjustments, "regular", + performance=sum((r.performance_amount or Decimal(0) for r in actual), Decimal(0)), + payable=sum((r.payable_amount or Decimal(0) for r in actual), Decimal(0)), + ledger_ids=[r.id for r in actual], include_adjust=True)) + return output + + def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]: """按经理枚举生成团队行(含零成员团队)。""" teams = [] @@ -243,6 +298,8 @@ def _build_team(session, manager_id: int, context: dict) -> dict | None: context["adjustments"], context["include_mp"], ) + if context.get("frozen_rows") is not None: + member_rows = _frozen_member_rows(manager_id, context) team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0")) team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0")) team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0"))) @@ -397,12 +454,17 @@ def _adjustments_by_person(orders) -> dict[int, dict]: "extra": Decimal("0"), "deduct": Decimal("0"), "personnel_name": order.personnel_name, + "by_manager": {}, + "unattributed": {"extra": Decimal(0), "deduct": Decimal(0)}, }, ) if order.direction == ADJUSTMENT_DIRECTION_EXTRA: acc["extra"] += order.amount else: acc["deduct"] += order.amount + attribution = getattr(order, "attribution_json", None) + bucket = acc["by_manager"].setdefault(attribution.get("manager_id"), {"extra": Decimal(0), "deduct": Decimal(0)}) if attribution else acc["unattributed"] + bucket["extra" if order.direction == ADJUSTMENT_DIRECTION_EXTRA else "deduct"] += order.amount return by_person diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index e356360..ca1c237 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -82,6 +82,9 @@ def generate( ), meta=meta, ): + from app.services import payroll_months + + control = payroll_months.lock_month(session, month, operator) existing = settlement_repository.get_by_month(session, month) _guard_regeneration(existing, month) _release_previous_round(session, existing, month) @@ -103,6 +106,29 @@ def generate( # 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结 # 记成失败审计,与库中事实相反) + if control.generated_at is None: + from datetime import datetime + + control.generated_at = datetime.now() + from app.core.config import get_settings + + approval_enabled = get_settings().payroll_allow_legacy_calculation or ( + get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 2) + ) + settlement.snapshot_json = { + **settlement.snapshot_json, + "approval_required": approval_enabled, + } + if not approval_enabled: + # 2026-10-30 用户澄清:生成工资即完成,不建审批单或自动通过痕迹。 + settlement.status = SETTLEMENT_STATUS_APPROVED + settlement.locked_at = control.generated_at + settlement.locked_by = control.maker_name + settlement.approval_order_no = None + settlement.snapshot_json = { + "note": "工资生成即完成并锁定,仅供查看,无需系统审核", + "approval_required": False, + } session.flush() detail = _detail_of(session, settlement) session.commit() @@ -239,6 +265,13 @@ def submit( meta: RequestMeta | None = None, ) -> SettlementDetailOut: """提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。""" + from app.core.config import get_settings + + if ( + not get_settings().payroll_allow_legacy_calculation + and not get_settings().payroll_approval_enabled + ): + raise BadRequestError("工资生成后直接完成,仅供查看,无需系统审批") with audit_service.audit( session, operator=operator, @@ -456,7 +489,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) - if manager.business_role_id else None ) - if role is None or role.role_code != "sales_manager": + frozen_manager = any( + (r.snapshot_json or {}).get("engine") == "FIFO" + and (r.snapshot_json or {}).get("manager_id") == manager_id + for r in ledger_rows + ) + if not frozen_manager and (role is None or role.role_code != "sales_manager"): continue # 非营销经理的上级不参与聚合 aggregates.append(_aggregate_row(session, manager, bucket, company_base, share)) return aggregates @@ -509,6 +547,47 @@ def create_adjustment( from app.services.no_generator import next_no amount = _adjustment_amount(payload) + from app.core.config import get_settings + from app.models import PerformanceLedger + from app.services import payroll_months + + source_id = getattr(payload, "source_ledger_id", None) + source = session.get(PerformanceLedger, source_id) if source_id else None + if source_id and (source is None or source.settlement_id is None): + raise BadRequestError("分配调整必须引用已生成工资的原台账") + if source_id: + from app.models import PayrollInput + + snapshot = source.snapshot_json or {} + if source.calc_type != 1 or source.is_active != 1 or snapshot.get("engine") != "FIFO": + raise BadRequestError("分配调整必须引用有效的正式FIFO单笔台账") + original = session.get(PayrollInput, snapshot.get("input_id")) + if original is None: + raise BadRequestError("原台账的归属依据缺失") + allowed = {source.personnel_id} + latest = session.scalar( + select(PayrollInput) + .where(PayrollInput.business_id == source.business_id) + .order_by(PayrollInput.version.desc()) + .limit(1) + ) + values = (latest or original).data_json + business_key = snapshot.get("event", {}).get("business_key") + for handover in values.get("facts", {}).get("handovers", []): + if handover.get("business_key") == business_key: + for field in ("from_personnel_id", "to_personnel_id"): + target = ( + values.get("maps", {}).get("personnel", {}).get(str(handover.get(field))) + ) + if target is not None: + allowed.add(target) + if payload.personnel_id not in allowed: + raise BadRequestError("调整人员不属于该业务已登记的归属或交接范围") + adjustment_month = ( + payroll_months.current_month() + if source_id or not get_settings().payroll_allow_legacy_calculation + else payload.settlement_month + ) person = session.get(Personnel, payload.personnel_id) if person is None: @@ -532,7 +611,16 @@ def create_adjustment( column_name="order_no", prefix=NO_PREFIX_ADJUSTMENT, ), - settlement_month=payload.settlement_month, + settlement_month=adjustment_month, + adjustment_type="ALLOCATION" if source_id else "GENERAL", + source_ledger_id=source_id, + attribution_json={ + "business_id": source.business_id, + "manager_id": (source.snapshot_json or {}).get("manager_id"), + "original_personnel_id": source.personnel_id, + } + if source + else None, personnel_id=payload.personnel_id, personnel_name=person.personnel_name, direction=payload.direction, @@ -544,7 +632,14 @@ def create_adjustment( session.add(order) session.flush() - _wire_adjustment_approval(session, order, operator) + if get_settings().payroll_allow_legacy_calculation or ( + get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3) + ): + _wire_adjustment_approval(session, order, operator) + else: + from app.models.settlement import ADJUSTMENT_STATUS_EFFECTIVE + + order.status = ADJUSTMENT_STATUS_EFFECTIVE out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定) session.commit() logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"]) diff --git a/backend/app/services/tplus/README.md b/backend/app/services/tplus/README.md index 1027e1b..75c4b32 100644 --- a/backend/app/services/tplus/README.md +++ b/backend/app/services/tplus/README.md @@ -1,4 +1,4 @@ -# T+ 只读同步 +# T+ 读取、基础导入与核算预演 > AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。 @@ -9,6 +9,9 @@ - `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。 - `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。 - `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。 +- `source_heads/snapshots` 记录全量读取的当前源版本,包含哈希去重的 A→B→A;中断任务不发布部分指针。 +- `master_import` 按人员、产品、客户预览及导入;源、事实、目标变化时拒绝旧预览,保留人工核算字段。 +- `workbench` 保存人工事实版本、幂等任务和逐行结果,与原 worker 共用锁;`fifo/calculation_preview` 固定快照并计算隔离预演,支持原快照重跑。 ## 主要入口 @@ -20,4 +23,20 @@ ## 验收限制 -当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。 +本轮允许已确认的人员、产品、客户进入基础表;不写正式业务、发货、回款、绩效台账、月结、审批或发放记录,不推进时间水位。源库始终只读。日期、含税和人工指导成本已明确;生成凭证资格已按ARAP01来源与有效总账凭证关联核实,未审不等于未生成;平账范围、取价时点、历史余额、特殊更正仍按记录分别待补。 + +Docker 是当前开发主线,2008 R2 `10.50.1600` 验收后置;2022 副本不能替代同版本实测。实际导入结果、接口、缺驱动处理及启动方法见 `docs/T+基础导入与核算预演开发记录_2026-10-29.md`,字段证据见同日字段与规则差异表。 + +## 正式工资闭环扩展(2026-10-30,优先于上方上一阶段边界) + +已新增 BUSINESS_PREVIEW/BUSINESS_APPLY,允许核实后导入正式业务、发货和回款,正式核算通过独立工资入口执行。源 SQL Server 仍始终只读。新版按同一发货客户名头统一平账、按发货月份指导成本封存;不再按合同拆组或要求重复确认成本月份。 + +制作人身份由现有账号ID显式配置;审核能力保留但默认关闭,关闭时工资生成直接完成且不写审批步骤。多原单更正仍待讨论。字段、接口、迁移和最终验收边界见 `docs/正式工资闭环开发记录_2026-10-30.md`,客户回执原文见 `docs/客户补充回执核对_2026-10-30.md`。 + +## 自动采集与样本核对(2026-10-30) + +`automation.tick` 通过 `tplus-maintain --once` 排队最近到期周的九类原始采集,默认每周一北京时间 02:00 且关闭。明确断连/超时最多两次自动重试,间隔 5/15 分钟;部分失败、配置/资料错误及正式导入不自动重试。人工重试结果接入同一周期,独立心跳不受长查询阻塞。概况的 `automation` 提供状态及异常,不暴露连接凭据。 + +`tplus-materials --customer-id --month --connection-file --output-dir` 从恢复库只读形成样本核对包;空费用和旧初始化只能作为证据,不能推定零运费/切换余额。用户没有首轮样本人工指导成本,真实金额验收待补。 + +V20/V21 迁移 head 为 `21f2a3b4c5d6`,现有迁移保留,开发初始化同步。Windows 托管、默认关闭及停止边界见根目录 `scripts/windows/README.md`;本轮完整验证和未验收事项见 `docs/同步运维自动化与样本核对开发记录_2026-10-30.md`。未迁移原业务库或启用生产。 diff --git a/backend/app/services/tplus/automation.py b/backend/app/services/tplus/automation.py new file mode 100644 index 0000000..c720f77 --- /dev/null +++ b/backend/app/services/tplus/automation.py @@ -0,0 +1,273 @@ +"""周期检查只排队源采集:稳定意图键、有限重试、独立心跳和只读异常概况。""" + +import hashlib +import logging +import threading +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import NAMESPACE_URL, uuid4, uuid5 + +from sqlalchemy import func, inspect, select, text +from sqlalchemy.dialects.mysql import insert +from sqlalchemy.orm import sessionmaker + +from app.core.config import get_settings +from app.core.tplus_db import resolve_tplus_settings +from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError, TPlusWorkRun +from app.services.tplus import task_service +from app.services.tplus.errors import TRANSIENT_CODES +from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS + +logger = logging.getLogger(__name__) +SHANGHAI = timezone(timedelta(hours=8)) +ENTITIES = tuple(sorted(m.entity_type for m in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS))) + + +def local_now(): + return datetime.now(SHANGHAI).replace(tzinfo=None) + + +def source_identity(settings): + source = resolve_tplus_settings(settings) + value = f"{source.t_plus_host.casefold()}:{source.t_plus_port}/{source.t_plus_database}" + return hashlib.sha256(value.encode()).hexdigest(), source.t_plus_database + + +def due_period(now, weekday=0, clock="02:00"): + """取最近一次已到期时段;只补这一个周期,不逐周补发历史任务。""" + if now.tzinfo: + now = now.astimezone(SHANGHAI).replace(tzinfo=None) + hour, minute = map(int, clock.split(":")) + day = now.date() - timedelta(days=(now.weekday() - weekday) % 7) + due = datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute) + if due > now: + due -= timedelta(days=7) + year, week, _ = due.isocalendar() + return f"{year}-W{week:02d}", due + + +def intent_key(source_key, period, attempt=0): + return str(uuid5(NAMESPACE_URL, f"payroll:tplus:weekly:{source_key}:{period}:{attempt}")) + + +def _state(session, key, database, *, lock=False): + if lock and session.bind.dialect.name == "mysql": + stmt = insert(TPlusAutomationState).values(source_key=key, source_database=database) + session.execute(stmt.on_duplicate_key_update(source_key=stmt.inserted.source_key)) + statement = select(TPlusAutomationState).where(TPlusAutomationState.source_key == key) + if lock: + statement = statement.with_for_update().execution_options(populate_existing=True) + row = session.scalar(statement) + if row is None and lock: + row = TPlusAutomationState(source_key=key, source_database=database, retry_attempts=0, worker_phase="STOPPED") + session.add(row) + session.flush() + return row + + +@contextmanager +def maintenance_lock(session, key): + """维护检查独立于长采集锁;同一源的两个检查不能重复创建重试。""" + if session.bind.dialect.name != "mysql": + yield True + return + connection = session.get_bind().connect() + name = "ys:tplus:maintain:" + key[:32] + acquired = False + try: + acquired = connection.execute(text("SELECT GET_LOCK(:name,0)"), {"name": name}).scalar() == 1 + yield acquired + finally: + if acquired: + try: + if connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": name}).scalar() != 1: + connection.invalidate() + except Exception: + connection.invalidate() + connection.close() + + +def _has_active(session): + # 实际读取仍受原worker命名锁保护;排队检查防止周期任务积压。 + for model in (TPlusSyncBatch, TPlusWorkRun): + if session.scalar(select(model.id).where(model.status.in_(("QUEUED", "RUNNING"))).limit(1)) is not None: + return True + return False + + +def _enqueue(session, state, period, attempt, parent=None): + values = { + "batch_no": task_service.new_batch_no(), "request_key": intent_key(state.source_key, period, attempt), + "sync_mode": "FULL", "status": "QUEUED", "source_database": state.source_database, + "entity_types_json": list(ENTITIES), "parent_batch_no": parent, + "trigger_type": "AUTO_RETRY" if attempt else "SCHEDULED", "automatic_attempt": attempt, + "source_record_count": 0, "staged_record_count": 0, "failed_record_count": 0, "skipped_record_count": 0, + } + batch, inserted = task_service._insert(session, values) + if inserted and parent: + from app.repositories.tplus_repository import increment_retry_errors + + increment_retry_errors(session, parent) + state.scheduled_period = period + state.current_batch_no = batch.batch_no + state.retry_attempts = attempt + state.next_retry_at = None + if not attempt: + state.root_batch_no = batch.batch_no + return batch + + +def _cycle_current(session, state): + """锁住本周期重试树;人工恢复也算本周期进度,自动次数不能被重置。""" + if not state.root_batch_no: + return None + root = session.scalar(select(TPlusSyncBatch).where( + TPlusSyncBatch.batch_no == state.root_batch_no, + ).with_for_update().execution_options(populate_existing=True)) + if root is None: + return None + current, frontier, seen = root, [root.batch_no], {root.batch_no} + while frontier: + children = list(session.scalars(select(TPlusSyncBatch).where( + TPlusSyncBatch.parent_batch_no.in_(frontier), + TPlusSyncBatch.source_database == state.source_database, + ).order_by(TPlusSyncBatch.id).with_for_update().execution_options(populate_existing=True))) + frontier = [] + for child in children: + if child.batch_no in seen: + continue + seen.add(child.batch_no) + frontier.append(child.batch_no) + state.retry_attempts = max(state.retry_attempts or 0, child.automatic_attempt or 0) + if child.id > current.id: + current = child + state.current_batch_no = current.batch_no + return current + + +def tick(session, *, settings=None, now=None): + settings, now = settings or get_settings(), now or local_now() + if not settings.t_plus_automation_enabled: + return {"status": "DISABLED", "queued": False} + if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists(): + return {"status": "STOP_REQUESTED", "queued": False} + key, database = source_identity(settings) + with maintenance_lock(session, key) as acquired: + if not acquired: + return {"status": "BUSY", "queued": False} + session.rollback() + state = _state(session, key, database, lock=True) + state.maintenance_at = now + period, _ = due_period(now, settings.t_plus_schedule_weekday, settings.t_plus_schedule_time) + current = _cycle_current(session, state) + if current and current.status == "SUCCEEDED": + state.last_success_at = current.finished_at + state.next_retry_at = None + if _has_active(session): + session.commit() + return {"status": "DEFERRED", "queued": False, "period": period} + batch = None + if state.scheduled_period != period: + batch = _enqueue(session, state, period, 0) + elif current and current.status == "FAILED" and (state.retry_attempts or 0) < 2: + codes = set(session.scalars(select(TPlusSyncError.error_code).where(TPlusSyncError.batch_no == current.batch_no))) + if codes and codes <= TRANSIENT_CODES and current.finished_at is not None: + delay = (5, 15)[state.retry_attempts or 0] + state.next_retry_at = current.finished_at + timedelta(minutes=delay) + if now >= state.next_retry_at: + batch = _enqueue(session, state, period, (state.retry_attempts or 0) + 1, current.batch_no) + else: + state.next_retry_at = None + elif current and current.status in ("FAILED", "PARTIAL"): + state.next_retry_at = None + session.commit() + return {"status": "QUEUED" if batch else "CHECKED", "queued": batch is not None, + "batch_no": batch.batch_no if batch else None, "period": period} + + +def heartbeat(session_factory, settings, owner, phase, *, now=None, claim=False): + key, database = source_identity(settings) + with session_factory() as session: + state = _state(session, key, database, lock=True) + if state.worker_owner not in (None, owner) and not claim: + session.rollback() + return + state.worker_owner, state.worker_phase = owner, phase + state.heartbeat_at = now or local_now() + session.commit() + + +class WorkerHeartbeat: + """独立会话和线程写心跳,长查询不阻塞;只在worker已持采集锁时启动。""" + def __init__(self, session, phase="RUNNING", interval=30, owner=None): + self.factory = sessionmaker(session.get_bind(), expire_on_commit=False) + self.settings = get_settings() + self.owner, self.phase, self.interval = owner or str(uuid4()), phase, interval + self.stop = threading.Event() + self.thread = None + + def __enter__(self): + try: + source_identity(self.settings) + with self.factory() as session: + if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__): + return self + heartbeat(self.factory, self.settings, self.owner, self.phase, claim=True) + except Exception: + logger.warning("T+心跳暂不可写,请检查迁移与运行配置") + return self + self.thread = threading.Thread(target=self._pulse, name="tplus-heartbeat", daemon=True) + self.thread.start() + return self + + def _pulse(self): + while not self.stop.wait(self.interval): + try: + heartbeat(self.factory, self.settings, self.owner, self.phase) + except Exception: + logger.warning("T+进程心跳更新失败") + + def __exit__(self, *_args): + self.stop.set() + if self.thread: + self.thread.join(timeout=5) + try: + heartbeat(self.factory, self.settings, self.owner, "IDLE") + except Exception: + logger.warning("T+进程状态更新失败") + + +def overview(session, *, settings=None, now=None): + settings, now = settings or get_settings(), now or local_now() + base = {"enabled": settings.t_plus_automation_enabled, "weekday": settings.t_plus_schedule_weekday, + "time": settings.t_plus_schedule_time, "timezone": "Asia/Shanghai", "worker_phase": "UNKNOWN", + "heartbeat_at": None, "last_success_at": None, "next_retry_at": None, "alerts": []} + base["stop_requested"] = bool(settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists()) + try: + key, database = source_identity(settings) + except RuntimeError: + return base + if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__): + base["alerts"] = [{"code": "MIGRATION_REQUIRED", "message": "运行状态结构尚未升级"}] + return base + state = _state(session, key, database) + if state: + base.update(worker_phase=state.worker_phase, heartbeat_at=state.heartbeat_at, + last_success_at=state.last_success_at, next_retry_at=state.next_retry_at) + alerts = base["alerts"] + latest = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database).order_by(TPlusSyncBatch.id.desc()).limit(1)) + if latest and latest.status in ("FAILED", "PARTIAL"): + alerts.append({"code": "SYNC_REQUIRES_ATTENTION", "message": "最近采集失败或部分失败,请核对后处理", "batch_no": latest.batch_no}) + queued = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database, + TPlusSyncBatch.status == "QUEUED", TPlusSyncBatch.create_time <= now - timedelta(minutes=15)).order_by(TPlusSyncBatch.id).limit(1)) + if queued: + alerts.append({"code": "QUEUE_OVERDUE", "message": "采集任务排队超过15分钟", "batch_no": queued.batch_no}) + last_success = session.scalar(select(func.max(TPlusSyncBatch.finished_at)).where(TPlusSyncBatch.source_database == database, TPlusSyncBatch.status == "SUCCEEDED", TPlusSyncBatch.sync_mode == "FULL")) + base["last_success_at"] = last_success + if settings.t_plus_automation_enabled and not base["stop_requested"]: + if last_success is None or now - last_success > timedelta(days=8): + alerts.append({"code": "SYNC_STALE", "message": "尚无成功采集或超过8天未成功同步"}) + if state is None or state.heartbeat_at is None or now - state.heartbeat_at > timedelta(minutes=2) or state.worker_phase == "STOPPED": + alerts.append({"code": "WORKER_OFFLINE", "message": "采集进程心跳中断,请检查托管任务"}) + return base diff --git a/backend/app/services/tplus/business_import.py b/backend/app/services/tplus/business_import.py new file mode 100644 index 0000000..9909790 --- /dev/null +++ b/backend/app/services/tplus/business_import.py @@ -0,0 +1,407 @@ +"""已核实的独立业务从源快照进入正式单据;预览、指纹、执行分别留痕。""" + +import copy +from collections import Counter +from datetime import date, datetime +from decimal import Decimal +from types import SimpleNamespace + +from sqlalchemy import select + +from app.models import ( + Business, + BusinessItem, + PayrollInput, + Personnel, + Product, + Receipt, + Shipment, + ShipmentItem, + TPlusSourceLink, + TPlusWorkLine, + User, +) +from app.services import payroll_months +from app.services.no_generator import next_no +from app.services.payroll_access import ensure_payroll_writer +from app.services.tplus.calculation_preview import execute_preview +from app.services.tplus.fifo import money +from app.services.tplus.workbench import WorkbenchError, digest, get_run + + +def _link(session, source, entity, source_id): + return session.scalar( + select(TPlusSourceLink).where( + TPlusSourceLink.source_database == source, + TPlusSourceLink.entity_type == entity, + TPlusSourceLink.source_id == str(source_id), + ) + ) + + +def _latest_input(session, business_id): + return session.scalar( + select(PayrollInput) + .where(PayrollInput.business_id == business_id) + .order_by(PayrollInput.version.desc()) + .limit(1) + ) + + +def _append_compatible(old, new): + """只能新增单据、补充事实;既有单据金额、日期与原发货归属变化进入差异处理。""" + for entity, fields, identity in ( + ( + "shipments", + ( + "date", + "amount", + "quantity", + "product_id", + "unit", + "personnel_id", + "business_key", + "performance_excluded", + "correction_pair_key", + ), + lambda r: (r["source_id"], r["line_id"]), + ), + ( + "receipts", + ("date", "amount", "voucher_confirmed", "exclusion", "redirect_business_key"), + lambda r: r["source_id"], + ), + ): + current = {identity(r): r for r in new[entity]} + for previous in old[entity]: + candidate = current.get(identity(previous)) + if candidate is None or any( + str(previous.get(f)) != str(candidate.get(f)) for f in fields + ): + return False + return all( + old["group"].get(k) == new["group"].get(k) + for k in ("opening_balance", "excluded_receipt_ids", "customer_id") + ) + + +def _target_state(session, link): + if link is None: + return None + business = session.get(Business, link.target_id) + if business is None: + raise WorkbenchError("BUSINESS_TARGET_MISSING") + tables = ( + (Business, Business.id == business.id), + (BusinessItem, BusinessItem.business_id == business.id), + (Shipment, Shipment.business_id == business.id), + (Receipt, Receipt.business_id == business.id), + ) + result = {} + for model, condition in tables: + result[model.__tablename__] = [ + {c.name: str(getattr(r, c.name)) for c in model.__table__.columns} + for r in session.scalars(select(model).where(condition).order_by(model.id)) + ] + return result + + +def _build(session, run): + preview = SimpleNamespace(input_json=run.input_json, source_database=run.source_database) + execute_preview(session, preview, save_lines=False, customer_scope=True) + frozen = preview.snapshot_json + rows = [] + targets = {} + seen_customers = set() + for key, values in frozen["calculation_inputs"].items(): + summary = preview.summary_json["groups"][key] + row = {"entity_type": "businesses", "source_id": key, "action": "PENDING", "reason": None} + rows.append(row) + if summary["status"] not in ("READY", "SUCCEEDED"): + row["reason"] = ",".join(summary.get("reasons", [])) or "BUSINESS_DATA_PENDING" + continue + customer_id = values["group"]["customer_id"] + if customer_id in seen_customers: + row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED" + continue + seen_customers.add(customer_id) + if any(not ship.get("guidance_finalized") for ship in values["shipments"]): + row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED" + continue + business_key = "customer:" + customer_id + row["business_key"] = business_key + row["input_hash"] = digest(values) + link = _link(session, run.source_database, "businesses", business_key) + targets[key] = _target_state(session, link) + if link: + current = _latest_input(session, link.target_id) + row["target_id"] = link.target_id + if current and current.fingerprint == row["input_hash"]: + row["action"] = "SKIP" + elif current and _append_compatible(current.data_json, values): + row["action"] = "UPDATE" + else: + row["action"], row["reason"] = "CONFLICT", "FORMAL_SOURCE_CHANGED" + else: + row["action"] = "CREATE" + for entity, ids in ( + ("shipments", values["group"]["shipment_ids"]), + ("receipts", values["group"]["receipt_ids"]), + ): + for source_id in ids: + linked = _link(session, run.source_database, entity, source_id) + if linked and ( + not link + or not current + or source_id not in current.data_json["maps"].get(entity, {}) + ): + row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED" + if not _link(session, run.source_database, "customers", values["group"]["customer_id"]): + row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED" + for ship in values["shipments"]: + if not _link(session, run.source_database, "products", ship["product_id"]): + row["action"], row["reason"] = "PENDING", "PRODUCT_IMPORT_REQUIRED" + frozen = {"calculation": frozen, "plan": rows, "targets": targets} + return rows, frozen, digest(frozen) + + +def _add_link(session, source, entity, source_id, target): + session.add( + TPlusSourceLink( + source_database=source, + entity_type=entity, + source_id=str(source_id), + target_id=target.id, + ) + ) + + +def _new_no(session, model, column, prefix): + return next_no(session, model=model, column_name=column, prefix=prefix) + + +def _create(session, run, row, frozen): + values = copy.deepcopy(frozen["calculation"]["calculation_inputs"][row["source_id"]]) + people = {} + for code in values["facts"]["personnel"]: + candidates = list( + session.scalars( + select(Personnel) + .join(TPlusSourceLink, TPlusSourceLink.target_id == Personnel.id) + .where( + TPlusSourceLink.source_database == run.source_database, + TPlusSourceLink.entity_type == "personnel", + Personnel.source_no == code, + ) + ) + ) + if len(candidates) == 1: + people[code] = candidates[0] + needed = {str(s["personnel_id"]) for s in values["shipments"]} + needed.update(str(s["manager_id"]) for s in values["shipments"] if s.get("manager_id")) + for handover in values["facts"]["handovers"]: + needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"]))) + if needed - people.keys(): + raise WorkbenchError("PERSONNEL_IMPORT_REQUIRED") + customer = _link(session, run.source_database, "customers", values["group"]["customer_id"]) + old_input = _latest_input(session, row["target_id"]) if row.get("target_id") else None + business = ( + session.get(Business, row["target_id"], with_for_update=True) + if row.get("target_id") + else Business( + business_no=_new_no(session, Business, "business_no", "YW"), + customer_id=customer.target_id, + owner_personnel_id=people[str(values["shipments"][0]["personnel_id"])].id, + business_date=min(date.fromisoformat(s["date"]) for s in values["shipments"]), + status=1, + is_special=0, + t_plus_id=row["business_key"], + source_no=row["business_key"], + sync_batch_no=run.run_no, + synced_at=datetime.now(), + ) + ) + session.add(business) + session.flush() + if old_input is None: + _add_link(session, run.source_database, "businesses", row["business_key"], business) + maps = ( + copy.deepcopy(old_input.data_json["maps"]) + if old_input + else {"shipments": {}, "shipment_items": {}, "receipts": {}, "personnel": {}} + ) + maps["personnel"].update({code: p.id for code, p in people.items()}) + freight_total, sales_total, cost_total = Decimal(0), Decimal(0), Decimal(0) + for source_id in values["group"]["shipment_ids"]: + lines = [s for s in values["shipments"] if s["source_id"] == source_id] + if not lines: + continue + if any(s.get("performance_excluded") for s in lines): + values.setdefault("excluded_corrections", []).append( + {"shipment_id": source_id, "lines": lines} + ) + continue + head = lines[0] + freight = sum((money(s.get("freight_amount") or "0") for s in lines), Decimal(0)) + if source_id in maps["shipments"]: + existing = session.get(Shipment, maps["shipments"][source_id], with_for_update=True) + if ( + existing is None + or existing.total_amount != sum((money(s["amount"]) for s in lines), Decimal(0)) + or existing.ship_date.isoformat() != head["date"] + ): + raise WorkbenchError("FORMAL_TARGET_CHANGED") + sales_total += existing.total_amount + freight_total += existing.freight_amount + cost_total += sum( + (money(s["cost_total"]) for s in lines if s.get("cost_total") is not None), + Decimal(0), + ) + continue + shipment = Shipment( + business_id=business.id, + shipment_no=_new_no(session, Shipment, "shipment_no", "SH"), + ship_date=date.fromisoformat(head["date"]), + total_amount=sum((money(s["amount"]) for s in lines), Decimal(0)), + freight_amount=freight, + source_no=head["source_no"], + t_plus_id=source_id, + sync_batch_no=run.run_no, + synced_at=datetime.now(), + ) + session.add(shipment) + session.flush() + maps["shipments"][source_id] = shipment.id + _add_link(session, run.source_database, "shipments", source_id, shipment) + for ship in lines: + product = session.get( + Product, + _link(session, run.source_database, "products", ship["product_id"]).target_id, + ) + quantity = money(ship["quantity"]) + amount = money(ship["amount"]) + cost = money(ship["cost_total"]) if ship.get("cost_total") is not None else None + item = ShipmentItem( + shipment_id=shipment.id, + product_id=product.id, + product_name=product.product_name, + spec_model=product.spec_model or "", + unit=ship["unit"], + quantity=quantity, + sale_price=(amount / quantity).quantize(Decimal("0.0001")), + amount=amount, + ) + session.add(item) + session.flush() + maps["shipment_items"][ship["line_id"]] = item.id + session.add( + BusinessItem( + business_id=business.id, + product_id=product.id, + product_name=product.product_name, + spec_model=product.spec_model or "", + unit=ship["unit"], + quantity=quantity, + sale_price=item.sale_price, + sale_amount=amount, + cost_amount=cost, + product_category=ship["category"], + ) + ) + sales_total += amount + cost_total += cost or Decimal(0) + freight_total += freight + received = Decimal(0) + for cash in values["receipts"]: + if cash.get("exclusion"): + continue + if cash["source_id"] in maps["receipts"]: + existing = session.get( + Receipt, maps["receipts"][cash["source_id"]], with_for_update=True + ) + if ( + existing is None + or existing.amount != money(cash["amount"]) + or existing.receipt_date.isoformat() != cash["date"] + ): + raise WorkbenchError("FORMAL_TARGET_CHANGED") + received += existing.amount + continue + receipt = Receipt( + business_id=business.id, + receipt_batch_no=_new_no(session, Receipt, "receipt_batch_no", "HK"), + receipt_date=date.fromisoformat(cash["date"]), + amount=money(cash["amount"]), + is_barter=0, + settlement_status=0, + source_no=cash["source_no"], + t_plus_id=cash["source_id"], + sync_batch_no=run.run_no, + synced_at=datetime.now(), + ) + session.add(receipt) + session.flush() + maps["receipts"][cash["source_id"]] = receipt.id + _add_link(session, run.source_database, "receipts", cash["source_id"], receipt) + received += receipt.amount + business.total_sale_amount = sales_total + business.total_ship_amount = sales_total + business.total_cost_amount = cost_total + business.total_received_amount = received + business.total_freight_amount = freight_total + values["maps"] = maps + values["source_facts"] = frozen["calculation"]["facts"] + session.add( + PayrollInput( + business_id=business.id, + version=old_input.version + 1 if old_input else 1, + source_database=run.source_database, + group_key=row["source_id"], + fingerprint=row["input_hash"], + data_json=values, + requested_by=run.requested_by, + ) + ) + row["target_id"] = business.id + + +def execute_business(session, run): + operator = session.get(User, run.requested_by) + ensure_payroll_writer(operator) + request = run + if run.kind == "BUSINESS_APPLY": + request = get_run(session, run.input_json["preview_no"], run.source_database) + if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"): + raise WorkbenchError("PREVIEW_REQUIRED") + payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator) + rows, frozen, fingerprint = _build(session, request) + if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint: + raise WorkbenchError("PREVIEW_STALE") + counts = Counter() + for i, row in enumerate(rows, 1): + if run.kind == "BUSINESS_APPLY" and row["action"] in ("CREATE", "UPDATE"): + try: + with session.begin_nested(): + _create(session, run, row, frozen) + except WorkbenchError as exc: + row["action"], row["reason"] = "PENDING", str(exc) + counts[row["action"]] += 1 + session.add( + TPlusWorkLine( + run_no=run.run_no, + line_no=i, + entity_type="businesses", + source_id=row["source_id"], + action=row["action"], + reason=row.get("reason"), + data_json=row, + ) + ) + run.snapshot_json, run.snapshot_hash = frozen, fingerprint + run.summary_json = { + "counts": dict(counts), + "total": len(rows), + "formal_business_written": run.kind == "BUSINESS_APPLY", + "formal_payroll_written": False, + } + run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED" diff --git a/backend/app/services/tplus/calculation_preview.py b/backend/app/services/tplus/calculation_preview.py new file mode 100644 index 0000000..8430520 --- /dev/null +++ b/backend/app/services/tplus/calculation_preview.py @@ -0,0 +1,541 @@ +"""固定真实源快照和人工事实,组装隔离的 FIFO 核算预演。""" + +from calendar import monthrange +from collections import Counter +from datetime import date +from decimal import InvalidOperation + +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.models import Customer, Product, TPlusSourceLink, TPlusSyncRecord, TPlusWorkLine +from app.services import param_service +from app.services.tplus.fifo import START, amount_text, money, preview_group +from app.services.tplus.snapshots import source_records, source_statement +from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts + + +def _parameters(session, days): + values = {} + for day in sorted(days): + snap = param_service.resolve_snapshot(session, date.fromisoformat(day)) + values[day] = { + "start": snap.get("salesperson.rate.start"), + "slope": snap.get("salesperson.rate.slope"), + "floor": snap.get("salesperson.rate.floor"), + "boundary": snap.integer("salesperson.days.boundary"), + "enterprise": snap.get("enterprise.rate"), + "thresholds": { + category: snap.get("product.threshold", product_category=category) + for category in ("municipal_water", "rail_transit") + }, + } + return values + + +def _group_records(session, source, entity, ids): + rows = source_records(session, source, entity, ids) + if {r.source_id for r in rows} != set(ids): + raise WorkbenchError("GROUP_SOURCE_MISSING") + return rows + + +def _guidance(facts, product_id, line_id, month): + candidates = [ + f.data_json + for (kind, _), f in facts.items() + if kind == "GUIDANCE" + and f.data_json["product_id"] == product_id + and f.data_json["month"] == month + and (f.data_json["basis"] == "UNIT" or f.data_json.get("shipment_line_id") == line_id) + ] + return candidates[0] if len(candidates) == 1 else None + + +def _shipments(session, run, key, group, facts): + headers = _group_records(session, run.source_database, "shipments", group["shipment_ids"]) + details = source_records( + session, run.source_database, "shipment_items", parent_ids=group["shipment_ids"] + ) + heads = {r.source_id: r for r in headers} + product_ids = {str(r.payload_json.get("idinventory")) for r in details} + product_targets = dict( + session.execute( + select(TPlusSourceLink.source_id, Product) + .join(Product, Product.id == TPlusSourceLink.target_id) + .where( + TPlusSourceLink.source_database == run.source_database, + TPlusSourceLink.entity_type == "products", + TPlusSourceLink.source_id.in_(product_ids), + ) + ).all() + ) + customer_target = session.scalar( + select(Customer) + .join(TPlusSourceLink, Customer.id == TPlusSourceLink.target_id) + .where( + TPlusSourceLink.source_database == run.source_database, + TPlusSourceLink.entity_type == "customers", + TPlusSourceLink.source_id == group["customer_id"], + ) + ) + rows, snapshots = [], headers + details + for record in details: + raw = record.payload_json + head_record = heads.get(str(raw.get("source_parent_id"))) + if head_record is None: + continue + head = head_record.payload_json + if str(head.get("idcustomer")) != group["customer_id"]: + raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") + supplement = facts.get(("SHIPMENT", record.source_id)) + supplement = supplement.data_json if supplement else {} + correction_fact = facts.get(("CORRECTION", record.source_id)) or facts.get( + ("CORRECTION", head_record.source_id) + ) + correction = ( + correction_fact.data_json + if correction_fact and correction_fact.data_json.get("entity") == "SHIPMENT" + else {} + ) + product_id = str(raw.get("idinventory")) + product = facts.get(("PRODUCT", product_id)) + customer = facts.get(("CUSTOMER", group["customer_id"])) + pending = [] + if ( + not supplement.get("sales_amount_excludes_freight") + and supplement.get("freight_amount") is None + ): + pending.append("FREIGHT_PENDING") + if not supplement.get("business_key"): + pending.append("SHIPPING_OWNERSHIP_PENDING") + # SA_SaleDelivery.isCancel 是 VoucherSettleState(如428未结清、180已结清), + # 并非作废标志;不能将非零编码当作红冲或作废。 + if head.get("voucherState") != 189: + pending.append("SHIPMENT_STATE_PENDING") + if head.get("isCounteractDelivery") and not correction.get("exclude_performance"): + pending.append("NEGATIVE_EVENT_REVIEW_REQUIRED") + day = supplement.get("date_override") or str(head["voucherdate"])[:10] + managers = [ + f.data_json + for (kind, _), f in facts.items() + if kind == "MANAGER" + and f.data_json["business_key"] == supplement.get("business_key") + and f.data_json["effective_date"] <= day + ] + managers.sort(key=lambda v: v["effective_date"]) + for (kind, _), fact in facts.items(): + if ( + kind == "HANDOVER" + and fact.data_json["business_key"] == supplement.get("business_key") + and fact.data_json["mode"] == "ACTIVE" + and not fact.data_json["shipping_ownership_confirmed"] + ): + pending.append("HANDOVER_SHIPMENTS_PENDING") + if raw.get("taxAmount") is None: + pending.append("TAX_AMOUNT_PENDING") + gross_amount = money(raw.get("taxAmount") or "0") + freight = ( + money(supplement["freight_amount"]) + if supplement.get("freight_amount") is not None + else money("0") + ) + target_product = product_targets.get(product_id) + rows.append( + { + "source_id": head_record.source_id, + "line_id": record.source_id, + "source_no": head_record.source_no, + "date": day, + "group_key": key, + "business_key": supplement.get("business_key"), + "personnel_id": supplement.get("personnel_id"), + "amount": str(gross_amount - freight), + "gross_tax_amount": str(gross_amount), + "freight_amount": str(freight) + if supplement.get("freight_amount") is not None + else None, + "product_id": product_id, + "quantity": str(raw.get("quantity") or "0"), + "category": target_product.product_category + if target_product + else product.data_json["category"] + if product + else None, + "unit": target_product.unit + if target_product + else product.data_json["unit"] + if product + else None, + "customer_type": customer_target.customer_type + if customer_target + else customer.data_json["customer_type"] + if customer + else None, + "classification_evidence": { + "product_target_id": target_product.id if target_product else None, + "customer_target_id": customer_target.id if customer_target else None, + }, + "manager_id": managers[-1]["manager_id"] if managers else None, + "performance_excluded": bool(correction.get("exclude_performance")), + "correction_pair_key": correction.get("pair_key"), + "exclusion_reason": "CORRECTION_EXCLUDED" if correction else None, + "pending": pending, + } + ) + covered = {r["source_id"] for r in rows} + if covered != set(group["shipment_ids"]): + raise WorkbenchError("SHIPMENT_DETAILS_MISSING") + return rows, snapshots + + +def _receipts(session, run, key, group, facts): + records = _group_records(session, run.source_database, "receipts", group["receipt_ids"]) + rows = [] + for record in records: + raw = record.payload_json + correction_fact = facts.get(("CORRECTION", record.source_id)) + correction = ( + correction_fact.data_json + if correction_fact and correction_fact.data_json.get("entity") == "RECEIPT" + else {} + ) + redirect_target = correction.get("redirect_business_key") + if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key: + raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") + fact = facts.get(("RECEIPT", record.source_id)) + confirmed, voucher_evidence, voucher_pending = _voucher_evidence(raw, fact) + pending = [] + pending.extend(voucher_pending) + if raw.get("isReceiveFlag") != 1: + pending.append("NOT_RECEIPT") + # 凭证关系与人工核实冲突时不能强行按人工勾选放行。 + if pending: + confirmed = False + if record.source_id in group.get("excluded_receipt_ids", []): + continue + if raw.get("voucherstate") != 189: + confirmed = False + exclusion = None + if raw.get("isReceiveFlag") == 0: + exclusion = "NOT_RECEIPT" + elif ( + voucher_evidence.get("basis") == "GL_SrcVoucherInfo/GL_Doc" + and voucher_evidence.get("count") == 0 + and not voucher_pending + ): + exclusion = "NOT_GENERATED_VOUCHER" + if redirect_target and redirect_target != key: + exclusion = "REDIRECTED_RECEIPT" + rows.append( + { + "source_id": record.source_id, + "source_no": record.source_no, + "date": str(raw["voucherDate"])[:10], + "group_key": key, + "amount": str(raw["amount"]), + "voucher_confirmed": confirmed, + "exclusion": exclusion, + "voucher_evidence": voucher_evidence, + "accounting_voucher_id": raw.get("accounting_voucher_id"), + "accounting_voucher_code": raw.get("accounting_voucher_code"), + "pending": pending, + "redirect_business_key": redirect_target, + "redirected_from_customer_id": str(raw.get("idpartner")) + if redirect_target == key and str(raw.get("idpartner")) != key + else None, + } + ) + return rows, records + + +def _voucher_evidence(raw, fact): + """以真实总账关系核实生成资格;旧快照只接受有依据的人工核实。""" + count, valid = raw.get("accounting_voucher_count"), raw.get("valid_accounting_voucher_count") + if count is None or valid is None: + return ( + bool(fact and fact.data_json["voucher_confirmed"]), + { + "basis": "MANUAL" if fact else "UNKNOWN", + "reference": fact.data_json["voucher_reference"] if fact else None, + }, + [], + ) + count, valid = int(count), int(valid) + if count < 0 or valid < 0 or valid > count: + raise ValueError("凭证关联数量异常") + generated = count > 0 and valid == count and raw.get("accounting_voucher_id") is not None + pending = ["ACCOUNTING_VOUCHER_INVALID"] if count > 0 and not generated else [] + if fact and fact.data_json["voucher_confirmed"] != generated: + pending.append("VOUCHER_EVIDENCE_CONFLICT") + return ( + generated and not pending, + { + "basis": "GL_SrcVoucherInfo/GL_Doc", + "count": count, + "valid_count": valid, + "voucher_id": raw.get("accounting_voucher_id"), + "voucher_code": raw.get("accounting_voucher_code"), + "reference": fact.data_json["voucher_reference"] if fact else None, + }, + pending, + ) + + +def _costed_shipments(shipments, receipts, facts, group): + """回款月定价时成本按每笔回款取值,交给 FIFO 逐段计算。""" + basis = "SHIPMENT" + for ship in shipments: + if basis is None: + ship["pending"].append("GUIDANCE_MONTH_BASIS_PENDING") + continue + months = {r["date"][:7] for r in receipts} if basis == "RECEIPT" else {ship["date"][:7]} + costs = {} + for month in months: + guide = _guidance(facts, ship["product_id"], ship["line_id"], month) + if guide is None or guide["unit"] != ship["unit"]: + continue + ship["guidance_finalized"] = bool(guide.get("is_finalized")) + cost = money(guide["amount"]) + if guide["basis"] == "UNIT": + if money(ship["quantity"]) <= 0: + ship["pending"].append("QUANTITY_PENDING") + continue + cost *= money(ship["quantity"]) + costs[month] = amount_text(cost) + ship["cost_by_month"] = costs + ship["cost_month_basis"] = basis + ship["cost_total"] = costs.get(ship["date"][:7]) if basis == "SHIPMENT" else None + + +def execute_preview(session, run, *, save_lines=True, customer_scope=False): + """同一次事务固化所有事实、来源指纹、参数和结果,不写正式台账。""" + if run.input_json.get("replay_no"): + return _replay(session, run) + facts = latest_facts(session, run.source_database) + month = run.input_json["month"] + year, mon = map(int, month.split("-")) + until = date(year, mon, monthrange(year, mon)[1]) + selected = [] + overlapping = set() + for key in run.input_json["group_keys"]: + fact = facts.get(("GROUP", key)) + if fact is None: + selected.append((key, {})) + continue + group = dict(fact.data_json) + if customer_scope: + peers = [ + f.data_json + for (kind, _), f in facts.items() + if kind == "GROUP" and f.data_json["customer_id"] == group["customer_id"] + ] + + def policy(g): + return ( + money(g["opening_balance"]) if g.get("opening_balance") is not None else None, + tuple(sorted(g.get("excluded_receipt_ids", []))), + bool(g.get("opening_confirmed")), + bool(g.get("historical_settlement_confirmed")), + ) + + if len({policy(g) for g in peers}) != 1: + selected.append((key, {"customer_scope_conflict": True})) + continue + # 2026-10-30 E7:同一发货客户名头统一平账,不再按合同/人工业务键拆组。 + group["scope_confirmed"] = True + for entity, field, day_field, target_field in ( + ("shipments", "idcustomer", "voucherdate", "shipment_ids"), + ("receipts", "idpartner", "voucherDate", "receipt_ids"), + ): + statement = source_statement(session, run.source_database, entity).where( + TPlusSyncRecord.payload_json[field].as_integer() == int(group["customer_id"]) + ) + source_ids = [ + r.source_id + for r in session.scalars(statement) + if START.isoformat() + <= str(r.payload_json.get(day_field, ""))[:10] + <= until.isoformat() + ] + if entity == "receipts": + source_ids.extend( + f.fact_key + for (kind, _), f in facts.items() + if kind == "CORRECTION" + and f.data_json.get("entity") == "RECEIPT" + and f.data_json.get("redirect_business_key") == key + and f.fact_key not in source_ids + ) + group[target_field] = sorted(source_ids, key=lambda value: int(value)) + # 也检查未选组,避免同一单据在另一组被解释为独立资金。 + for (kind, other_key), other in facts.items(): + if ( + kind == "GROUP" + and other_key != key + and not customer_scope + and ( + set(group["shipment_ids"]) & set(other.data_json["shipment_ids"]) + or set(group["receipt_ids"]) & set(other.data_json["receipt_ids"]) + ) + ): + overlapping.add(key) + selected.append((key, group)) + frozen = { + "rule_version": RULE_VERSION, + "month": month, + "source": [], + "facts": [ + { + "id": f.id, + "kind": f.kind, + "key": f.fact_key, + "version": f.version, + "data": f.data_json, + "evidence": f.evidence, + } + for f in facts.values() + ], + "parameters": {}, + "calculation_inputs": {}, + } + count, summaries = 0, {} + person_facts = {key: f.data_json for (kind, key), f in facts.items() if kind == "PERSONNEL"} + handovers = [f.data_json for (kind, _), f in facts.items() if kind == "HANDOVER"] + for key, data in selected: + group = {**data, "key": key} + if data.get("customer_scope_conflict"): + result = {"status": "PENDING", "reasons": ["CUSTOMER_SCOPE_CONFLICT"], "lines": []} + elif key in overlapping: + result = {"status": "PENDING", "reasons": ["OVERLAPPING_GROUPS"], "lines": []} + elif not data: + result = {"status": "PENDING", "reasons": ["GROUP_FACT_REQUIRED"], "lines": []} + elif ( + not group.get("scope_confirmed") + or not group.get("opening_confirmed") + or not group.get("historical_settlement_confirmed") + ): + result = preview_group(group, [], [], {}, {}, until) + else: + try: + ships, ship_refs = _shipments(session, run, key, group, facts) + cash, cash_refs = _receipts(session, run, key, group, facts) + frozen["source"].extend( + (r.entity_type, r.source_id, r.id, r.row_hash) for r in ship_refs + cash_refs + ) + _costed_shipments(ships, cash, facts, group) + # max(发货日,回款日) 必为其中一个日期,无需构造单据笛卡尔积。 + days = { + row["date"] + for row in [*ships, *cash] + if START.isoformat() <= row["date"] <= until.isoformat() + } + params = _parameters(session, days) + # 已确认的原修改方案和到账日口径不再依赖重复人工勾选。 + params["august_confirmed"] = True + params["prepayment_parameter_basis"] = "RECEIPT" + frozen["parameters"][key] = params + frozen["calculation_inputs"][key] = { + "group": group, + "shipments": ships, + "receipts": cash, + "facts": {"personnel": person_facts, "handovers": handovers}, + "params": params, + } + result = preview_group( + group, + ships, + cash, + {"personnel": person_facts, "handovers": handovers}, + params, + until, + ) + except WorkbenchError as exc: + frozen["calculation_inputs"].pop(key, None) + result = {"status": "PENDING", "reasons": [str(exc)], "lines": []} + except BadRequestError: + frozen["calculation_inputs"].pop(key, None) + result = {"status": "PENDING", "reasons": ["PARAMETERS_PENDING"], "lines": []} + except (ValueError, KeyError, InvalidOperation, TypeError): + frozen["calculation_inputs"].pop(key, None) + result = {"status": "PENDING", "reasons": ["DATA_VALUE_INVALID"], "lines": []} + if key not in frozen["calculation_inputs"]: + frozen["calculation_inputs"][key] = {"pending": result} + summaries[key] = {k: v for k, v in result.items() if k != "lines"} + if save_lines: + count = _save_group(session, run, key, result, count) + run.snapshot_json, run.snapshot_hash = frozen, digest(frozen) + run.summary_json = { + "groups": summaries, + "total_lines": count, + "rule_version": RULE_VERSION, + "month": month, + "formal_payroll_written": False, + } + states = Counter(v["status"] for v in summaries.values()) + run.status = "PARTIAL" if states["PENDING"] or states["PARTIAL"] else "SUCCEEDED" + + +def _save_group(session, run, key, result, count): + lines = result["lines"] or ( + [{"status": "PENDING", "reasons": result["reasons"], "performance": None}] + if result["status"] == "PENDING" + else [] + ) + for line in lines: + count += 1 + session.add( + TPlusWorkLine( + run_no=run.run_no, + line_no=count, + entity_type="group", + source_id=key, + action=line["status"], + reason=",".join(line.get("reasons", []))[:128] or None, + data_json={**line, "group_key": key, "formal_payroll_written": False}, + ) + ) + return count + + +def _replay(session, run): + """只重算原运行冻结输入;不读取当前事实、指导价、参数或源数据。""" + import copy + + previous = get_run(session, run.input_json["replay_no"], run.source_database) + frozen = copy.deepcopy(previous.snapshot_json or {}) + if ( + previous.kind != "CALC_PREVIEW" + or frozen.get("rule_version") not in ("2026-10-30.1", RULE_VERSION) + or not frozen.get("calculation_inputs") + ): + raise WorkbenchError("REPLAY_UNAVAILABLE") + year, month = map(int, frozen["month"].split("-")) + until = date(year, month, monthrange(year, month)[1]) + summaries, count = {}, 0 + for key, values in frozen["calculation_inputs"].items(): + result = values.get("pending") + if result is None: + result = preview_group( + values["group"], + values["shipments"], + values["receipts"], + values["facts"], + values["params"], + until, + ) + summaries[key] = {k: v for k, v in result.items() if k != "lines"} + count = _save_group(session, run, key, result, count) + run.snapshot_json, run.snapshot_hash = frozen, digest(frozen) + run.summary_json = { + "groups": summaries, + "total_lines": count, + "rule_version": RULE_VERSION, + "month": frozen["month"], + "formal_payroll_written": False, + } + run.status = ( + "PARTIAL" + if any(v["status"] in ("PENDING", "PARTIAL") for v in summaries.values()) + else "SUCCEEDED" + ) diff --git a/backend/app/services/tplus/errors.py b/backend/app/services/tplus/errors.py new file mode 100644 index 0000000..150f1fe --- /dev/null +++ b/backend/app/services/tplus/errors.py @@ -0,0 +1,35 @@ +"""只按明确的源驱动错误分类,不把含凭据的驱动文本写入响应。""" + +from sqlalchemy.exc import TimeoutError as PoolTimeoutError + +TRANSIENT_CODES = frozenset({"SOURCE_CONNECTION_LOST", "SOURCE_TIMEOUT"}) +MESSAGES = { + "SOURCE_CONNECTION_LOST": "源连接中断,请检查网络和只读连接", + "SOURCE_TIMEOUT": "源连接或查询超时", + "SOURCE_AUTH_ERROR": "源账号认证失败,请核对只读账号配置", + "SOURCE_SCHEMA_ERROR": "源表或字段不匹配,请核对版本与映射", + "SOURCE_CONFIG_ERROR": "源连接配置不完整或任务排队后发生变化", + "SOURCE_DRIVER_ERROR": "源数据库驱动不可用,请检查已验证的ODBC驱动", + "QUERY_FILE_MISSING": "缺少源查询文件,请核对部署包", +} + + +def classify_source_error(exc, fallback="READ_ERROR"): + if isinstance(exc, (TimeoutError, PoolTimeoutError)): + return "SOURCE_TIMEOUT" + if isinstance(exc, ModuleNotFoundError): + return "SOURCE_DRIVER_ERROR" + if isinstance(exc, FileNotFoundError): + return "QUERY_FILE_MISSING" + original = getattr(exc, "orig", exc) + args = getattr(original, "args", ()) + state = str(args[0]).upper() if args else "" + if state in {"HYT00", "HYT01"}: + return "SOURCE_TIMEOUT" + if state in {"08S01", "08001", "08003", "08006"}: + return "SOURCE_CONNECTION_LOST" + if state == "28000": + return "SOURCE_AUTH_ERROR" + if state in {"42S02", "42S22", "07002"}: + return "SOURCE_SCHEMA_ERROR" + return fallback diff --git a/backend/app/services/tplus/fifo.py b/backend/app/services/tplus/fifo.py new file mode 100644 index 0000000..9207729 --- /dev/null +++ b/backend/app/services/tplus/fifo.py @@ -0,0 +1,355 @@ +"""核算预演纯函数:只分配已明确的平账组,不写正式台账。""" + +from datetime import date +from decimal import ROUND_HALF_UP, Decimal +from fractions import Fraction + +from app.services import formula + +CENT = Decimal("0.01") +ZERO = Decimal("0") +START = date(2026, 3, 1) +AUGUST = date(2026, 8, 1) + + +def money(value) -> Decimal: + """拒绝非有限金额,不用浮点数参与计算。""" + if isinstance(value, float): + raise ValueError("金额必须为十进制字符串") + result = Decimal(str(value)) + if not result.is_finite(): + raise ValueError("金额必须为有限数字") + return result + + +def amount_text(value: Decimal) -> str: + """在展示边界按分四舍五入。""" + return str(value.quantize(CENT, rounding=ROUND_HALF_UP)) + + +def _day(value) -> date: + return date.fromisoformat(str(value)[:10]) + + +def _key(row): + return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0))) + + +def _beneficiary(shipment, receipt, facts, as_of): + owner = str(shipment.get("personnel_id") or "") + applicable = sorted( + (f for f in facts if f.get("business_key") == shipment.get("business_key")), + key=lambda f: (_day(f["effective_date"]), f.get("version", 0)), + ) + for fact in applicable: + # 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。 + if ( + fact["mode"] == "RESIGNATION" + and _day(fact["effective_date"]) <= _day(as_of) + and owner == str(fact["from_personnel_id"]) + ): + owner = str(fact["to_personnel_id"]) + return owner + + +def _calculate(shipment, receipt, matched, previously_matched, facts, params): + reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", [])) + effective = max(_day(shipment["date"]), _day(receipt["date"])) + is_prepayment = _day(receipt["date"]) < _day(shipment["date"]) + if is_prepayment and not params.get("prepayment_parameter_basis"): + reasons.append("PREPAYMENT_PARAMETERS_PENDING") + if effective >= AUGUST and not params.get("august_confirmed", False): + reasons.append("AUGUST_RULE_PENDING") + owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"]) + person = facts.get("personnel", {}).get(owner) + original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id"))) + if not person: + reasons.append("PERSONNEL_PENDING") + elif person.get("status") == 0: + known_later_departure = any( + record.get("mode") == "RESIGNATION" + and record.get("business_key") == shipment.get("business_key") + and str(record.get("from_personnel_id")) == owner + and _day(record["effective_date"]) > _day(facts["as_of"]) + for record in facts.get("handovers", []) + ) + if not known_later_departure: + reasons.append("HANDOVER_PENDING") + cost = shipment.get("cost_total") + if shipment.get("cost_month_basis") == "RECEIPT": + cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7]) + if cost is None: + reasons.append("GUIDANCE_PENDING") + category = shipment.get("category") + if category not in ("municipal_water", "rail_transit"): + reasons.append("PRODUCT_CATEGORY_PENDING") + customer_type = shipment.get("customer_type") + if customer_type not in ("personal", "manager_personal", "enterprise"): + reasons.append("CUSTOMER_CATEGORY_PENDING") + parameter_date = ( + _day(receipt["date"]) + if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT" + else effective + ) + rate_params = params.get(parameter_date.isoformat()) + if rate_params is None: + reasons.append("PARAMETERS_PENDING") + if reasons: + return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None} + total = money(shipment["amount"]) + cost_total = money(cost) + # 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。 + before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP) + after = (cost_total * (previously_matched + matched) / total).quantize( + CENT, rounding=ROUND_HALF_UP + ) + allocated_cost = after - before + threshold = Fraction(str(rate_params["thresholds"][category])) + converted = formula.converted_profit( + matched, formula.profit_rate(matched - allocated_cost, matched), threshold + ) + days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"])) + manager_own = bool(original_person and original_person["role_code"] == "sales_manager") + if manager_own or person["role_code"] == "sales_manager" or customer_type == "manager_personal": + rate = Fraction(0) + elif _day(receipt["date"]) < _day(shipment["date"]): + rate = Fraction(str(rate_params["start"])) / 1000 + elif customer_type == "enterprise": + rate = Fraction(str(rate_params["enterprise"])) / 1000 + else: + rate = formula.salesperson_rate( + days, + start=Fraction(str(rate_params["start"])), + slope=Fraction(str(rate_params["slope"])), + floor=Fraction(str(rate_params["floor"])), + boundary_days=int(rate_params["boundary"]), + ) + return { + "status": "READY", + "reasons": [], + "personnel_id": owner, + "manager_id": shipment.get("manager_id"), + "manager_pending": not bool(shipment.get("manager_id")), + "effective_date": effective.isoformat(), + "days": days, + "allocated_cost": amount_text(allocated_cost), + "converted_profit": amount_text(converted), + "profit_threshold": str(rate_params["thresholds"][category]), + "rate": str(rate), + "rate_percent": format( + (Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")), + "f", + ) + .rstrip("0") + .rstrip(".") + + "%", + "parameters": rate_params, + "parameter_date": parameter_date.isoformat(), + "performance": amount_text(formula.performance_amount(converted, rate)), + "excluded_manager_own": manager_own or person["role_code"] == "sales_manager", + } + + +def preview_group(group, shipments, receipts, facts, params, until: date) -> dict: + """按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。""" + if not group.get("scope_confirmed"): + return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []} + if not group.get("opening_confirmed") or group.get("opening_balance") is None: + return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []} + if not group.get("historical_settlement_confirmed"): + return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []} + shipments = [row for row in shipments if START <= _day(row["date"]) <= until] + receipts = [row for row in receipts if START <= _day(row["date"]) <= until] + excluded_shipments = [row for row in shipments if row.get("performance_excluded")] + shipments = [row for row in shipments if not row.get("performance_excluded")] + excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key) + receipts = [row for row in receipts if not row.get("exclusion")] + amount_pending = { + reason + for row in shipments + for reason in row.get("pending", []) + if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING") + } + if amount_pending: + # 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。 + return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []} + opening = money(group["opening_balance"]) + if opening < 0: + raise ValueError("期初应收不能为负,预收余额应单独登记") + invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0] + if invalid: + return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []} + if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments): + return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []} + if any( + money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts) + ): + return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []} + groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)} + if groups - {str(group["key"])}: + raise ValueError("禁止跨平账组分配") + ship_rows = sorted(shipments, key=_key) + cash_rows = sorted(receipts, key=_key) + ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows] + receipt_keys = [str(r["source_id"]) for r in cash_rows] + if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys): + raise ValueError("预演输入包含重复单据或明细") + # 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。 + if any(not r.get("voucher_confirmed") for r in cash_rows): + reasons = {"VOUCHER_STATE_PENDING"} + for receipt in cash_rows: + reasons.update(receipt.get("pending", [])) + return {"status": "PENDING", "reasons": sorted(reasons), "lines": []} + consumed = [ZERO for _ in ship_rows] + lines = [ + { + "kind": "EXCLUDED", + "shipment_id": str(row["source_id"]), + "shipment_no": row.get("source_no"), + "shipment_date": row["date"], + "line_id": str(row.get("line_id", 0)), + "amount": amount_text(money(row["amount"])), + "status": "EXCLUDED", + "performance": None, + "reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"], + "correction_pair_key": row.get("correction_pair_key"), + } + for row in excluded_shipments + ] + [ + { + "kind": "EXCLUDED", + "receipt_id": str(row["source_id"]), + "receipt_no": row.get("source_no"), + "receipt_date": row["date"], + "amount": amount_text(money(row["amount"])), + "status": "EXCLUDED", + "performance": None, + "reasons": [row["exclusion"]], + "voucher_evidence": row.get("voucher_evidence"), + "accounting_voucher_code": row.get("accounting_voucher_code"), + } + for row in excluded_receipts + ] + remaining_opening = opening + cash_total = ZERO + for receipt in cash_rows: + available = money(receipt["amount"]) + cash_total += available + if remaining_opening: + matched = min(available, remaining_opening) + if matched: + lines.append( + { + "kind": "OPENING", + "receipt_id": str(receipt["source_id"]), + "receipt_no": receipt.get("source_no"), + "receipt_date": receipt["date"], + "voucher_evidence": receipt.get("voucher_evidence"), + "accounting_voucher_code": receipt.get("accounting_voucher_code"), + "amount": amount_text(matched), + "status": "LEGACY", + "performance": None, + "reasons": ["LEGACY_SYSTEM_CALCULATES"], + } + ) + remaining_opening -= matched + available -= matched + for i, shipment in enumerate(ship_rows): + if available == 0: + break + remaining = money(shipment["amount"]) - consumed[i] + matched = min(available, remaining) + if matched <= 0: + continue + result = _calculate( + shipment, + receipt, + matched, + consumed[i], + {**facts, "as_of": until.isoformat()}, + params, + ) + lines.append( + { + "kind": "SHIPMENT", + "receipt_id": str(receipt["source_id"]), + "receipt_no": receipt.get("source_no"), + "receipt_date": receipt["date"], + "voucher_evidence": receipt.get("voucher_evidence"), + "accounting_voucher_code": receipt.get("accounting_voucher_code"), + "shipment_id": str(shipment["source_id"]), + "shipment_no": shipment.get("source_no"), + "shipment_date": shipment["date"], + "line_id": str(shipment.get("line_id", 0)), + "product_id": shipment.get("product_id"), + "quantity": shipment.get("quantity"), + "cost_total": shipment.get("cost_total"), + "gross_tax_amount": shipment.get("gross_tax_amount"), + "freight_amount": shipment.get("freight_amount"), + "cost_month_basis": shipment.get("cost_month_basis"), + "cost_by_month": shipment.get("cost_by_month"), + "shipment_remaining_after": amount_text(remaining - matched), + "amount": amount_text(matched), + **result, + } + ) + consumed[i] += matched + available -= matched + if available: + lines.append( + { + "kind": "PREPAYMENT", + "receipt_id": str(receipt["source_id"]), + "receipt_no": receipt.get("source_no"), + "receipt_date": receipt["date"], + "voucher_evidence": receipt.get("voucher_evidence"), + "accounting_voucher_code": receipt.get("accounting_voucher_code"), + "amount": amount_text(available), + "status": "WAITING_SHIPMENT", + "performance": None, + "reasons": ["NO_ACTUAL_SHIPMENT"], + } + ) + allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO) + if allocated != cash_total: + raise ValueError("回款分配金额不守恒") + return { + "status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED", + "reasons": [], + "lines": lines, + "receipt_total": amount_text(cash_total), + "excluded_receipt_total": amount_text( + sum((money(r["amount"]) for r in excluded_receipts), ZERO) + ), + "excluded_shipment_total": amount_text( + sum((money(r["amount"]) for r in excluded_shipments), ZERO) + ), + "allocated_total": amount_text(allocated), + "opening_remaining": amount_text(remaining_opening), + "shipment_remaining": amount_text( + sum( + ( + money(row["amount"]) - used + for row, used in zip(ship_rows, consumed, strict=True) + ), + ZERO, + ) + ), + "prepayment_remaining": amount_text( + sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO) + ), + "performance_total": amount_text( + sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO) + ), + "month_performance_total": amount_text( + sum( + ( + money(r["performance"]) + for r in lines + if r.get("performance") is not None + and r.get("effective_date", "")[:7] == until.isoformat()[:7] + ), + ZERO, + ) + ), + } diff --git a/backend/app/services/tplus/mapping.py b/backend/app/services/tplus/mapping.py index dcc5a53..e4cb22e 100644 --- a/backend/app/services/tplus/mapping.py +++ b/backend/app/services/tplus/mapping.py @@ -1,7 +1,7 @@ """T+ 字段映射元数据。 这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。 -高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。 +高风险口径在客户确认前保持 ``PENDING``;源读取只写 staging,基础导入另经事实核对和预览。 """ from dataclasses import dataclass @@ -27,7 +27,7 @@ class TPlusMapping: target_table: str status: MappingStatus unresolved: tuple[str, ...] = () - version: str = "0.2" + version: str = "0.5" schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验" @@ -38,7 +38,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"), "personnel", MappingStatus.CANDIDATE, - ("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"), + ("名单缺失编号", "经理分管业务", "入职日期", "试用期"), ), TPlusMapping( "products", @@ -46,7 +46,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ("id", "code", "name", "specification", "idunit", "idinventoryclass"), "products", MappingStatus.CANDIDATE, - ("库存数量语义", "参考价来源", "系统产品分类"), + ("人工核算分类", "指导价取值时点"), ), TPlusMapping( "customers", @@ -54,7 +54,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ("id", "code", "name", "Contact", "representative", "disabled", "updated"), "customers", MappingStatus.CANDIDATE, - ("客户唯一键", "改名/合并规则", "系统客户分类"), + ("源客户属性为空", "客户负责人资料"), ), TPlusMapping( "businesses", @@ -62,7 +62,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"), "businesses", MappingStatus.PENDING, - ("成本口径", "业务负责人覆盖规则", "含税口径"), + ("指导价取值时点", "业务交接依据", "平账范围"), ), TPlusMapping( "shipments", @@ -79,7 +79,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ), "shipments", MappingStatus.PENDING, - ("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"), + ("退货/红冲规则", "运费实际金额", "发货归属"), ), TPlusMapping( "receipts", @@ -96,7 +96,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = ( ), "receipts", MappingStatus.PENDING, - ("到账日期", "收款方式", "核销状态", "回款与发货关联"), + ("凭证异常例单复核", "特殊款项识别", "平账范围"), ), ) @@ -107,7 +107,7 @@ DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = ( ("id", "idSaleOrderDTO", "LatestCost"), "business_items", MappingStatus.PENDING, - ("成本口径", "含税口径"), + ("不导入T+成本,仅保留追溯快照",), ), TPlusMapping( "shipment_items", diff --git a/backend/app/services/tplus/master_import.py b/backend/app/services/tplus/master_import.py new file mode 100644 index 0000000..43577ae --- /dev/null +++ b/backend/app/services/tplus/master_import.py @@ -0,0 +1,288 @@ +"""主数据先预览再执行,按源ID关联,只更新允许同步的字段。""" + +import json +from collections import Counter +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.models import BusinessRole, Customer, Personnel, Product, TPlusSourceLink, TPlusWorkLine +from app.services.tplus.snapshots import source_records +from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts + +MODELS = {"personnel": Personnel, "products": Product, "customers": Customer} +ORDER = {"personnel": 0, "products": 1, "customers": 2} +# 恢复库 eap_EnumItem:211=客户,228=客户/供应商,226=供应商。 +CUSTOMER_ONLY = 211 + + +def _identity(row): + keys = { + "personnel": ("personnel_name",), + "products": ("product_name", "spec_model"), + "customers": ("company_name", "owner_name"), + }[row["entity_type"]] + return row["entity_type"], tuple(str(row["values"][key]).rstrip().casefold() for key in keys) + + +def _target_snapshot(target): + if target is None: + return None + return {c.name: getattr(target, c.name) for c in target.__table__.columns} + + +def _master_values(entity, record, facts, session): + row = record.payload_json + if entity == "personnel": + code = str(row.get("code") or "").strip() + fact = facts.get(("PERSONNEL", code)) + if fact is None: + return None, "NOT_IN_PERSONNEL_ROSTER" + person = fact.data_json + if person["code"] != code or person["name"] != str(row.get("name") or "").strip(): + return None, "PERSONNEL_IDENTITY_CONFLICT" + role = session.scalar( + select(BusinessRole).where(BusinessRole.role_code == person["role_code"]) + ) + if role is None: + return None, "BUSINESS_ROLE_MISSING" + return { + "personnel_name": person["name"], + "business_role_id": role.id, + "status": person["status"], + }, None + if entity == "products": + fact = facts.get(("PRODUCT", record.source_id)) + if fact is None: + return None, "PRODUCT_CATEGORY_PENDING" + unit = row.get("unit_name") + if not unit or unit != fact.data_json["unit"]: + return None, "PRODUCT_UNIT_PENDING" + return { + "product_name": row["name"], + "spec_model": row.get("specification") or "", + "unit": unit, + "product_category": fact.data_json["category"], + }, None + if row.get("partnerType") != CUSTOMER_ONLY: + return None, "NOT_CUSTOMER_ONLY" + fact = facts.get(("CUSTOMER", record.source_id)) + if fact is None: + return None, "CUSTOMER_ATTRIBUTE_PENDING" + return { + "company_name": row["name"], + "owner_name": fact.data_json["owner_name"], + "customer_type": fact.data_json["customer_type"], + }, None + + +def _protected_update(entity, values): + allowed = { + "personnel": {"personnel_name"}, + "products": {"product_name", "spec_model", "unit"}, + "customers": {"company_name"}, + }[entity] + return {k: v for k, v in values.items() if k in allowed} + + +def _conflict(session, entity, values, target_id): + model = MODELS[entity] + if entity == "personnel": + conditions = [model.personnel_name == values["personnel_name"]] + elif entity == "products": + conditions = [ + model.product_name == values["product_name"], + model.spec_model == values["spec_model"], + ] + else: + conditions = [ + model.company_name == values["company_name"], + model.owner_name == values["owner_name"], + ] + if target_id: + conditions.append(model.id != target_id) + return session.scalar(select(model.id).where(*conditions).limit(1)) is not None + + +def build_master_plan(session, run, *, lock_targets=False): + """重新计算源、事实、目标指纹;执行前复核所有输入。""" + facts = latest_facts(session, run.source_database) + links = { + (v.entity_type, v.source_id): v + for v in session.scalars( + select(TPlusSourceLink).where(TPlusSourceLink.source_database == run.source_database) + ) + } + rows = [] + snapshots = [] + target_ids = [] + for entity in sorted(run.input_json["entity_types"], key=ORDER.__getitem__): + records = source_records( + session, run.source_database, entity, run.input_json.get("source_ids") or None + ) + for record in records: + link = links.get((entity, record.source_id)) + target = ( + session.get( + MODELS[entity], + link.target_id, + populate_existing=True, + with_for_update=lock_targets, + ) + if link + else None + ) + values, reason = _master_values(entity, record, facts, session) + action = "PENDING" if reason else "CREATE" + if reason in ("NOT_IN_PERSONNEL_ROSTER", "NOT_CUSTOMER_ONLY"): + action = "SKIP" + if link and target is None: + action, reason = "CONFLICT", "TARGET_MISSING" + elif values: + if target: + values = _protected_update(entity, values) + action = ( + "UPDATE" + if any(getattr(target, k) != v for k, v in values.items()) + else "SKIP" + ) + if _conflict( + session, + entity, + {**(_target_snapshot(target) or {}), **values}, + target.id if target else None, + ): + action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT" + snapshots.append((entity, record.source_id, record.row_hash, record.id)) + target_ids.append((entity, record.source_id, _target_snapshot(target))) + rows.append( + { + "entity_type": entity, + "source_id": record.source_id, + "action": action, + "reason": reason, + "values": values or {}, + "record_id": record.id, + "row_hash": record.row_hash, + "target_id": target.id if target else None, + "source_no": record.source_no, + "source_name": record.payload_json.get("name"), + } + ) + identities = Counter(_identity(r) for r in rows if r["action"] == "CREATE") + for row in rows: + if row["action"] == "CREATE" and identities[_identity(row)] > 1: + row["action"], row["reason"] = "CONFLICT", "DUPLICATE_SOURCE_IDENTITY" + # 以代码名单检查漏读人员,不能让缺失人员静默成为成功。 + if "personnel" in run.input_json["entity_types"]: + seen = {r["source_no"] for r in rows if r["entity_type"] == "personnel"} + for kind, key in facts: + if kind == "PERSONNEL" and key not in seen and not run.input_json.get("source_ids"): + rows.append( + { + "entity_type": "personnel", + "source_id": key, + "action": "PENDING", + "reason": "ROSTER_SOURCE_MISSING", + "values": {}, + } + ) + frozen = { + "rule_version": RULE_VERSION, + "source": snapshots, + "targets": target_ids, + "plan": rows, + "facts": [ + { + "id": f.id, + "kind": f.kind, + "key": f.fact_key, + "version": f.version, + "data": f.data_json, + "evidence": f.evidence, + } + for f in facts.values() + ], + } + frozen = json.loads(json.dumps(frozen, ensure_ascii=False, default=str)) + return rows, frozen, digest(frozen) + + +def _line(session, run, i, row, action=None, reason=None): + session.add( + TPlusWorkLine( + run_no=run.run_no, + line_no=i, + entity_type=row["entity_type"], + source_id=row["source_id"], + action=action or row["action"], + reason=reason or row.get("reason"), + data_json={ + k: v for k, v in row.items() if k not in ("entity_type", "action", "reason") + }, + ) + ) + + +def execute_master(session, run): + """执行预览或原子导入;正式核算表永远不参与此服务。""" + if run.kind == "MASTER_PREVIEW": + rows, snapshot, fingerprint = build_master_plan(session, run) + run.snapshot_json, run.snapshot_hash = snapshot, fingerprint + for i, row in enumerate(rows, 1): + _line(session, run, i, row) + counts = Counter(r["action"] for r in rows) + run.summary_json = { + "counts": dict(counts), + "rule_version": RULE_VERSION, + "total": len(rows), + "formal_payroll_written": False, + } + run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED" + return + preview = get_run(session, run.input_json["preview_no"], run.source_database) + if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): + raise WorkbenchError("PREVIEW_REQUIRED") + rows, frozen, fingerprint = build_master_plan(session, preview, lock_targets=True) + if preview.snapshot_hash != fingerprint: + raise WorkbenchError("PREVIEW_STALE") + run.snapshot_hash, run.snapshot_json = fingerprint, frozen + counts = Counter() + for i, row in enumerate(rows, 1): + action, reason = row["action"], row.get("reason") + if action in ("CREATE", "UPDATE"): + try: + with session.begin_nested(): + model = MODELS[row["entity_type"]] + if action == "CREATE": + target = model( + **row["values"], + t_plus_id=row["source_id"], + source_no=row.get("source_no"), + sync_batch_no=run.run_no, + synced_at=datetime.now(), + ) + session.add(target) + session.flush() + session.add( + TPlusSourceLink( + source_database=run.source_database, + entity_type=row["entity_type"], + source_id=row["source_id"], + target_id=target.id, + ) + ) + else: + target = session.get(model, row["target_id"], with_for_update=True) + for field, value in row["values"].items(): + setattr(target, field, value) + target.sync_batch_no, target.synced_at = run.run_no, datetime.now() + session.flush() + row["target_id"] = target.id + except IntegrityError: + action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT" + counts[action] += 1 + _line(session, run, i, row, action, reason) + run.summary_json = {"counts": dict(counts), "total": len(rows), "formal_payroll_written": False} + run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED" diff --git a/backend/app/services/tplus/materials.py b/backend/app/services/tplus/materials.py new file mode 100644 index 0000000..05175c1 --- /dev/null +++ b/backend/app/services/tplus/materials.py @@ -0,0 +1,84 @@ +"""从源库只读整理核对资料,不把费用空值或旧初始化余额当作已确认事实。""" + +import hashlib +import json +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path + +from sqlalchemy import text + +from app.core.exceptions import BadRequestError +from app.core.month import validate_month + +QUERIES = ("customer", "shipments", "items", "receipts", "opening", "history", "expenses") +ROOT = Path(__file__).resolve().parents[4] / "db/queries/tplus/materials" + + +def summarize(records, customer_id, month): + """空记录只构成候选余额依据,不自动替代业务确认。""" + if len(records["customer"]) != 1: + raise BadRequestError("源客户不存在或身份不唯一") + truncated = [name for name, rows in records.items() if len(rows) > 200] + missing = [ + {"key": "GUIDANCE", "title": "发货月份人工指导成本", "reason": "T+成本不用于绩效,需填写并封存人工指导成本"}, + {"key": "PRODUCT_CATEGORY", "title": "产品绩效核算分类", "reason": "源产品分类不等于市政水利/轨道交通核算分类"}, + {"key": "HISTORICAL_PAYROLL", "title": "样本是否已在旧软件核算或发放", "reason": "源应收及收款记录不能证明绩效已发放"}, + {"key": "MANAGER_HANDOVER", "title": "样本当时的分管及交接依据", "reason": "保留源业务员工号,不按当前部门推断历史经理或接手人"}, + ] + if not records["customer"][0].get("customerType"): + missing.append({"key": "CUSTOMER_CATEGORY", "title": "客户绩效分类", "reason": "源客户绩效属性为空"}) + missing.append({"key": "FREIGHT", "title": "该批发货运费是否已剔除", "reason": "费用表和金额仅作证据,缺少关联或空值不自动等于零运费"}) + no_old_rows = not records["opening"] and not records["history"] + return { + "customer_id": customer_id, "month": month, "captured_at": datetime.now().isoformat(), + "source_read_only": True, "formal_payroll_written": False, + "found": {name: len(rows) for name, rows in records.items()}, + "opening_candidate": "0.00" if no_old_rows else None, + "opening_confirmed": False, + "opening_explanation": "未查到初始化及切换日前应收记录,仅作为候选零余额依据" if no_old_rows else "需将初始化记录与截至2026-02-28的应收变动核对,不直接使用2017年期初", + "truncated": truncated, "ready_for_amount": False, "missing": missing, + "evidence": {name: rows[:200] for name, rows in records.items()}, + } + + +def collect_materials(source, customer_id, month): + validate_month(month) + if customer_id <= 0: + raise BadRequestError("客户ID必须为正整数") + year, mon = map(int, month.split("-")) + until = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1) + args = {"customer_id": customer_id, "until": until, "cutoff": date(2026, 3, 1)} + records = {name: [dict(r) for r in source.execute(text((ROOT / f"{name}.sql").read_text(encoding="utf-8")), args).mappings()] for name in QUERIES} + result = summarize(records, customer_id, month) + normalized = json.loads(json.dumps(result, ensure_ascii=False, default=str)) + normalized["evidence_hash"] = hashlib.sha256(json.dumps(normalized["evidence"], sort_keys=True, ensure_ascii=False).encode()).hexdigest() + return normalized + + +def render_markdown(report): + def money(value): + return "待核对" if value is None else f"{Decimal(str(value)):,.2f}" + + customer = report["evidence"]["customer"][0] + lines = [f"# 样本资料核对:{customer['name']}({report['month']})", "", + f"核对时间:{report['captured_at']}。源库只读;未写入正式工资。", "", + "## 已从恢复库整理", "", "| 类别 | 数量 |", "|---|---:|"] + labels = {"customer": "客户", "shipments": "发货单", "items": "发货明细", "receipts": "回款凭证关联行", "opening": "初始化应收", "history": "切换日前应收分组", "expenses": "销售费用分摊"} + lines += [f"| {labels[name]} | {count} |" for name, count in report["found"].items()] + lines += ["", "## 单据与归属", "", "| 发货单 | 日期 | 含税金额 | 业务员工号 |", "|---|---|---:|---|"] + lines += [f"| {r['code']} | {str(r['voucherdate'])[:10]} | {money(r['taxAmount'])} | {r.get('personnel_code') or '待核对'} |" for r in report["evidence"]["shipments"]] + lines += ["", "| 产品代码 | 品名/规格 | 数量 | 含税单价 | 含税金额 | 运费 |", "|---|---|---:|---:|---:|---:|"] + lines += [f"| {r.get('product_code', '待核对')} | {r.get('product_name', '')} {r.get('specification', '')} | {r.get('quantity', '待核对')} | {money(r.get('taxPrice'))} | {money(r.get('taxAmount'))} | {money(r.get('expenseAmount'))} |" for r in report["evidence"]["items"]] + lines += ["", "| 回款单 | 日期 | 金额 | 总账凭证 |", "|---|---|---:|---|"] + lines += [f"| {r['code']} | {str(r['voucherDate'])[:10]} | {money(r['amount'])} | {r.get('accounting_voucher_code') or '待核对'} |" for r in report["evidence"]["receipts"]] + lines += ["", "## 可推导但尚未确认", "", report["opening_explanation"], + f"候选期初余额:{report['opening_candidate'] or '待核对'}。本报告不自动确认期初余额。", "", + "## 仅需补充的资料", ""] + lines += [f"- {item['title']}:{item['reason']}。" for item in report["missing"]] + if report.get("customer_reply"): + lines += ["", "## 本轮补充回复", "", report["customer_reply"]] + if report["truncated"]: + lines += ["", "存在超出200行的资料,当前核对包不完整,不能作为完整金额验收依据。"] + lines += ["", "指导成本、分类或关键依据缺失,实际工资金额尚未验收,不填默认值。", "", f"证据指纹:`{report['evidence_hash']}`", ""] + return "\n".join(lines) diff --git a/backend/app/services/tplus/query_service.py b/backend/app/services/tplus/query_service.py index bc5b6fb..c231483 100644 --- a/backend/app/services/tplus/query_service.py +++ b/backend/app/services/tplus/query_service.py @@ -11,6 +11,8 @@ from app.schemas.tplus import ( TPlusOverviewOut, ) from app.services.tplus import task_service +from app.services.tplus.automation import overview as automation_overview +from app.services.tplus.errors import MESSAGES from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS from app.services.tplus.relationship_audit import public_report @@ -22,6 +24,7 @@ ERROR_MESSAGES = { "TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一", } _ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)} +ERROR_MESSAGES.update(MESSAGES) def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut: @@ -51,6 +54,8 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut: error_summary="任务有异常,请查看异常明细" if batch.status in ("FAILED", "PARTIAL") else None, + trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL", + automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0, ) @@ -119,4 +124,5 @@ def overview(session) -> TPlusOverviewOut: status_counts=repository.status_counts(session), total_staged_records=repository.total_staged_records(session), latest_audit=public_report(latest.audit_result_json) if latest else None, + automation=automation_overview(session, settings=task_service.get_settings()), ) diff --git a/backend/app/services/tplus/relationship_audit.py b/backend/app/services/tplus/relationship_audit.py index 80cf63e..cc29b96 100644 --- a/backend/app/services/tplus/relationship_audit.py +++ b/backend/app/services/tplus/relationship_audit.py @@ -29,10 +29,22 @@ _COLUMNS = { "order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"), } PENDING_CONFIRMATIONS = ( - ("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"), - ("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"), - ("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"), - ("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"), + ( + "cost_basis", + "月度指导成本", + "采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确", + ), + ( + "voucher_state", + "凭证例外核对", + "已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替", + ), + ( + "opening_balance", + "历史承接", + "发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供", + ), + ("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"), ("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"), ) diff --git a/backend/app/services/tplus/report_labels.py b/backend/app/services/tplus/report_labels.py new file mode 100644 index 0000000..ccc641f --- /dev/null +++ b/backend/app/services/tplus/report_labels.py @@ -0,0 +1,85 @@ +"""核对报告中的固定业务说明,未知编码保留以便排查。""" + +ACTION_LABELS = { + "CREATE": "新增", + "UPDATE": "更新", + "SKIP": "跳过", + "PENDING": "待补充", + "CONFLICT": "冲突", + "READY": "可核对", + "LEGACY": "旧应收冲平", + "WAITING_SHIPMENT": "预收待发货", + "EXCLUDED": "不纳入本次预演", +} +REASON_LABELS = { + "CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料", + "GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存", + "CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过", + "FORMAL_TARGET_CHANGED": "正式单据与已导入资料不一致,请先核对差异", + "INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复", + "BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演", + "FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异", + "SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入", + "CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入", + "PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入", + "PERSONNEL_IMPORT_REQUIRED": "请先完成相关业务员及经理的基础导入", + "NOT_IN_PERSONNEL_ROSTER": "不在已确认人员名单中", + "PERSONNEL_IDENTITY_CONFLICT": "工号与名单姓名不一致", + "ROSTER_SOURCE_MISSING": "名单工号未在T+找到", + "BUSINESS_ROLE_MISSING": "系统岗位不存在", + "PRODUCT_CATEGORY_PENDING": "产品核算分类待补", + "PRODUCT_UNIT_PENDING": "产品单位待核对", + "NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质", + "CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补", + "TARGET_MISSING": "原导入目标已不存在", + "TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突", + "DUPLICATE_SOURCE_IDENTITY": "源记录身份重复", + "GROUP_SCOPE_PENDING": "平账范围待确认", + "GROUP_FACT_REQUIRED": "尚未登记该平账组", + "OVERLAPPING_GROUPS": "同一单据被多个平账组重复选取", + "OPENING_BALANCE_PENDING": "期初应收待核实", + "HISTORICAL_SETTLEMENT_PENDING": "历史已核算或已发款待核实", + "VOUCHER_STATE_PENDING": "尚未核实收款已生成凭证", + "ACCOUNTING_VOUCHER_INVALID": "关联凭证缺失、已作废或制单状态异常", + "VOUCHER_EVIDENCE_CONFLICT": "凭证关联与人工核实不一致,请复核", + "NOT_GENERATED_VOUCHER": "无关联总账凭证,不纳入本次预演", + "NOT_RECEIPT": "属于付款单,不作为销售回款核算", + "GUIDANCE_PENDING": "缺少有效指导成本", + "QUANTITY_PENDING": "单位成本对应的发货数量需核对", + "GUIDANCE_MONTH_BASIS_PENDING": "指导价取值月份待确认", + "PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认", + "AUGUST_RULE_PENDING": "8月调整参数待确认", + "PERSONNEL_PENDING": "归属人员待确认", + "CUSTOMER_CATEGORY_PENDING": "客户分类待确认", + "PARAMETERS_PENDING": "计提参数待补", + "NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对", + "NEGATIVE_EVENT_REVIEW_REQUIRED": "红冲或作废业务需人工核对", + "LEGACY_SYSTEM_CALCULATES": "只冲旧应收,绩效由原软件计算", + "NO_ACTUAL_SHIPMENT": "未实际发货,暂不计提", + "FREIGHT_PENDING": "运费是否剔除及实际金额待核实", + "SHIPPING_OWNERSHIP_PENDING": "发货业务及人员归属待核实", + "HANDOVER_SHIPMENTS_PENDING": "在职交接的发货归属待核实", + "HANDOVER_PENDING": "离职日期或接手依据待补,不能猜测归属", + "TAX_AMOUNT_PENDING": "源含税金额缺失", + "SHIPMENT_STATE_PENDING": "销货单尚未核实为生效状态", + "GROUP_SOURCE_MISSING": "所选单据不在当前完整快照中", + "GROUP_CUSTOMER_MISMATCH": "平账组含有其他客户单据", + "SHIPMENT_DETAILS_MISSING": "发货明细不完整", + "SOURCE_REFRESH_REQUIRED": "需先完整读取相关源数据", + "AMOUNT_PRECISION_PENDING": "金额精度需核对", + "DATA_VALUE_INVALID": "源日期或金额格式需核对", +} + + +def reason_label(value): + return ";".join(REASON_LABELS.get(part, part) for part in (value or "").split(",") if part) + + +def spreadsheet_value(value): + """防公式注入,并让带前导零的编号在电子表格中保持文本。""" + text = str(value) if value is not None else "" + if text.lstrip(" \t\r\n").startswith(("=", "+", "-", "@")) or ( + text.isdigit() and ((text.startswith("0") and len(text) > 1) or len(text) > 15) + ): + return "'" + text + return value diff --git a/backend/app/services/tplus/snapshots.py b/backend/app/services/tplus/snapshots.py new file mode 100644 index 0000000..8ea132b --- /dev/null +++ b/backend/app/services/tplus/snapshots.py @@ -0,0 +1,53 @@ +"""工作区只使用成功完整读取发布的当前源快照。""" + +from sqlalchemy import select + +from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord +from app.services.tplus.workbench import WorkbenchError + + +def source_statement(session, source, entity): + """只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。""" + batches = session.scalars( + select(TPlusSyncBatch) + .where( + TPlusSyncBatch.source_database == source, + TPlusSyncBatch.sync_mode == "FULL", + ) + .order_by(TPlusSyncBatch.id.desc()) + ) + batch_no = None + for batch in batches: + report = batch.audit_result_json or {} + if entity in (batch.entity_types_json or []) and batch.status != "SUCCEEDED": + raise WorkbenchError("SOURCE_REFRESH_REQUIRED") + if entity in report.get("observed_entities", []): + batch_no = batch.batch_no + break + if batch_no is None: + raise WorkbenchError("SOURCE_REFRESH_REQUIRED") + return ( + select(TPlusSyncRecord) + .join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id) + .where( + TPlusSourceHead.source_database == source, + TPlusSourceHead.entity_type == entity, + TPlusSourceHead.batch_no == batch_no, + ) + ) + + +def source_records(session, source, entity, ids=None, parent_ids=None): + """旧暂存未建立当前指针时要求重新读取,禁止 max(record.id) 猜最新值。""" + if ids == [] or parent_ids == []: + return [] + statement = source_statement(session, source, entity) + if ids: + statement = statement.where(TPlusSourceHead.source_id.in_(ids)) + if parent_ids is not None: + statement = statement.where( + TPlusSyncRecord.payload_json["source_parent_id"] + .as_integer() + .in_([int(v) for v in parent_ids]) + ) + return list(session.scalars(statement.order_by(TPlusSyncRecord.source_id))) diff --git a/backend/app/services/tplus/source_heads.py b/backend/app/services/tplus/source_heads.py new file mode 100644 index 0000000..3745982 --- /dev/null +++ b/backend/app/services/tplus/source_heads.py @@ -0,0 +1,59 @@ +"""保留成功读取实际观察到的当前版本,不把历史最大ID当成当前值。""" + +from sqlalchemy import inspect, select, tuple_ + +from app.models import TPlusSourceHead, TPlusSyncRecord +from app.services.tplus.staging import prepare_row + + +def observe_rows(session, batch, mapping, rows): + """每页提交前记录所有读到的源版本,包括去重跳过的 A→B→A。""" + # V15 运行中的升级窗口继续允许原读取功能;工作区本身要求 V16。 + if not inspect(session.connection()).has_table(TPlusSourceHead.__tablename__): + return + prepared = [ + prepare_row(batch.batch_no, mapping.entity_type, mapping.source_table, r) + for r in rows + if r.get("id") is not None + ] + wanted = [(r["source_id"], r["row_hash"]) for r in prepared] + if not wanted: + return + records = { + (r.source_id, r.row_hash): r.id + for r in session.scalars( + select(TPlusSyncRecord).where( + TPlusSyncRecord.entity_type == mapping.entity_type, + tuple_(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).in_(wanted), + ) + ) + } + source = batch.source_database + if not source: + raise ValueError("源账套身份缺失,不能发布当前快照") + current = { + h.source_id: h + for h in session.scalars( + select(TPlusSourceHead).where( + TPlusSourceHead.source_database == source, + TPlusSourceHead.entity_type == mapping.entity_type, + TPlusSourceHead.source_id.in_([v[0] for v in wanted]), + ) + ) + } + for row in prepared: + record_id = records.get((row["source_id"], row["row_hash"])) + if record_id is None: + continue + head = current.get(row["source_id"]) + if head is None: + head = TPlusSourceHead( + source_database=source, + entity_type=mapping.entity_type, + source_id=row["source_id"], + record_id=record_id, + batch_no=batch.batch_no, + ) + session.add(head) + current[row["source_id"]] = head + head.record_id, head.batch_no = record_id, batch.batch_no diff --git a/backend/app/services/tplus/sync_service.py b/backend/app/services/tplus/sync_service.py index 6f1f79d..325c46e 100644 --- a/backend/app/services/tplus/sync_service.py +++ b/backend/app/services/tplus/sync_service.py @@ -11,8 +11,10 @@ from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.tplus_lock import sync_lock from app.models import TPlusSyncBatch, TPlusSyncError +from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error from app.services.tplus.mapping import TPlusMapping, get_mapping from app.services.tplus.reader import TPlusReader +from app.services.tplus.source_heads import observe_rows from app.services.tplus.staging import stage_rows from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running @@ -74,13 +76,18 @@ class TPlusSyncService: if batch.status != "RUNNING" or not batch.entity_types_json: raise ValueError("任务尚未领取或读取范围不完整") mappings = [get_mapping(entity) for entity in batch.entity_types_json] + interrupted = False for mapping in mappings: try: self._read_entity(batch, mapping, batch_size) except Exception as exc: self.target_session.rollback() - self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__) + code = classify_source_error(exc) + self._record_error(batch, mapping, {}, code, type(exc).__name__) self.target_session.commit() + if code in TRANSIENT_CODES: + interrupted = True + break batch.status = "SUCCEEDED" if batch.failed_record_count: failed_rows = ( @@ -94,7 +101,8 @@ class TPlusSyncService: ) or 0 ) - batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED" + # 中途断连仍是未完成的全量采集;保留已暂存页,重跑由哈希防重。 + batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED" batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照" batch.finished_at = datetime.now() batch.watermark_to = None @@ -106,12 +114,20 @@ class TPlusSyncService: source = self.reader.iter_entity(mapping.entity_type, batch_size=size) for chunk in batched(source, size): inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk)) + observe_rows(self.target_session, batch, mapping, list(chunk)) batch.source_record_count += len(chunk) batch.staged_record_count += inserted batch.skipped_record_count += len(chunk) - inserted - len(errors) for row, reason in errors: self._record_error(batch, mapping, row, "STAGE_ERROR", reason) self.target_session.commit() + report = dict(batch.audit_result_json or {}) + report["source_head_version"] = 1 + report["observed_entities"] = list( + dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type]) + ) + batch.audit_result_json = report + self.target_session.commit() def _record_error( self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str diff --git a/backend/app/services/tplus/task_service.py b/backend/app/services/tplus/task_service.py index 6777781..c5e6f48 100644 --- a/backend/app/services/tplus/task_service.py +++ b/backend/app/services/tplus/task_service.py @@ -16,6 +16,7 @@ from app.repositories import tplus_repository as repository from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn from app.services import audit_service from app.services.audit_service import AuditRecord, RequestMeta +from app.services.tplus.errors import MESSAGES @dataclass(frozen=True) @@ -164,6 +165,7 @@ def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None: "TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典", } message = messages.get(code, "任务未完成,请核对运行配置") + message = MESSAGES.get(code, message) batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message batch.failed_record_count = (batch.failed_record_count or 0) + 1 session.add( diff --git a/backend/app/services/tplus/workbench.py b/backend/app/services/tplus/workbench.py new file mode 100644 index 0000000..2929e3b --- /dev/null +++ b/backend/app/services/tplus/workbench.py @@ -0,0 +1,295 @@ +"""工作区任务与事实存取:与原有 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): + """可公开的固定失败编码;不得传入客户原文或驱动异常。""" diff --git a/backend/app/services/tplus/worker.py b/backend/app/services/tplus/worker.py index ead0b6a..e375690 100644 --- a/backend/app/services/tplus/worker.py +++ b/backend/app/services/tplus/worker.py @@ -7,14 +7,25 @@ from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings from app.core.tplus_lock import TPlusBusyError, sync_lock from app.repositories import tplus_repository as repository from app.services.tplus import task_service +from app.services.tplus.automation import WorkerHeartbeat +from app.services.tplus.errors import classify_source_error from app.services.tplus.relationship_audit import TPlusRelationshipAudit from app.services.tplus.sync_service import TPlusSyncService -def run_once(session, source_factory=None): +def run_once(session, source_factory=None, *, worker_owner=None): """返回本轮处理的批次号;忙锁或队列为空时不假称已执行。""" try: - with sync_lock(session): + with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner): + from sqlalchemy import inspect + + from app.models import TPlusWorkRun + from app.services.tplus.workbench import execute_next + + if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__): + number = execute_next(session) + if number: + return number task_service.recover_running(session) batch = repository.next_queued(session) if batch is None: @@ -30,10 +41,12 @@ def run_once(session, source_factory=None): def _execute(session, batch, source_factory): + configured = False try: settings = resolve_tplus_settings(task_service.get_settings()) if batch.source_database and batch.source_database != settings.t_plus_database: raise RuntimeError("任务排队后的源账套配置已改变") + configured = True factory = source_factory or get_tplus_session_factory() with factory() as source: if batch.sync_mode == "AUDIT": @@ -55,7 +68,7 @@ def _execute(session, batch, source_factory): batch, "INTERRUPTED" if isinstance(exc, (KeyboardInterrupt, SystemExit)) - else "EXECUTION_ERROR", + else classify_source_error(exc, "EXECUTION_ERROR" if configured else "SOURCE_CONFIG_ERROR"), ) if isinstance(exc, (KeyboardInterrupt, SystemExit)): raise diff --git a/backend/tests/README.md b/backend/tests/README.md index b0082b1..f6651ee 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -316,3 +316,13 @@ uv run pytest tests/integration -q # 仅集成 | 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed | > 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 + +## 2026-10-30 正式工资闭环与新回执验证 + +新增 `test_formal_payroll.py`(单元与MySQL集成)、隔离库保护用例;旧基础数据用例显式使用writer身份,历史公式/比例审批兼容用例显式启用legacy_calculation,不用测试放宽生产授权。 + +最终完整单元749项通过。隔离完整集成基线396通过、1个旧夹具权限失败,已修复并在75项受影响模块回归中通过;不将此记录冒充最终代码一次性全量集成通过。V18/V19迁移往返及结构9项通过。覆盖默认无审批痕迹、可选审核开启、客户名头合并FIFO、发货月成本封存、逐段金额与防重、未生成工资月份级联重算、固化分管和当月调整。详细证据见 `docs/正式工资闭环开发记录_2026-10-30.md`。 + +**禁止直接对原业务库执行集成测试。** 运行 `python scripts/run_isolated_tests.py` 创建受限测试库;夹具在执行前及清理前核验显式标记、名称及真实引擎目标。运行器保留隔离库便于复查,不打印临时凭据。 + +最新完整集成补验:全部29个测试文件分两套独立数据库覆盖,215+184=399项通过,失败0、跳过0。上述旧夹具失败已经修复并关闭;最终结果以本条和开发记录末节为准。完整单元749通过,V18/V19往返及结构9通过。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 18fab4c..4f1ead5 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -19,6 +19,7 @@ 评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等 """ +import os import uuid from decimal import Decimal from pathlib import Path @@ -81,7 +82,12 @@ def _purge_test_data() -> None: ``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配, 会把 tn... 这类无关账号一并卷入。 """ + from app.core.config import get_settings + from tests.isolation import ensure_test_database + + ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE")) factory = get_session_factory() + ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE")) with factory() as session: users = list( session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() @@ -104,6 +110,11 @@ def _purge_test_data() -> None: # ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除—— # ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面 # 的单据与人员清理;仅测试写入,全量清)---- + from app.models import PayrollAllocation, PayrollInput, PayrollMonth, TPlusAutomationState + session.execute(delete(TPlusAutomationState)) + session.execute(delete(PayrollAllocation)) + session.execute(delete(PayrollInput)) + session.execute(delete(PayrollMonth)) session.execute(delete(PerformanceLedger)) session.execute(delete(SettlementItem)) session.execute(delete(MonthlySettlement)) @@ -216,6 +227,11 @@ def _purge_test_data() -> None: @pytest.fixture(autouse=True) def _purge_after_each_test(): """每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" + from app.core.config import get_settings + from tests.isolation import ensure_test_database + + ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE")) + ensure_test_database(get_session_factory().kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE")) yield _purge_test_data() @@ -311,9 +327,11 @@ def make_role(): @pytest.fixture -def admin(make_user, auth_headers) -> dict: +def admin(make_user, auth_headers, monkeypatch) -> dict: """管理员账号(含请求头)。""" user = make_user([ROLE_ADMIN]) + from app.core.config import get_settings + monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user["id"]) user["headers"] = auth_headers(user) return user @@ -357,3 +375,18 @@ def tplus_tasks(monkeypatch): db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers))) db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers))) db.commit() + + +@pytest.fixture +def legacy_calculation(monkeypatch): + """明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。""" + from app.core.config import get_settings + monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True) + + +@pytest.fixture +def writer(normal, admin, monkeypatch): + """指定一个普通角色账号为工资制作人,证明身份授权与管理员角色无关。""" + from app.core.config import get_settings + monkeypatch.setattr(get_settings(), "payroll_maker_user_id", normal["id"]) + return normal diff --git a/backend/tests/integration/test_approval_flow.py b/backend/tests/integration/test_approval_flow.py index 414aa70..6eb0d3a 100644 --- a/backend/tests/integration/test_approval_flow.py +++ b/backend/tests/integration/test_approval_flow.py @@ -22,7 +22,7 @@ from fastapi.testclient import TestClient from tests.integration.factories import business_payload, customer -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _BUSINESSES = "/api/v1/business/businesses" _APPROVALS = "/api/v1/system/approvals" diff --git a/backend/tests/integration/test_approval_version.py b/backend/tests/integration/test_approval_version.py index b5281d4..808035e 100644 --- a/backend/tests/integration/test_approval_version.py +++ b/backend/tests/integration/test_approval_version.py @@ -33,7 +33,7 @@ from tests.integration.test_approval_flow import ( _set_switch, ) -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] @pytest.fixture diff --git a/backend/tests/integration/test_audit_retry.py b/backend/tests/integration/test_audit_retry.py index 98b31c8..64bc9bd 100644 --- a/backend/tests/integration/test_audit_retry.py +++ b/backend/tests/integration/test_audit_retry.py @@ -91,11 +91,11 @@ def _log_count(username: str, *, success: bool) -> int: return int(session.execute(stmt).scalar_one()) -def _baseline(normal: dict) -> tuple[int, int]: +def _baseline(writer: dict) -> tuple[int, int]: """POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。""" return ( - _log_count(normal["username"], success=True), - _log_count(normal["username"], success=False), + _log_count(writer["username"], success=True), + _log_count(writer["username"], success=False), ) @@ -103,12 +103,12 @@ class TestAuditRetry: """重试成功与持续失败两条路径的审计记录数。""" def test_首次撞号重试成功_审计恰一条真成功( - self, quiet_client: TestClient, normal: dict, monkeypatch + self, quiet_client: TestClient, writer: dict, monkeypatch ) -> None: client = quiet_client - customer_id = _customer(client, normal["headers"]) + customer_id = _customer(client, writer["headers"]) first = client.post( - _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + _BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"] ) assert first.status_code == 200, first.text existing_no = first.json()["data"]["business_no"] @@ -123,25 +123,25 @@ class TestAuditRetry: return real_generate(session) monkeypatch.setattr(business_service, "_generate_no", flaky_generate) - base_ok, base_fail = _baseline(normal) + base_ok, base_fail = _baseline(writer) second = client.post( - _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + _BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"] ) assert second.status_code == 200, second.text assert calls["n"] == 2, "重试应恰好再取一次号" # 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功 # 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0 - assert _log_count(normal["username"], success=True) == base_ok + 1 - assert _log_count(normal["username"], success=False) == base_fail + assert _log_count(writer["username"], success=True) == base_ok + 1 + assert _log_count(writer["username"], success=False) == base_fail def test_持续冲突_最终失败且审计恰一条失败( - self, quiet_client: TestClient, normal: dict, monkeypatch + self, quiet_client: TestClient, writer: dict, monkeypatch ) -> None: client = quiet_client - customer_id = _customer(client, normal["headers"]) + customer_id = _customer(client, writer["headers"]) first = client.post( - _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + _BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"] ) assert first.status_code == 200, first.text existing_no = first.json()["data"]["business_no"] @@ -150,10 +150,10 @@ class TestAuditRetry: return existing_no monkeypatch.setattr(business_service, "_generate_no", always_conflict) - base_ok, base_fail = _baseline(normal) + base_ok, base_fail = _baseline(writer) second = client.post( - _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + _BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"] ) # 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)—— # 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实 @@ -161,8 +161,8 @@ class TestAuditRetry: assert "完整性冲突" in second.json()["message"] # 修复前的症状:return _retry_create 无界递归直至 RecursionError, # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 - assert _log_count(normal["username"], success=False) == base_fail + 1 - assert _log_count(normal["username"], success=True) == base_ok + assert _log_count(writer["username"], success=False) == base_fail + 1 + assert _log_count(writer["username"], success=True) == base_ok def _receipt_log_count(username: str, *, success: bool) -> int: @@ -199,12 +199,12 @@ class TestReceiptAuditRetry: """ def test_首次撞号重试成功_审计恰一条真成功( - self, quiet_client: TestClient, normal: dict, monkeypatch + self, quiet_client: TestClient, writer: dict, monkeypatch ) -> None: client = quiet_client - business = create_business(client, normal["headers"]) + business = create_business(client, writer["headers"]) first = client.post( - _RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"] + _RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"] ) assert first.status_code == 200, first.text existing_no = first.json()["data"]["receipt_batch_no"] @@ -219,24 +219,24 @@ class TestReceiptAuditRetry: return real_generate(session) monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate) - base_ok = _receipt_log_count(normal["username"], success=True) - base_fail = _receipt_log_count(normal["username"], success=False) + base_ok = _receipt_log_count(writer["username"], success=True) + base_fail = _receipt_log_count(writer["username"], success=False) second = client.post( - _RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"] + _RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"] ) assert second.status_code == 200, second.text assert calls["n"] == 2, "重试应恰好再取一次号" - assert _receipt_log_count(normal["username"], success=True) == base_ok + 1 - assert _receipt_log_count(normal["username"], success=False) == base_fail + assert _receipt_log_count(writer["username"], success=True) == base_ok + 1 + assert _receipt_log_count(writer["username"], success=False) == base_fail def test_持续冲突_最终失败且审计恰一条失败( - self, quiet_client: TestClient, normal: dict, monkeypatch + self, quiet_client: TestClient, writer: dict, monkeypatch ) -> None: client = quiet_client - business = create_business(client, normal["headers"]) + business = create_business(client, writer["headers"]) first = client.post( - _RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"] + _RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"] ) assert first.status_code == 200, first.text existing_no = first.json()["data"]["receipt_batch_no"] @@ -245,12 +245,12 @@ class TestReceiptAuditRetry: return existing_no monkeypatch.setattr(receipt_service, "_generate_no", always_conflict) - base_ok = _receipt_log_count(normal["username"], success=True) - base_fail = _receipt_log_count(normal["username"], success=False) + base_ok = _receipt_log_count(writer["username"], success=True) + base_fail = _receipt_log_count(writer["username"], success=False) second = client.post( - _RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"] + _RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"] ) assert second.status_code == 400, second.text - assert _receipt_log_count(normal["username"], success=False) == base_fail + 1 - assert _receipt_log_count(normal["username"], success=True) == base_ok + assert _receipt_log_count(writer["username"], success=False) == base_fail + 1 + assert _receipt_log_count(writer["username"], success=True) == base_ok diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py index df59880..2b42a70 100644 --- a/backend/tests/integration/test_businesses.py +++ b/backend/tests/integration/test_businesses.py @@ -43,7 +43,7 @@ from app.models import ( from app.models.business import BUSINESS_STATUS_ACTIVE from app.models.customer import OWNER_TYPE_BUSINESS -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _BUSINESSES = "/api/v1/business/businesses" _PERSONNEL = "/api/v1/base/personnel" @@ -199,8 +199,8 @@ class TestCreate: """创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端 传值,列表检索与 M3 取数都会错乱。""" - def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_创建业务_编号格式与当日序号递增(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) first = _create(client, headers, customer["id"]) second = _create(client, headers, customer["id"]) @@ -218,18 +218,18 @@ class TestCreate: assert first["status"] == BUSINESS_STATUS_ACTIVE assert first["customer_name"] == customer["company_name"] - def test_空明细_400(self, client: TestClient, normal: dict) -> None: - customer = _customer(client, normal["headers"]) + def test_空明细_400(self, client: TestClient, writer: dict) -> None: + customer = _customer(client, writer["headers"]) resp = client.post( - _BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"] + _BUSINESSES, json=_payload(customer["id"], items=[]), headers=writer["headers"] ) assert resp.status_code == 400, resp.text - def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None: + def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, writer: dict) -> None: """数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的 DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入, 创建响应(读内存值)与复查(读库值)前后显示不一致。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -250,43 +250,43 @@ class TestCreate: ).scalar_one() assert db_amount == Decimal("5.17") - def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: - customer = _customer(client, normal["headers"]) + def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None: + customer = _customer(client, writer["headers"]) resp = client.post( _BUSINESSES, json=_payload(customer["id"], items=[_item(quantity="0")]), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "数量" in resp.json()["message"], resp.text - def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None: + def test_非法产品类别_400(self, client: TestClient, writer: dict) -> None: """类别是折算阈值的行级锚点:脏类别取不到阈值参数。""" - customer = _customer(client, normal["headers"]) + customer = _customer(client, writer["headers"]) resp = client.post( _BUSINESSES, json=_payload(customer["id"], items=[_item(product_category="water")]), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "产品类别不合法" in resp.json()["message"], resp.text - def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: - customer = _customer(client, normal["headers"]) + def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None: + customer = _customer(client, writer["headers"]) resp = client.post( _BUSINESSES, json=_payload(customer["id"], items=[_item(sale_price="abc")]), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "须为有效数字" in resp.json()["message"], resp.text def test_销售额由服务层固化_入参sale_amount不生效( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: """schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由 数量×单价计算,否则前端传值直接改业务销售额。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00") data = _create(client, headers, customer["id"], items=[item]) @@ -295,18 +295,18 @@ class TestCreate: assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02") assert Decimal(detail["total_sale_amount"]) == Decimal("37.02") - def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None: - customer = _customer(client, normal["headers"]) + def test_特殊业务缺说明_400(self, client: TestClient, writer: dict) -> None: + customer = _customer(client, writer["headers"]) resp = client.post( _BUSINESSES, json=_payload(customer["id"], is_special=1), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "特殊业务必须填写说明" in resp.json()["message"], resp.text - def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算") assert data["is_special"] == 1 @@ -317,8 +317,8 @@ class TestEdit: """编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 / 数据库 SUM)不一致就说明重算有旁路。""" - def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -365,10 +365,10 @@ class TestEdit: assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00") def test_编辑端点塞owner不生效_归属唯一走ownership( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: """归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) someone = _personnel(client, headers) @@ -387,10 +387,10 @@ class TestEdit: ] assert logs["total"] == 0, "编辑端点改归属却无留痕" - def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None: + def test_终态业务编辑_400且明细未动(self, client: TestClient, writer: dict) -> None: """终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态 业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create(client, headers, customer["id"]) _terminate(client, headers, data["id"]) @@ -412,10 +412,10 @@ class TestEdit: assert len(detail["items"]) == 1 assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") - def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None: + def test_二次分配方案合计超100_保存400(self, client: TestClient, writer: dict) -> None: """口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人—— 超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) part = _personnel(client, headers) @@ -435,8 +435,8 @@ class TestEdit: assert resp.status_code == 400, resp.text assert "超过 100%" in resp.json()["message"] - def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_二次分配参与人含负责人_保存400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) @@ -452,8 +452,8 @@ class TestEdit: assert resp.status_code == 400, resp.text assert "含合同负责营销人员" in resp.json()["message"] - def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) part = _personnel(client, headers) @@ -479,8 +479,8 @@ class TestEdit: class TestStatus: """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" - def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终止留痕_终态禁再变更(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) data = _create(client, headers, customer["id"]) @@ -514,14 +514,14 @@ class TestStatus: assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1) assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3) assert rows[1]["change_reason"] == "客户取消订单" - assert rows[1]["operator_name"] == normal["username"] + assert rows[1]["operator_name"] == writer["username"] class TestOwnership: """归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。""" - def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_归属变更_主表更新与留痕同生(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) old_owner = _personnel(client, headers) data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"]) @@ -555,7 +555,7 @@ class TestOwnership: assert row["old_owner_name"] == old_owner["personnel_name"] assert row["new_owner_name"] == new_owner["personnel_name"] assert row["change_reason"] == "人员调动" - assert row["operator_name"] == normal["username"] + assert row["operator_name"] == writer["username"] # owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属) factory = get_session_factory() @@ -567,8 +567,8 @@ class TestOwnership: f"owner_type 应为业务归属(2):{log.owner_type}" ) - def test_归属未变化_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_归属未变化_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) @@ -580,8 +580,8 @@ class TestOwnership: assert resp.status_code == 400, resp.text assert "归属未变化" in resp.json()["message"], resp.text - def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_创建时负责人员已离职_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] quit_owner = _personnel(client, headers) off = client.put( f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers @@ -596,8 +596,8 @@ class TestOwnership: assert resp.status_code == 400, resp.text assert "负责人员不存在或已离职" in resp.json()["message"], resp.text - def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_归属端点负责人员不存在_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) data = _create(client, headers, customer["id"]) resp = client.put( @@ -613,8 +613,8 @@ class TestCustomerProtect: """客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据 语义断裂 —— 服务层检查是唯一防线。""" - def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_有发货或回款时换客户_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) other = _customer(client, headers) data = _create(client, headers, customer["id"]) @@ -658,8 +658,8 @@ class TestCustomerProtect: assert blocked_receipt.status_code == 400, blocked_receipt.text assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text - def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_有发货或回款禁删_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) data = _create(client, headers, customer["id"]) @@ -693,8 +693,8 @@ class TestCustomerProtect: assert blocked_receipt.status_code == 400, blocked_receipt.text assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text - def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) owner = _personnel(client, headers) new_owner = _personnel(client, headers) @@ -745,10 +745,10 @@ class TestGrossProfit: """毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、 折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。""" - def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None: + def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, writer: dict) -> None: """销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% → 折算 10000 ×(997/1000 − 30/100) = 6970。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -778,10 +778,10 @@ class TestGrossProfit: assert cats[0]["profit_rate"] == "997/1000" assert Decimal(cats[0]["converted_profit"]) == Decimal("6970") - def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None: + def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, writer: dict) -> None: """单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常, 否则录入期(补价前)打开毛利润视图就是 500。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -805,9 +805,9 @@ class TestGrossProfit: assert Decimal(cats[0]["converted_profit"]) == Decimal("0") def test_毛利润_成本缺失行_派生为空不计入类别汇总( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -854,11 +854,11 @@ class TestGrossProfit: assert Decimal(cats[0]["converted_profit"]) == Decimal("3400") def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值( - self, client: TestClient, normal: dict, threshold_batch + self, client: TestClient, writer: dict, threshold_batch ) -> None: """改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」 靠 asOf 回溯参数快照,M3 台账才是法定固化点。""" - headers = normal["headers"] + headers = writer["headers"] customer = _customer(client, headers) data = _create( client, @@ -893,13 +893,13 @@ class TestGrossProfit: class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" - def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增业务成功留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _customer(client, headers) _create(client, headers, customer["id"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE ] assert logs, "新增业务未留操作日志" @@ -907,17 +907,17 @@ class TestAudit: assert latest.success == 1 assert "创建业务" in latest.summary - def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None: - customer = _customer(client, normal["headers"]) + def test_归属人员不存在失败留审计(self, client: TestClient, writer: dict) -> None: + customer = _customer(client, writer["headers"]) resp = client.post( _BUSINESSES, json=_payload(customer["id"], owner_personnel_id=999999999), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_BUSINESS and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py index d088b1f..a6f3e9a 100644 --- a/backend/tests/integration/test_calc.py +++ b/backend/tests/integration/test_calc.py @@ -26,7 +26,7 @@ from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocatio from app.models.receipt import SETTLEMENT_DONE from tests.integration.factories import business_payload, customer -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _CALC = "/api/v1/calc" _BUSINESSES = "/api/v1/business/businesses" @@ -338,8 +338,8 @@ class TestGuards: assert float(ledger["converted_profit"]) == -2000.0 assert float(ledger["performance_amount"]) < 0 - def test_准入_normal403_marketing_ops可用( - self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers + def test_准入_未指定账号拒绝_指定制作人可用( + self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch ) -> None: _biz, receipt = _create_full_chain( client, @@ -354,6 +354,9 @@ class TestGuards: ops = make_user(["marketing_ops"]) ops["headers"] = auth_headers(ops) + from app.core.config import get_settings + assert _calc(client, ops["headers"], receipt["id"]).status_code == 403 + monkeypatch.setattr(get_settings(), "payroll_maker_user_id", ops["id"]) resp = _calc(client, ops["headers"], receipt["id"]) assert resp.status_code == 200, resp.text @@ -426,7 +429,8 @@ class TestRecalc: ) assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 - fin = finance + assert client.post(f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "非制作人拒绝"}, headers=finance["headers"]).status_code == 403 + fin = admin resp = client.post( f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "成本录入修正"}, @@ -726,7 +730,7 @@ class TestSecondaryAllocation: resp = client.post( f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "用例:分摊重算"}, - headers=finance["headers"], + headers=admin["headers"], ) assert resp.status_code == 200, resp.text diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py index 5333aec..dc09b58 100644 --- a/backend/tests/integration/test_customers.py +++ b/backend/tests/integration/test_customers.py @@ -122,38 +122,38 @@ class TestAuth: class TestCrudFlow: """正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。""" - def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None: - owner = _personnel(client, normal["headers"]) - data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"]) + def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, writer: dict) -> None: + owner = _personnel(client, writer["headers"]) + data = _create_customer(client, writer["headers"], owner_personnel_id=owner["id"]) assert data["owner_personnel_id"] == owner["id"] assert data["owner_personnel_name"] == owner["personnel_name"] assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL - def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None: + def test_同公司名不同负责人创建成功(self, client: TestClient, writer: dict) -> None: company = _company() - first = _create_customer(client, normal["headers"], company_name=company) + first = _create_customer(client, writer["headers"], company_name=company) second = client.post( _CUSTOMERS, json=_cpayload(company, owner_name="李老板"), - headers=normal["headers"], + headers=writer["headers"], ) assert second.status_code == 200, second.text assert second.json()["data"]["id"] != first["id"] - def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None: + def test_重名同负责人_400(self, client: TestClient, writer: dict) -> None: company = _company() - _create_customer(client, normal["headers"], company_name=company) + _create_customer(client, writer["headers"], company_name=company) dup = client.post( _CUSTOMERS, json=_cpayload(company, owner_name="张老板"), - headers=normal["headers"], + headers=writer["headers"], ) assert dup.status_code == 400, dup.text assert "该公司名称与负责人的客户已存在" in dup.json()["message"] - def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None: + def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, writer: dict) -> None: """业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。""" - headers = normal["headers"] + headers = writer["headers"] customer = _create_customer(client, headers) resp = client.post( _BUSINESSES, @@ -175,20 +175,20 @@ class TestCrudFlow: assert blocked.status_code == 400, blocked.text assert "已存在关联业务" in blocked.json()["message"] - def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None: + def test_客户分类非法_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _CUSTOMERS, json=_cpayload(_company(), customer_type="not_a_type"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "客户分类不合法" in resp.json()["message"] def test_编辑其他字段不触归属_塞归属字段也被忽略( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: """归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。""" - headers = normal["headers"] + headers = writer["headers"] owner = _personnel(client, headers) stranger = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) @@ -220,8 +220,8 @@ class TestCrudFlow: class TestOwnership: """归属变更:主表更新与留痕同事务;校验失败不留脏数据。""" - def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_归属变更_主表更新且留痕新增(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] old = _personnel(client, headers) new = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=old["id"]) @@ -243,10 +243,10 @@ class TestOwnership: assert log.old_owner_personnel_id == int(old["id"]) assert log.new_owner_personnel_id == int(new["id"]) assert log.change_reason == "区域调整" - assert log.operator_name == normal["username"] + assert log.operator_name == writer["username"] - def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_变更后归属人员已离职_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] target = _personnel(client, headers) client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers) customer = _create_customer(client, headers) @@ -259,8 +259,8 @@ class TestOwnership: assert "归属人员不存在或已离职" in resp.json()["message"] assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕" - def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新旧归属相同_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] owner = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) resp = client.put( @@ -271,19 +271,19 @@ class TestOwnership: assert resp.status_code == 400, resp.text assert "归属未变化" in resp.json()["message"] - def test_双方均为空_400(self, client: TestClient, normal: dict) -> None: + def test_双方均为空_400(self, client: TestClient, writer: dict) -> None: """无归属客户再清一次归属也属「未变化」——不产生空转留痕。""" - customer = _create_customer(client, normal["headers"]) + customer = _create_customer(client, writer["headers"]) resp = client.put( f"{_CUSTOMERS}/{customer['id']}/ownership", json={"new_owner_personnel_id": None}, - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "归属未变化" in resp.json()["message"] - def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] owner = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) resp = client.put( @@ -306,8 +306,8 @@ class TestOwnership: class TestOwnershipLogs: """留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。""" - def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] first = _personnel(client, headers) second = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=first["id"]) @@ -343,11 +343,11 @@ class TestOwnershipLogs: class TestAudit: """写操作(含归属变更)留痕,摘要要能定位到客户。""" - def test_新增客户留审计(self, client: TestClient, normal: dict) -> None: - data = _create_customer(client, normal["headers"]) + def test_新增客户留审计(self, client: TestClient, writer: dict) -> None: + data = _create_customer(client, writer["headers"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE ] assert logs, "新增客户未留操作日志" @@ -355,8 +355,8 @@ class TestAudit: assert latest.success == 1 assert data["company_name"] in latest.summary - def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_归属变更审计摘要含客户名(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] old = _personnel(client, headers) new = _personnel(client, headers) customer = _create_customer(client, headers, owner_personnel_id=old["id"]) @@ -369,7 +369,7 @@ class TestAudit: logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_CUSTOMER and log.success == 1 ] assert logs, "归属变更未留操作日志" @@ -387,9 +387,9 @@ class TestRating: """ def test_保存评级_回显总分与时间_且编辑端点不含评分( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] customer = _create_customer(client, headers) assert customer["rating_score"] is None, "新客户应无评分" @@ -408,8 +408,8 @@ class TestRating: assert resp.status_code == 200, resp.text assert resp.json()["data"]["rating_score"] == 60 - def test_评分越界_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_评分越界_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] customer = _create_customer(client, headers) resp = client.put( f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers @@ -417,9 +417,9 @@ class TestRating: assert resp.status_code == 400, resp.text def test_按评分排序_未评分垫底_升序降序都不压过低分( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] high = _create_customer(client, headers) low = _create_customer(client, headers) unrated = _create_customer(client, headers) diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index a4d9978..496fd61 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -17,7 +17,7 @@ from decimal import Decimal import pytest from fastapi.testclient import TestClient -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _DASHBOARD = "/api/v1/dashboard" _PERSONNEL = "/api/v1/base/personnel" diff --git a/backend/tests/integration/test_formal_payroll.py b/backend/tests/integration/test_formal_payroll.py new file mode 100644 index 0000000..81b7d1a --- /dev/null +++ b/backend/tests/integration/test_formal_payroll.py @@ -0,0 +1,315 @@ +"""真实MySQL验证正式核算、工资生成锁、身份权限及冻结报表。""" + +from concurrent.futures import ThreadPoolExecutor +from decimal import Decimal +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from app.core.db import get_session_factory +from app.models import ( + AdjustmentOrder, + ApprovalOrder, + ApprovalStep, + BusinessRole, + PayrollMonth, + PerformanceLedger, + Personnel, +) +from tests.integration.factories import business_payload, customer + +pytestmark = pytest.mark.integration +BASE = "/api/v1/payroll" + + +def post(client, path, headers, data): + response = client.post(path, headers=headers, json=data) + assert response.status_code == 200, response.text + return response.json()["data"] + + +@pytest.fixture +def formal_chain(client, admin): + headers = admin["headers"] + with get_session_factory()() as s: + roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))} + persons = [] + for code in ("sales_manager", "salesperson"): + persons.append( + post( + client, + "/api/v1/base/personnel", + headers, + { + "personnel_name": "t_正式工资_" + uuid4().hex[:8], + "business_role_id": roles[code], + "salary_standard": "0", + }, + ) + ) + manager, owner = persons + cust = customer(client, headers) + item = { + "product_name": "t_正式产品", + "spec_model": "", + "product_category": "municipal_water", + "quantity": "2", + "sale_price": "50", + "guided_price": "25", + } + business = post( + client, + "/api/v1/business/businesses", + headers, + business_payload(cust["id"], owner_personnel_id=owner["id"], items=[item]), + ) + shipment = post( + client, + "/api/v1/business/shipments", + headers, + { + "business_id": business["id"], + "ship_date": "2026-03-10", + "freight_amount": "0", + "items": [ + { + "product_name": item["product_name"], + "spec_model": "", + "quantity": "2", + "sale_price": "50", + } + ], + }, + ) + detail = client.get(f"/api/v1/business/shipments/{shipment['id']}", headers=headers).json()[ + "data" + ] + receipt = post( + client, + "/api/v1/business/receipts", + headers, + {"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"}, + ) + setup = { + "month": "2026-03", + "expected_version": 0, + "evidence": "隔离样本已逐笔核实", + "opening_balance": "0", + "history_confirmed": True, + "guidance_finalized": True, + "shipment_ownerships": [ + { + "shipment_item_id": detail["items"][0]["id"], + "personnel_id": owner["id"], + "manager_id": manager["id"], + "unit_cost": "25", + } + ], + } + post(client, f"{BASE}/businesses/{business['id']}/input", headers, setup) + return business, receipt, setup, owner, manager + + +def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain): + business, _, setup, owner, manager = formal_chain + headers = admin["headers"] + result = post( + client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"} + ) + assert result["performance"] == "0.20" and result["payable"] == "0.20" + generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"}) + assert generated["status"] == 3 and not generated["approval_required"] + with get_session_factory()() as s: + assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0 + assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0 + assert Decimal(generated["total_payable"]) == Decimal("0.24") + assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"] + setup["expected_version"] = 1 + assert ( + client.post( + f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup + ).status_code + == 400 + ) + assert ( + client.post( + f"{BASE}/businesses/{business['id']}/calculate", + headers=headers, + json={"month": "2026-03", "reason": "不允许绕过生成锁"}, + ).status_code + == 400 + ) + with get_session_factory()() as s: + s.get(Personnel, owner["id"]).manager_id = None + s.commit() + report = client.get( + "/api/v1/reports/department-summary", + headers=headers, + params={"settlement_month": "2026-03"}, + ) + assert report.status_code == 200, report.text + data = report.json()["data"] + team = next(t for t in data["teams"] if t["manager_id"] == manager["id"]) + assert Decimal(team["team_base"]) == Decimal("0.20") + assert Decimal(data["totals"]["payable_total"]) == Decimal(generated["total_payable"]) + + +def test_非制作人包括其他管理员均不能写( + client, admin, normal, make_user, auth_headers, formal_chain +): + business, _, setup, _, _ = formal_chain + other_admin = make_user(["admin"]) + for headers in (normal["headers"], auth_headers(other_admin)): + assert ( + client.post( + f"{BASE}/businesses/{business['id']}/calculate", + headers=headers, + json={"month": "2026-03"}, + ).status_code + == 403 + ) + assert ( + client.post( + f"{BASE}/businesses/{business['id']}/input", headers=headers, json=setup + ).status_code + == 403 + ) + assert ( + client.post("/api/v1/business/businesses", headers=headers, json={}).status_code == 403 + ) + assert ( + client.get( + f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"] + ).status_code + == 200 + ) + + +def test_并发核算仅一份正式结果(client, admin, formal_chain): + business, _, _, _, _ = formal_chain + + def call(): + return client.post( + f"{BASE}/businesses/{business['id']}/calculate", + headers=admin["headers"], + json={"month": "2026-03"}, + ).status_code + + with ThreadPoolExecutor(max_workers=2) as pool: + statuses = list(pool.map(lambda _: call(), range(2))) + assert sorted(statuses) == [200, 400] + with get_session_factory()() as s: + rows = list( + s.scalars( + select(PerformanceLedger).where( + PerformanceLedger.business_id == business["id"], + PerformanceLedger.is_active == 1, + ) + ) + ) + assert len(rows) == 1 + + +def test_生成失败不锁定且重新生成不解锁(client, admin, formal_chain): + business, _, _, _, _ = formal_chain + headers = admin["headers"] + assert ( + client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"} + ).status_code + == 400 + ) + with get_session_factory()() as s: + control = s.scalar(select(PayrollMonth)) + assert control is None or control.generated_at is None + post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}) + post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"}) + assert ( + client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2026-03"} + ).status_code + == 400 + ) + assert client.get(BASE + "/months/2026-03", headers=headers).json()["data"]["allocation_locked"] + + +def test_保留可选审核_默认关闭_开启后原节点可用( + client, admin, formal_chain, make_user, auth_headers, monkeypatch +): + from app.core.config import get_settings + + monkeypatch.setattr(get_settings(), "payroll_approval_enabled", True) + business, _, _, _, _ = formal_chain + headers = admin["headers"] + post(client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"}) + generated = post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"}) + assert generated["status"] == 1 and generated["approval_required"] + post(client, f"/api/v1/settlements/{generated['id']}/submit", headers, {}) + with get_session_factory()() as s: + order = s.scalar( + select(ApprovalOrder).where( + ApprovalOrder.ref_id == generated["id"], ApprovalOrder.order_type == 2 + ) + ) + order_id = order.id + finance = make_user(["finance"]) + leader = make_user(["leader"]) + first = post( + client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(finance), {"action": 1} + ) + assert first["status"] == 2 + last = post( + client, f"/api/v1/system/approvals/{order_id}/action", auth_headers(leader), {"action": 1} + ) + assert last["status"] == 3 + + +def test_分配调整固定当月且关闭审核不留审批痕迹(client, admin, formal_chain): + from app.services.payroll_months import current_month + + business, _, _, owner, manager = formal_chain + headers = admin["headers"] + result = post( + client, f"{BASE}/businesses/{business['id']}/calculate", headers, {"month": "2026-03"} + ) + post(client, "/api/v1/settlements/generate", headers, {"month": "2026-03"}) + adjustment = post( + client, + BASE + "/allocation-adjustments", + headers, + { + "ledger_id": result["ledger_ids"][0], + "personnel_id": owner["id"], + "direction": 1, + "amount": "0.01", + "reason": "逐笔核对差额", + }, + ) + with get_session_factory()() as s: + order = s.get(AdjustmentOrder, adjustment["id"]) + assert order.status == 2 and order.settlement_month == current_month() + assert order.attribution_json["manager_id"] == manager["id"] + assert s.scalar(select(func.count()).select_from(ApprovalOrder)) == 0 + assert s.scalar(select(func.count()).select_from(ApprovalStep)) == 0 + + +def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain): + _, _receipt, _, _, _ = formal_chain + # 使用没有正式准备的新业务,验证旧批次入口默认关闭,而不是依赖角色拒绝。 + business = post( + client, + "/api/v1/business/businesses", + admin["headers"], + business_payload(customer(client, admin["headers"])["id"]), + ) + empty_receipt = post( + client, + "/api/v1/business/receipts", + admin["headers"], + {"business_id": business["id"], "receipt_date": "2026-03-10", "amount": "100"}, + ) + response = client.post( + f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"] + ) + assert response.status_code == 400 and "正式核算" in response.json()["message"] diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py index 2dc94c0..f064653 100644 --- a/backend/tests/integration/test_personnel.py +++ b/backend/tests/integration/test_personnel.py @@ -90,51 +90,51 @@ class TestAuth: class TestCrudFlow: """正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。""" - def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) assert data["status"] == 1, "新增人员应默认在职" assert data["is_probation"] == 0 # 金额红线:str 进 str 出,不得经 float assert data["salary_standard"] == "3500.50" - detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"]) assert detail.status_code == 200, detail.text body = detail.json()["data"] assert body["salary_standard"] == "3500.50" assert body["regions"] == [] assert body["plans"] == [] - def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] manager = _create(client, headers) member = _create(client, headers, manager_id=manager["id"]) assert member["manager_id"] == manager["id"] assert member["manager_name"] == manager["personnel_name"] assert member["role_name"] == manager["role_name"] - def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) listed = client.get( - _PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"] + _PERSONNEL, params={"keyword": data["personnel_name"]}, headers=writer["headers"] ) assert listed.status_code == 200, listed.text rows = listed.json()["data"]["list"] assert [r["id"] for r in rows] == [data["id"]] - def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) updated = client.put( f"{_PERSONNEL}/{data['id']}", json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1), - headers=normal["headers"], + headers=writer["headers"], ) assert updated.status_code == 200, updated.text body = updated.json()["data"] assert body["phone"] == "13900001111" assert body["is_probation"] == 1 - def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_在职离职双向切换(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] data = _create(client, headers) off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers) assert off.status_code == 200, off.text @@ -143,11 +143,11 @@ class TestCrudFlow: assert on.status_code == 200, on.text assert on.json()["data"]["status"] == 1 - def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) - resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) + resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"]) assert resp.status_code == 200, resp.text - detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"]) assert detail.status_code == 404 assert "人员不存在" in detail.json()["message"] @@ -155,27 +155,27 @@ class TestCrudFlow: class TestRefRules: """岗位/经理引用校验 —— 核算锚点不悬空、不环引用。""" - def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) resp = client.put( f"{_PERSONNEL}/{data['id']}", json=_payload(data["personnel_name"], manager_id=data["id"]), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "直属经理不能是自己" in resp.json()["message"] - def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None: + def test_直属经理不存在_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _PERSONNEL, json=_payload(_uname(), manager_id=999999999), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "直属经理不存在或已离职" in resp.json()["message"] - def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] manager = _create(client, headers) quit_resp = client.put( f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers @@ -189,16 +189,16 @@ class TestRefRules: assert resp.status_code == 400, resp.text assert "直属经理不存在或已离职" in resp.json()["message"] - def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None: + def test_岗位不存在_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _PERSONNEL, json=_payload(_uname(), business_role_id=999999999), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "岗位不存在或已停用" in resp.json()["message"] - def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None: + def test_岗位已停用_400(self, client: TestClient, writer: dict) -> None: """停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。""" code = f"t_br_{uuid.uuid4().hex[:8]}" factory = get_session_factory() @@ -220,7 +220,7 @@ class TestRefRules: resp = client.post( _PERSONNEL, json=_payload(_uname(), business_role_id=role_id), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "岗位不存在或已停用" in resp.json()["message"] @@ -229,8 +229,8 @@ class TestRefRules: class TestDeleteProtection: """删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。""" - def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] manager = _create(client, headers) member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) @@ -244,9 +244,9 @@ class TestDeleteProtection: assert freed.status_code == 200, freed.text def test_被客户归属引用禁删_离职不放宽_解除后可删( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] owner = _create(client, headers) created = client.post( _CUSTOMERS, @@ -277,9 +277,9 @@ class TestDeleteProtection: freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) assert freed.status_code == 200, freed.text - def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None: + def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None: """业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。""" - headers = normal["headers"] + headers = writer["headers"] owner = _create(client, headers) assistant = _create(client, headers) resp_customer = client.post( @@ -328,9 +328,9 @@ class TestSubTableReplace: """辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。""" def test_辐射区域设置后详情回显一致_再次替换生效( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] data = _create(client, headers) resp = client.put( f"{_PERSONNEL}/{data['id']}/regions", @@ -357,8 +357,8 @@ class TestSubTableReplace: detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"] - def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] data = _create(client, headers) client.put( f"{_PERSONNEL}/{data['id']}/regions", @@ -373,9 +373,9 @@ class TestSubTableReplace: assert detail["regions"] == [] def test_拓展计划设置后详情回显一致_再次替换生效( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] data = _create(client, headers) resp = client.put( f"{_PERSONNEL}/{data['id']}/plans", @@ -405,8 +405,8 @@ class TestSubTableReplace: detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"] - def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] data = _create(client, headers) client.put( f"{_PERSONNEL}/{data['id']}/plans", @@ -424,11 +424,11 @@ class TestSubTableReplace: class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" - def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE ] assert logs, "新增人员未留操作日志" @@ -436,16 +436,16 @@ class TestAudit: assert latest.success == 1 assert data["personnel_name"] in latest.summary - def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None: + def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None: resp = client.post( _PERSONNEL, json=_payload(_uname(), manager_id=999999999), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_PERSONNEL and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_products.py b/backend/tests/integration/test_products.py index d940002..b0b526e 100644 --- a/backend/tests/integration/test_products.py +++ b/backend/tests/integration/test_products.py @@ -122,8 +122,8 @@ class TestAuth: class TestCrudFlow: """正向链路:建 → 查 → 改(含换类别)→ 删。""" - def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) assert data["status"] == 1, "新产品应默认启用" assert data["spec_model"] == "DN100" # 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float) @@ -132,34 +132,34 @@ class TestCrudFlow: assert isinstance(data["stock_quantity"], str) assert Decimal(data["stock_quantity"]) == Decimal("100") - def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) - detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + def test_详情与列表均能查到新增产品(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"]) assert detail.status_code == 200, detail.text assert detail.json()["data"]["id"] == data["id"] listed = client.get( - _PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"] + _PRODUCTS, params={"keyword": data["product_name"]}, headers=writer["headers"] ) assert listed.status_code == 200, listed.text assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]] - def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None: + def test_同名称不同规格创建成功(self, client: TestClient, writer: dict) -> None: name = _pname() - first = _create(client, normal["headers"], product_name=name, spec_model="DN100") + first = _create(client, writer["headers"], product_name=name, spec_model="DN100") second = client.post( _PRODUCTS, json=_payload(name, spec_model="DN200"), - headers=normal["headers"], + headers=writer["headers"], ) assert second.status_code == 200, second.text assert second.json()["data"]["id"] != first["id"] - def test_删除产品成功(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) - resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + def test_删除产品成功(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) + resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"]) assert resp.status_code == 200, resp.text - detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"]) assert detail.status_code == 404 assert "产品不存在" in detail.json()["message"] @@ -168,29 +168,29 @@ class TestDeleteProtection: """删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。""" def test_被业务明细引用_400且产品仍在( - self, client: TestClient, normal: dict, make_business_for_product + self, client: TestClient, writer: dict, make_business_for_product ) -> None: - product = _create(client, normal["headers"]) - business = make_business_for_product(normal["headers"], product["id"]) - resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + product = _create(client, writer["headers"]) + business = make_business_for_product(writer["headers"], product["id"]) + resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"]) assert resp.status_code == 400, resp.text assert "引用" in resp.json()["message"] # 产品仍在(引导改走停用而非物理删除) - still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + still = client.get(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"]) assert still.status_code == 200 # 业务明细行确实引用了该产品(护栏:确认保护不是空转) - detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[ + detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=writer["headers"]).json()[ "data" ] assert detail["items"][0]["product_id"] == product["id"] def test_被发货明细引用_400( - self, client: TestClient, normal: dict, make_business_for_product + self, client: TestClient, writer: dict, make_business_for_product ) -> None: - product = _create(client, normal["headers"]) - make_business_for_product(normal["headers"], product["id"]) + product = _create(client, writer["headers"]) + make_business_for_product(writer["headers"], product["id"]) # 在业务明细引用之外再挂一笔发货明细(同样引用 products.id) - businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[ + businesses = client.get("/api/v1/business/businesses", headers=writer["headers"]).json()[ "data" ] business_id = businesses["list"][0]["id"] @@ -208,68 +208,68 @@ class TestDeleteProtection: } ], }, - headers=normal["headers"], + headers=writer["headers"], ) assert shipment.status_code == 200, shipment.text # 此前业务明细引用已令删除被拒,此处再证发货引用同一防线 - resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"]) + resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"]) assert resp.status_code == 400, resp.text class TestValidationRules: """类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。""" - def test_重名同规格_400(self, client: TestClient, normal: dict) -> None: + def test_重名同规格_400(self, client: TestClient, writer: dict) -> None: name = _pname() - _create(client, normal["headers"], product_name=name) + _create(client, writer["headers"], product_name=name) dup = client.post( _PRODUCTS, json=_payload(name, spec_model="DN100"), - headers=normal["headers"], + headers=writer["headers"], ) assert dup.status_code == 400, dup.text assert "已存在同名称同规格的产品" in dup.json()["message"] - def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None: + def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, writer: dict) -> None: """组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。""" name = _pname() - first = _create(client, normal["headers"], product_name=name, spec_model=None) + first = _create(client, writer["headers"], product_name=name, spec_model=None) assert first["spec_model"] == "" dup = client.post( _PRODUCTS, json=_payload(name, spec_model=None), - headers=normal["headers"], + headers=writer["headers"], ) assert dup.status_code == 400, dup.text assert "已存在同名称同规格的产品" in dup.json()["message"] - def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None: + def test_产品类别非法_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _PRODUCTS, json=_payload(_pname(), product_category="not_a_category"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "产品类别不合法" in resp.json()["message"] - def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None: + def test_销售单价为负数_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _PRODUCTS, json=_payload(_pname(), sale_price="-1"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text - def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: + def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None: resp = client.post( _PRODUCTS, json=_payload(_pname(), sale_price="abc"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text - def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_编辑改类别为合法值成功(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) updated = client.put( f"{_PRODUCTS}/{data['id']}", json=_payload( @@ -277,7 +277,7 @@ class TestValidationRules: product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, sale_price="15.8", ), - headers=normal["headers"], + headers=writer["headers"], ) assert updated.status_code == 200, updated.text body = updated.json()["data"] @@ -288,8 +288,8 @@ class TestValidationRules: class TestStatusSwitch: """启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。""" - def test_停用再启用切换(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_停用再启用切换(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] data = _create(client, headers) off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers) assert off.status_code == 200, off.text @@ -304,11 +304,11 @@ class TestStatusSwitch: class TestAudit: """写操作(含失败)留痕 —— 审计是验收项。""" - def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None: - data = _create(client, normal["headers"]) + def test_新增产品成功留审计(self, client: TestClient, writer: dict) -> None: + data = _create(client, writer["headers"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE ] assert logs, "新增产品未留操作日志" @@ -316,18 +316,18 @@ class TestAudit: assert latest.success == 1 assert data["product_name"] in latest.summary - def test_重名失败留审计(self, client: TestClient, normal: dict) -> None: + def test_重名失败留审计(self, client: TestClient, writer: dict) -> None: name = _pname() - _create(client, normal["headers"], product_name=name) + _create(client, writer["headers"], product_name=name) dup = client.post( _PRODUCTS, json=_payload(name, spec_model="DN100"), - headers=normal["headers"], + headers=writer["headers"], ) assert dup.status_code == 400, dup.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_PRODUCT and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_ratings.py b/backend/tests/integration/test_ratings.py index 94436f9..87e5d9b 100644 --- a/backend/tests/integration/test_ratings.py +++ b/backend/tests/integration/test_ratings.py @@ -122,8 +122,8 @@ class TestOverlapGuardLock: class TestList: """列表直出种子基线:判定表是前端评级计算器的唯一数据源。""" - def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None: - resp = client.get(_GRADES, headers=normal["headers"]) + def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, writer: dict) -> None: + resp = client.get(_GRADES, headers=writer["headers"]) assert resp.status_code == 200, resp.text rows = resp.json()["data"] assert [(r["grade_name"], r["sort_order"]) for r in rows] == [ @@ -140,11 +140,11 @@ class TestCreate: 任一缺失都会算错等级。""" def test_删除种子后新增相邻区间_成功且判定表更新( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: """种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74), 新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。""" - headers = normal["headers"] + headers = writer["headers"] client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) resp = client.post( _GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers @@ -158,48 +158,48 @@ class TestCreate: rows = client.get(_GRADES, headers=headers).json()["data"] assert _names(client, headers) == ["A", "B", "D", "t_E"], rows - def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None: - resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"]) + def test_区间倒挂_400(self, client: TestClient, writer: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=writer["headers"]) assert resp.status_code == 400, resp.text assert "分值区间不合法" in resp.json()["message"], resp.text - def test_分值超上限_400(self, client: TestClient, normal: dict) -> None: + def test_分值超上限_400(self, client: TestClient, writer: dict) -> None: resp = client.post( - _GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"] + _GRADES, json=_gpayload(_tname(), "95", "150"), headers=writer["headers"] ) assert resp.status_code == 400, resp.text assert "分值区间不合法" in resp.json()["message"], resp.text - def test_分值负下限_400(self, client: TestClient, normal: dict) -> None: - resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"]) + def test_分值负下限_400(self, client: TestClient, writer: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=writer["headers"]) assert resp.status_code == 400, resp.text assert "分值区间不合法" in resp.json()["message"], resp.text - def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None: + def test_等级名称已存在_400(self, client: TestClient, writer: dict) -> None: """先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞 「与等级 D 重叠」而走不到名称校验。""" - headers = normal["headers"] + headers = writer["headers"] _drop_seed(client, headers, "D") resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers) assert resp.status_code == 400, resp.text assert "该等级名称已存在" in resp.json()["message"], resp.text - def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None: """85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。""" - resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"]) assert resp.status_code == 400, resp.text assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text # 失败不落库:判定表仍是 4 条种子 - assert _names(client, normal["headers"]) == ["A", "B", "C", "D"] + assert _names(client, writer["headers"]) == ["A", "B", "C", "D"] class TestEdit: """编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与 新增路共用重叠约束,两处都要拦。""" - def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None: """把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。""" - headers = normal["headers"] + headers = writer["headers"] b_id = _seed_id(client, headers, "B") resp = client.put( f"{_GRADES}/{b_id}", @@ -211,10 +211,10 @@ class TestEdit: # 失败不落库:种子保持原样 assert _names(client, headers) == ["A", "B", "C", "D"] - def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None: + def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, writer: dict) -> None: """删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠** (74 与 75 相邻),这是种子铺法的正例。""" - headers = normal["headers"] + headers = writer["headers"] client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) d_id = _seed_id(client, headers, "D") resp = client.put( @@ -228,8 +228,8 @@ class TestEdit: assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)] assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2] - def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_编辑回显改名与分数(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 grade_name = _tname() created = client.post( @@ -261,8 +261,8 @@ class TestDelete: """删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 立即反映,二次删除 404。""" - def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_删除等级后判定表更新_二次删除404(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 grade_name = _tname() created = client.post( @@ -283,8 +283,8 @@ class TestDelete: class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" - def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增等级成功留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 grade_name = _tname() resp = client.post( @@ -295,7 +295,7 @@ class TestAudit: assert resp.status_code == 200, resp.text logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_RATING and log.action == ACTION_CREATE ] assert logs, "新增评级等级未留操作日志" @@ -303,12 +303,12 @@ class TestAudit: assert latest.success == 1 assert grade_name in latest.summary - def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None: - resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + def test_重叠失败留审计(self, client: TestClient, writer: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"]) assert resp.status_code == 400, resp.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_RATING and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_receipts.py b/backend/tests/integration/test_receipts.py index 1a5c897..360390a 100644 --- a/backend/tests/integration/test_receipts.py +++ b/backend/tests/integration/test_receipts.py @@ -122,8 +122,8 @@ class TestAuth: class TestCreate: """创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。""" - def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) @@ -140,10 +140,10 @@ class TestCreate: assert data["settlement_status"] == 0 assert Decimal(data["amount"]) == Decimal("1200") - def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None: + def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, writer: dict) -> None: """发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天, 需规「26 天」示例已作废)。""" - headers = normal["headers"] + headers = writer["headers"] business = _create_business(client, headers) shipment = _create_shipment(client, headers, business["id"], "2026-10-05") data = _create( @@ -158,8 +158,8 @@ class TestCreate: assert data["shipment_no"] == shipment["shipment_no"] assert data["payment_days"] == 29, data - def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) @@ -170,35 +170,35 @@ class TestCreate: rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] assert Decimal(rollback["total_received_amount"]) == Decimal("0") - def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: - resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"]) + def test_业务不存在_404(self, client: TestClient, writer: dict) -> None: + resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=writer["headers"]) assert resp.status_code == 404, resp.text assert "业务不存在" in resp.json()["message"], resp.text - def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终态业务禁新增回款_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) _terminate(client, headers, business["id"]) resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers) assert resp.status_code == 400, resp.text assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text - def test_金额负数_400(self, client: TestClient, normal: dict) -> None: + def test_金额负数_400(self, client: TestClient, writer: dict) -> None: """一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。""" - business = _create_business(client, normal["headers"]) + business = _create_business(client, writer["headers"]) resp = client.post( - _RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"] + _RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=writer["headers"] ) assert resp.status_code == 400, resp.text assert "不能为负数" in resp.json()["message"], resp.text - def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None: + def test_金额三位小数_400(self, client: TestClient, writer: dict) -> None: """小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。""" - business = _create_business(client, normal["headers"]) + business = _create_business(client, writer["headers"]) resp = client.post( _RECEIPTS, json=_rpayload(business["id"], amount="12.345"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "最多两位小数" in resp.json()["message"], resp.text @@ -208,8 +208,8 @@ class TestCrossBusiness: """发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应 差异探测发货单存在性。""" - def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_跨业务指定发货批次_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) @@ -222,9 +222,9 @@ class TestCrossBusiness: assert resp.status_code == 400, resp.text assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text - def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None: + def test_编辑时指定跨业务发货批次_400(self, client: TestClient, writer: dict) -> None: """编辑路径(全量替换)同样强校验,无「未变即免检」旁路。""" - headers = normal["headers"] + headers = writer["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) @@ -242,8 +242,8 @@ class TestCrossBusiness: assert resp.status_code == 400, resp.text assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text - def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) shipment = _create_shipment(client, headers, business["id"], "2026-10-05") data = _create(client, headers, business["id"]) @@ -279,8 +279,8 @@ class TestCrossBusiness: class TestSettled: """已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。""" - def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_已核算禁改_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _settle(data["id"]) @@ -297,8 +297,8 @@ class TestSettled: assert resp.status_code == 400, resp.text assert "已核算回款禁止修改" in resp.json()["message"], resp.text - def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_已核算禁删_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _settle(data["id"]) @@ -312,9 +312,9 @@ class TestFilter: """核算状态筛选(M3 核算消费的入口口径)。""" def test_settlement_status筛选_0只回未核算_1只回已核算( - self, client: TestClient, normal: dict + self, client: TestClient, writer: dict ) -> None: - headers = normal["headers"] + headers = writer["headers"] business = _create_business(client, headers) pending = _create(client, headers, business["id"], amount="500.00") settled = _create(client, headers, business["id"], amount="700.00") @@ -343,13 +343,13 @@ class TestFilter: class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" - def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增回款成功留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE ] assert logs, "新增回款未留操作日志" @@ -358,8 +358,8 @@ class TestAudit: assert "新增回款" in latest.summary assert data["receipt_batch_no"] in latest.summary - def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_跨业务指定失败留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business1 = _create_business(client, headers) shipment = _create_shipment(client, headers, business1["id"], str(date.today())) business2 = _create_business(client, headers) @@ -371,7 +371,7 @@ class TestAudit: assert resp.status_code == 400, resp.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_RECEIPT and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_reports.py b/backend/tests/integration/test_reports.py index 4da0726..1154905 100644 --- a/backend/tests/integration/test_reports.py +++ b/backend/tests/integration/test_reports.py @@ -15,7 +15,7 @@ from decimal import Decimal import pytest from fastapi.testclient import TestClient -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _REPORTS = "/api/v1/reports" _PERSONNEL = "/api/v1/base/personnel" diff --git a/backend/tests/integration/test_secondary_allocation.py b/backend/tests/integration/test_secondary_allocation.py index 9323fa9..5eb5204 100644 --- a/backend/tests/integration/test_secondary_allocation.py +++ b/backend/tests/integration/test_secondary_allocation.py @@ -19,7 +19,7 @@ from fastapi.testclient import TestClient from tests.integration.factories import business_payload, customer -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _BUSINESSES = "/api/v1/business/businesses" _PERSONNEL = "/api/v1/base/personnel" diff --git a/backend/tests/integration/test_settlements.py b/backend/tests/integration/test_settlements.py index 9142fd5..d5f1ccd 100644 --- a/backend/tests/integration/test_settlements.py +++ b/backend/tests/integration/test_settlements.py @@ -21,7 +21,7 @@ from app.core.db import get_session_factory from app.models import MonthlySettlement, PerformanceLedger from tests.integration.factories import business_payload, customer -pytestmark = pytest.mark.integration +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")] _SETTLEMENTS = "/api/v1/settlements" _CALC = "/api/v1/calc" diff --git a/backend/tests/integration/test_shipments.py b/backend/tests/integration/test_shipments.py index eccb790..951d0d2 100644 --- a/backend/tests/integration/test_shipments.py +++ b/backend/tests/integration/test_shipments.py @@ -97,8 +97,8 @@ class TestCreate: """创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总 是 M3 起付判定的直接消费源,错写就是算错钱。""" - def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create( client, @@ -130,53 +130,53 @@ class TestCreate: assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400") assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50") - def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: - resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"]) + def test_业务不存在_404(self, client: TestClient, writer: dict) -> None: + resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=writer["headers"]) assert resp.status_code == 404, resp.text assert "业务不存在" in resp.json()["message"], resp.text - def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终态业务禁新发货_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) _terminate(client, headers, business["id"]) resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) assert resp.status_code == 400, resp.text assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text - def test_明细为空_400(self, client: TestClient, normal: dict) -> None: - business = _create_business(client, normal["headers"]) + def test_明细为空_400(self, client: TestClient, writer: dict) -> None: + business = _create_business(client, writer["headers"]) resp = client.post( - _SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"] + _SHIPMENTS, json=_spayload(business["id"], items=[]), headers=writer["headers"] ) assert resp.status_code == 400, resp.text - def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: - business = _create_business(client, normal["headers"]) + def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None: + business = _create_business(client, writer["headers"]) resp = client.post( _SHIPMENTS, json=_spayload(business["id"], items=[_item(quantity="0")]), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "数量" in resp.json()["message"], resp.text - def test_运费负数_400(self, client: TestClient, normal: dict) -> None: + def test_运费负数_400(self, client: TestClient, writer: dict) -> None: """运费是金额源数据:负值会让业务运费合计与报表直接错乱。""" - business = _create_business(client, normal["headers"]) + business = _create_business(client, writer["headers"]) resp = client.post( _SHIPMENTS, json=_spayload(business["id"], freight_amount="-8.00"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "不能为负数" in resp.json()["message"], resp.text - def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None: - business = _create_business(client, normal["headers"]) + def test_运费三位小数_400(self, client: TestClient, writer: dict) -> None: + business = _create_business(client, writer["headers"]) resp = client.post( _SHIPMENTS, json=_spayload(business["id"], freight_amount="8.333"), - headers=normal["headers"], + headers=writer["headers"], ) assert resp.status_code == 400, resp.text assert "最多两位小数" in resp.json()["message"], resp.text @@ -185,8 +185,8 @@ class TestCreate: class TestEdit: """编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。""" - def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_编辑发货单_两处汇总同步(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create( client, @@ -223,8 +223,8 @@ class TestEdit: class TestDelete: """删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。""" - def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create( client, @@ -264,8 +264,8 @@ class TestDelete: assert Decimal(business_detail["total_ship_amount"]) == Decimal("0") assert Decimal(business_detail["total_freight_amount"]) == Decimal("0") - def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create( client, @@ -289,8 +289,8 @@ class TestTerminal: """终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算 的源数据,终态后不得变动。""" - def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终态业务发货单禁改_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _terminate(client, headers, business["id"]) @@ -306,8 +306,8 @@ class TestTerminal: assert resp.status_code == 400, resp.text assert "禁止编辑发货单" in resp.json()["message"], resp.text - def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终态业务发货单禁删_400(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) _terminate(client, headers, business["id"]) @@ -320,13 +320,13 @@ class TestTerminal: class TestAudit: """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" - def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_新增发货单成功留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) data = _create(client, headers, business["id"]) logs = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE ] assert logs, "新增发货单未留操作日志" @@ -335,15 +335,15 @@ class TestAudit: assert "新增发货单" in latest.summary assert data["shipment_no"] in latest.summary - def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None: - headers = normal["headers"] + def test_终态禁新发货失败留审计(self, client: TestClient, writer: dict) -> None: + headers = writer["headers"] business = _create_business(client, headers) _terminate(client, headers, business["id"]) resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) assert resp.status_code == 400, resp.text failed = [ log - for log in _logs_of(normal["username"]) + for log in _logs_of(writer["username"]) if log.module == MODULE_SHIPMENT and log.success == 0 ] assert failed, "失败操作未留痕" diff --git a/backend/tests/integration/test_tplus_automation.py b/backend/tests/integration/test_tplus_automation.py new file mode 100644 index 0000000..aecea39 --- /dev/null +++ b/backend/tests/integration/test_tplus_automation.py @@ -0,0 +1,158 @@ +"""真实MySQL验证周期竞争、重试、worker消费和独立长任务心跳。""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import nullcontext +from datetime import datetime, timedelta +from time import sleep +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, func, select, text + +from app.core.config import Settings +from app.core.db import get_session_factory +from app.models import ( + PerformanceLedger, + TPlusAutomationState, + TPlusSourceHead, + TPlusSyncBatch, + TPlusSyncError, + TPlusSyncRecord, +) +from app.services.tplus import automation, worker + +pytestmark = pytest.mark.integration +NOW = datetime(2026, 10, 30, 10) + + +@pytest.fixture +def auto_source(monkeypatch): + source = 'auto_test_' + uuid4().hex[:12] + settings = Settings(_env_file=None, app_secret_key='x' * 32, t_plus_enabled=True, + t_plus_database=source, t_plus_user='reader', t_plus_odbc_driver='unit', t_plus_automation_enabled=True) + monkeypatch.setattr(automation, 'get_settings', lambda: settings) + monkeypatch.setattr('app.services.tplus.task_service.get_settings', lambda: settings) + yield settings + with get_session_factory()() as s: + numbers = list(s.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.source_database == source))) + s.execute(delete(TPlusSourceHead).where(TPlusSourceHead.source_database == source)) + s.execute(delete(TPlusSyncRecord).where(TPlusSyncRecord.batch_no.in_(numbers))) + s.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers))) + s.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.source_database == source)) + s.execute(delete(TPlusAutomationState).where(TPlusAutomationState.source_database == source)) + s.commit() + + +def test_并发周期检查只排队一个完整采集(auto_source): + def call(): + with get_session_factory()() as s: + return automation.tick(s, settings=auto_source, now=NOW) + with ThreadPoolExecutor(max_workers=3) as pool: + results = list(pool.map(lambda _: call(), range(3))) + assert sum(r['queued'] for r in results) == 1 + with get_session_factory()() as s: + rows = list(s.scalars(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database))) + assert len(rows) == 1 and len(rows[0].entity_types_json) == 9 + + +def test_周期任务经原worker读取九类且正式工资不变(auto_source, monkeypatch): + def values(_reader, entity, **_args): + return iter([{'id': 123456789, 'code': 'AUTO-' + entity}]) + monkeypatch.setattr('app.services.tplus.reader.TPlusReader.iter_entity', values) + with get_session_factory()() as s: + result = automation.tick(s, settings=auto_source, now=NOW) + number = worker.run_once(s, source_factory=lambda: nullcontext(None), worker_owner='integration-owner') + assert number == result['batch_no'] + batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number)) + assert batch.status == 'SUCCEEDED' and batch.source_record_count == 9 + assert batch.trigger_type == 'SCHEDULED' + assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 + + +def test_长查询期间心跳使用独立会话(auto_source): + with get_session_factory()() as s, automation.WorkerHeartbeat(s, interval=0.1, owner='long-task'): + with get_session_factory()() as inspect_session: + first = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database)).heartbeat_at + sleep(1.3) + with get_session_factory()() as inspect_session: + state = inspect_session.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database)) + assert state.heartbeat_at > first and state.worker_phase == 'RUNNING' + + +def test_自动重试保留父批次并受两次上限约束(auto_source): + with get_session_factory()() as s: + first = automation.tick(s, settings=auto_source, now=NOW) + current = first['batch_no'] + for i in range(3): + batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == current)) + batch.status, batch.finished_at = 'FAILED', NOW + timedelta(hours=i) + s.add(TPlusSyncError(batch_no=current, entity_type='system', source_table='system', error_code='SOURCE_TIMEOUT', error_message='固定超时说明')) + s.commit() + outcome = automation.tick(s, settings=auto_source, now=NOW + timedelta(hours=i, minutes=20)) + assert outcome['queued'] == (i < 2) + if i < 2: + child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome['batch_no'])) + assert child.parent_batch_no == current + current = child.batch_no + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where(TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 3 + + +def test_人工恢复成功后不再对原批次自动重试(auto_source): + from app.schemas.tplus import TPlusRetryIn + from app.services.tplus import task_service + + with get_session_factory()() as s: + first = automation.tick(s, settings=auto_source, now=NOW) + original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no'])) + original.status, original.finished_at = 'FAILED', NOW + s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system', + error_code='SOURCE_TIMEOUT', error_message='固定说明')) + s.commit() + manual = task_service.retry_task(s, original.batch_no, TPlusRetryIn(request_key=uuid4())) + manual.status, manual.finished_at = 'SUCCEEDED', NOW + timedelta(minutes=2) + s.commit() + assert not automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10))['queued'] + state = s.scalar(select(TPlusAutomationState).where(TPlusAutomationState.source_database == auto_source.t_plus_database)) + assert state.current_batch_no == manual.batch_no and state.last_success_at == manual.finished_at + + +def test_人工与自动竞争同一失败批次只新增一个重试(auto_source): + from app.core.exceptions import BadRequestError + from app.schemas.tplus import TPlusRetryIn + from app.services.tplus import task_service + + with get_session_factory()() as s: + first = automation.tick(s, settings=auto_source, now=NOW) + original = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == first['batch_no'])) + original.status, original.finished_at = 'FAILED', NOW + s.add(TPlusSyncError(batch_no=original.batch_no, entity_type='system', source_table='system', + error_code='SOURCE_TIMEOUT', error_message='固定说明')) + s.commit() + + def manual(): + with get_session_factory()() as s: + try: + return task_service.retry_task(s, first['batch_no'], TPlusRetryIn(request_key=uuid4())).batch_no + except BadRequestError: + return '已有自动重试' + + def scheduled(): + with get_session_factory()() as s: + return automation.tick(s, settings=auto_source, now=NOW + timedelta(minutes=10)) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(manual), pool.submit(scheduled)] + for future in futures: + future.result(timeout=20) + with get_session_factory()() as s: + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch).where( + TPlusSyncBatch.source_database == auto_source.t_plus_database)) == 2 + + +def test_数据库状态默认值没有多余引号(auto_source): + key, database = automation.source_identity(auto_source) + with get_session_factory()() as s: + s.execute(text("INSERT INTO tplus_automation_states (source_key,source_database) VALUES (:key,:database)"), + {"key": key, "database": database}) + s.commit() + assert s.scalar(select(TPlusAutomationState.worker_phase).where(TPlusAutomationState.source_key == key)) == 'STOPPED' diff --git a/backend/tests/integration/test_tplus_workbench.py b/backend/tests/integration/test_tplus_workbench.py new file mode 100644 index 0000000..1e85782 --- /dev/null +++ b/backend/tests/integration/test_tplus_workbench.py @@ -0,0 +1,334 @@ +"""MySQL 上验证新工作区权限、队列、乐观版本和正式表隔离。""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import delete, func, select + +from app.core.db import get_session_factory +from app.models import ( + MonthlySettlement, + PerformanceLedger, + Receipt, + TPlusFact, + TPlusSourceHead, + TPlusSyncBatch, + TPlusSyncRecord, + TPlusWorkLine, + TPlusWorkRun, +) +from app.services.tplus import workbench, worker +from app.services.tplus.mapping import get_mapping +from app.services.tplus.source_heads import observe_rows +from app.services.tplus.staging import stage_row + +BASE = "/api/v1/system/tplus" +pytestmark = pytest.mark.integration + + +@pytest.fixture +def workspace(monkeypatch): + from app.core.config import Settings + + source = "wb_test_" + uuid4().hex[:16] + settings = Settings( + _env_file=None, + app_secret_key="x" * 32, + t_plus_enabled=True, + t_plus_database=source, + t_plus_user="reader", + t_plus_odbc_driver="unit", + ) + monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings) + monkeypatch.setattr(workbench, "_source_database", lambda: source) + monkeypatch.setattr("app.api.v1.system.tplus_workbench._source_database", lambda: source) + with get_session_factory()() as db: + yield db, source + db.rollback() + numbers = list( + db.scalars(select(TPlusWorkRun.run_no).where(TPlusWorkRun.source_database == source)) + ) + batches = list( + db.scalars( + select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.source_database == source) + ) + ) + db.execute(delete(TPlusWorkLine).where(TPlusWorkLine.run_no.in_(numbers))) + db.execute(delete(TPlusWorkRun).where(TPlusWorkRun.source_database == source)) + db.execute(delete(TPlusFact).where(TPlusFact.source_database == source)) + db.execute(delete(TPlusSourceHead).where(TPlusSourceHead.source_database == source)) + db.execute(delete(TPlusSyncRecord).where(TPlusSyncRecord.batch_no.in_(batches))) + db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(batches))) + db.commit() + + +def test_工作区全部入口沿用管理员权限(client, normal): + for path in ( + "/facts", + "/work-runs", + "/source-records?entity=personnel", + "/work-runs/example/report", + ): + assert client.get(BASE + path, headers=normal["headers"]).status_code == 403 + assert ( + client.post( + BASE + "/work-runs", + headers=normal["headers"], + json={ + "kind": "MASTER_PREVIEW", + "entity_types": ["personnel"], + "request_key": str(uuid4()), + }, + ).status_code + == 403 + ) + + +def test_资料乐观锁和历史在MySQL生效(client, admin, workspace): + db, _ = workspace + payload = { + "kind": "PRODUCT", + "fact_key": "9", + "expected_version": 0, + "evidence": "隔离测试:资料版本", + "data": {"category": "municipal_water", "unit": "件"}, + } + first = client.post(BASE + "/facts", headers=admin["headers"], json=payload) + assert first.status_code == 200 + assert client.post(BASE + "/facts", headers=admin["headers"], json=payload).status_code == 400 + payload.update(expected_version=1, evidence="隔离测试:修改分类") + payload["data"]["category"] = "rail_transit" + second = client.post(BASE + "/facts", headers=admin["headers"], json=payload) + assert second.json()["data"]["version"] == 2 + result = client.get( + BASE + f"/facts/{first.json()['data']['id']}/history", headers=admin["headers"] + ) + assert result.json()["data"]["total"] == 2 + assert db.scalar(select(func.count()).select_from(TPlusFact)) >= 2 + + +def test_MySQL队列预览和CSV不改正式工资(client, admin, workspace): + db, source = workspace + batch_no = "WB" + uuid4().hex[:20] + source_id = str(100000000 + int(uuid4().hex[:8], 16)) + batch = TPlusSyncBatch( + batch_no=batch_no, + source_database=source, + sync_mode="FULL", + status="SUCCEEDED", + entity_types_json=["products"], + audit_result_json={"observed_entities": ["products"], "source_head_version": 1}, + ) + db.add(batch) + db.flush() + row = { + "id": int(source_id), + "code": "=SOURCE", + "name": "t_workbench_product", + "unit_name": "件", + } + stage_row(db, batch_no=batch_no, entity_type="products", source_table="AA_Inventory", row=row) + observe_rows(db, batch, get_mapping("products"), [row]) + db.commit() + before = { + m.__tablename__: db.scalar(select(func.count()).select_from(m)) + for m in (Receipt, PerformanceLedger, MonthlySettlement) + } + payload = {"kind": "MASTER_PREVIEW", "request_key": str(uuid4()), "entity_types": ["products"]} + first = client.post(BASE + "/work-runs", headers=admin["headers"], json=payload).json()["data"] + assert ( + client.post(BASE + "/work-runs", headers=admin["headers"], json=payload).json()["data"][ + "run_no" + ] + == first["run_no"] + ) + assert worker.run_once(db) == first["run_no"] + db.expire_all() + detail = client.get(BASE + f"/work-runs/{first['run_no']}", headers=admin["headers"]).json()[ + "data" + ] + assert detail["summary"]["counts"] == {"PENDING": 1} + assert detail["formal_payroll_written"] is False + report = client.get(BASE + f"/work-runs/{first['run_no']}/report", headers=admin["headers"]) + assert report.status_code == 200 and "未写入正式工资" in report.text + response = client.get(BASE + "/source-records?entity=products", headers=admin["headers"]) + assert response.status_code == 200, response.json() + choices = response.json()["data"] + assert choices["total"] == 1 and choices["list"][0]["source_id"] == source_id + assert before == { + m.__tablename__: db.scalar(select(func.count()).select_from(m)) + for m in (Receipt, PerformanceLedger, MonthlySettlement) + } + + +def test_MySQL完整预演冻结重跑且正式表不变(client, admin, workspace): + db, source = workspace + n = 100000000 + int(uuid4().hex[:8], 16) + ship_id, line_id, cash_id = map(str, (n, n + 1, n + 2)) + entities = { + "shipments": [ + { + "id": n, + "code": "SH-TEST", + "idcustomer": 8, + "voucherdate": "2026-03-10", + "voucherState": 189, + "isCancel": 428, + } + ], + "shipment_items": [ + { + "id": n + 1, + "source_parent_id": n, + "idinventory": 9, + "taxAmount": "100", + "quantity": "2", + } + ], + "receipts": [ + { + "id": n + 2, + "code": "RC-TEST", + "idpartner": 8, + "voucherDate": "2026-03-10", + "isReceiveFlag": 1, + "voucherstate": 189, + "amount": "100", + "accounting_voucher_count": 1, + "valid_accounting_voucher_count": 1, + "accounting_voucher_id": 123, + "accounting_voucher_code": "GL-TEST-UNREVIEWED", + }, + { + "id": n + 3, + "code": "RC-NOT-GENERATED", + "idpartner": 8, + "voucherDate": "2026-03-10", + "isReceiveFlag": 1, + "voucherstate": 189, + "amount": "45", + "accounting_voucher_count": 0, + "valid_accounting_voucher_count": 0, + }, + ], + } + for entity, rows in entities.items(): + batch_no = "WB" + uuid4().hex[:20] + batch = TPlusSyncBatch( + batch_no=batch_no, + source_database=source, + sync_mode="FULL", + status="SUCCEEDED", + entity_types_json=[entity], + audit_result_json={"observed_entities": [entity]}, + ) + db.add(batch) + db.flush() + for row in rows: + stage_row( + db, + batch_no=batch_no, + entity_type=entity, + source_table=get_mapping(entity).source_table, + row=row, + ) + observe_rows(db, batch, get_mapping(entity), rows) + db.commit() + facts = [ + ( + "PERSONNEL", + "001", + {"code": "001", "name": "测试人员", "role_code": "salesperson", "status": 1}, + ), + ("PRODUCT", "9", {"category": "municipal_water", "unit": "件"}), + ("CUSTOMER", "8", {"customer_type": "personal", "owner_name": "测试负责人"}), + ( + "GUIDANCE", + "price9", + {"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"}, + ), + ( + "SHIPMENT", + line_id, + { + "sales_amount_excludes_freight": False, + "freight_amount": "10", + "business_key": "test", + "personnel_id": "001", + }, + ), + ( + "GROUP", + "g", + { + "customer_id": "8", + "shipment_ids": [ship_id], + "receipt_ids": [cash_id, str(n + 3)], + "scope_confirmed": True, + "opening_confirmed": True, + "opening_balance": "0", + "historical_settlement_confirmed": True, + "guidance_month_basis": "SHIPMENT", + }, + ), + ] + for kind, key, data in facts: + r = client.post( + BASE + "/facts", + headers=admin["headers"], + json={ + "kind": kind, + "fact_key": key, + "expected_version": 0, + "evidence": "隔离测试资料,不是真实客户规则", + "data": data, + }, + ) + assert r.status_code == 200, r.json() + before = { + m.__tablename__: db.scalar(select(func.count()).select_from(m)) + for m in (Receipt, PerformanceLedger, MonthlySettlement) + } + + def execute(**values): + r = client.post( + BASE + "/work-runs", + headers=admin["headers"], + json={"kind": "CALC_PREVIEW", "request_key": str(uuid4()), **values}, + ) + assert r.status_code == 200, r.json() + number = r.json()["data"]["run_no"] + assert worker.run_once(db) == number + return client.get(BASE + f"/work-runs/{number}", headers=admin["headers"]).json()["data"] + + first = execute(month="2026-03", group_keys=["g"]) + assert first["status"] == "SUCCEEDED", first + assert first["summary"]["groups"]["g"]["prepayment_remaining"] == "10.00" + assert first["summary"]["groups"]["g"]["receipt_total"] == "100.00" + assert first["summary"]["groups"]["g"]["allocated_total"] == "100.00" + assert first["summary"]["groups"]["g"]["excluded_receipt_total"] == "45.00" + lines = client.get( + BASE + f"/work-runs/{first['run_no']}/lines", headers=admin["headers"] + ).json()["data"]["list"] + shipment_line = next(row for row in lines if row["data"].get("kind") == "SHIPMENT") + assert shipment_line["data"]["accounting_voucher_code"] == "GL-TEST-UNREVIEWED" + assert any(row["action"] == "EXCLUDED" for row in lines) + r = client.post( + BASE + "/facts", + headers=admin["headers"], + json={ + "kind": "GUIDANCE", + "fact_key": "price9", + "expected_version": 1, + "evidence": "后续价格变化不应修改原运行", + "data": {**facts[3][2], "amount": "40"}, + }, + ) + assert r.status_code == 200 + repeated = execute(replay_no=first["run_no"]) + assert repeated["snapshot_hash"] == first["snapshot_hash"] + assert repeated["summary"] == first["summary"] + assert before == { + m.__tablename__: db.scalar(select(func.count()).select_from(m)) + for m in (Receipt, PerformanceLedger, MonthlySettlement) + } diff --git a/backend/tests/isolation.py b/backend/tests/isolation.py new file mode 100644 index 0000000..1188522 --- /dev/null +++ b/backend/tests/isolation.py @@ -0,0 +1,12 @@ +"""清理夹具只能运行在显式指定的独立测试库中。""" + +import re + + +def ensure_test_database(database: str, expected: str | None) -> None: + if ( + not expected + or database != expected + or not re.fullmatch(r"payroll_(?:wbtest|test)_[0-9a-f]{8,32}", database) + ): + raise RuntimeError("拒绝清理非隔离测试库:请通过隔离测试运行器设置 PAYROLL_TEST_DATABASE") diff --git a/backend/tests/unit/test_formal_payroll.py b/backend/tests/unit/test_formal_payroll.py new file mode 100644 index 0000000..0531c1f --- /dev/null +++ b/backend/tests/unit/test_formal_payroll.py @@ -0,0 +1,465 @@ +"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。""" + +import copy +from datetime import date +from decimal import Decimal +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.core.exceptions import BadRequestError, ForbiddenError +from app.models import ( + Business, + BusinessRole, + Customer, + PayrollAllocation, + PayrollInput, + PayrollMonth, + PerformanceLedger, + Personnel, + Product, + Receipt, + ShipmentItem, + TPlusSourceLink, + TPlusWorkRun, + User, +) +from app.services import manager_share, payroll_calculation, payroll_months +from app.services.payroll_access import can_write_payroll +from app.services.tplus.business_import import execute_business +from app.services.tplus.workbench import digest +from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview +from tests.unit.test_tplus_workbench import work_session as work_session + + +@pytest.fixture +def payroll_case(work_session, monkeypatch): + s = work_session + user = User(username="test_maker", password="test_hash_only", status=1) + s.add(user) + role = BusinessRole( + role_name="经理", + role_code="sales_manager", + calc_template="SALES_MANAGER", + status=1, + sort_order=2, + ) + s.add(role) + s.flush() + manager = Personnel( + personnel_name="测试经理", business_role_id=role.id, status=1, source_no="90001" + ) + s.add(manager) + s.commit() + monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id) + prepare_calc(s) + apply(s, preview(s, ["personnel"])) + product = Product( + product_name="测试产品", + spec_model="测试规格", + unit="件", + product_category="municipal_water", + status=1, + ) + customer = Customer(company_name="测试客户", owner_name="负责人", customer_type="personal") + s.add_all([product, customer]) + s.flush() + s.add_all( + [ + TPlusSourceLink( + source_database="test_source", + entity_type="products", + source_id="9", + target_id=product.id, + ), + TPlusSourceLink( + source_database="test_source", + entity_type="customers", + source_id="8", + target_id=customer.id, + ), + ] + ) + s.commit() + s.add( + TPlusSourceLink( + source_database="test_source", + entity_type="personnel", + source_id="90001", + target_id=manager.id, + ) + ) + s.commit() + fact( + s, + "PERSONNEL", + "90001", + { + "code": "90001", + "name": manager.personnel_name, + "role_code": "sales_manager", + "status": 1, + }, + ) + fact( + s, + "MANAGER", + "manager-biz", + {"business_key": "biz", "manager_id": "90001", "effective_date": "2026-03-01"}, + ) + from app.models import TPlusFact + + guidance = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE")) + guidance.data_json = {**guidance.data_json, "is_finalized": True} + s.commit() + return s, user, manager + + +def business_work(s, user, kind, previous=None): + run = TPlusWorkRun( + run_no=uuid4().hex, + request_key=str(uuid4()), + source_database="test_source", + requested_by=user.id, + kind=kind, + status="RUNNING", + input_json={"preview_no": previous.run_no} + if previous + else {"month": "2026-03", "group_keys": ["g"]}, + ) + s.add(run) + s.flush() + execute_business(s, run) + s.commit() + return run + + +def imported(case): + s, user, _manager = case + pre = business_work(s, user, "BUSINESS_PREVIEW") + assert pre.status == "SUCCEEDED", pre.summary_json + assert pre.summary_json["counts"] == {"CREATE": 1} + run = business_work(s, user, "BUSINESS_APPLY", pre) + assert run.status == "SUCCEEDED", run.summary_json + return s.scalar(select(Business)) + + +def test_指定账号而非管理员或岗位放行(payroll_case): + _, user, _ = payroll_case + assert can_write_payroll(user) + assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1)) + assert not can_write_payroll(None) + + +def test_正式导入防重与来源可追溯(payroll_case): + s, user, _ = payroll_case + business = imported(payroll_case) + assert s.scalar(select(func.count()).select_from(Receipt)) == 1 + assert s.scalar(select(func.count()).select_from(ShipmentItem)) == 1 + again = business_work(s, user, "BUSINESS_PREVIEW") + assert again.summary_json["counts"] == {"SKIP": 1} + business_work(s, user, "BUSINESS_APPLY", again) + assert s.scalar(select(func.count()).select_from(Business)) == 1 + record = payroll_calculation.latest_input(s, business.id) + assert record.data_json["source_facts"] + + +def test_正式FIFO台账金额与逐段过程守恒(payroll_case): + s, user, manager = payroll_case + business = imported(payroll_case) + result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert result["performance"] == "0.20" + assert result["payable"] == "0.20" + ledger = s.get(PerformanceLedger, result["ledger_ids"][0]) + assert ledger.snapshot_json["manager_id"] == manager.id + assert s.scalar(select(PayrollAllocation)).amount == Decimal("100.00") + with pytest.raises(BadRequestError, match="已有核算"): + payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + + +def test_输入变化使旧预览失效(payroll_case): + s, user, _ = payroll_case + pre = business_work(s, user, "BUSINESS_PREVIEW") + product = s.scalar(select(Product)) + product.product_category = "rail_transit" + s.commit() + with pytest.raises(ValueError, match="PREVIEW_STALE"): + business_work(s, user, "BUSINESS_APPLY", pre) + s.rollback() + assert s.scalar(select(func.count()).select_from(Business)) == 0 + + +def test_经理汇总读取固化业务分管(payroll_case): + s, user, manager = payroll_case + business = imported(payroll_case) + payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + rows = list(s.scalars(select(PerformanceLedger))) + owner = s.get(Personnel, rows[0].personnel_id) + owner.manager_id = None + s.commit() + buckets = manager_share.collect_team_buckets(s, rows, False) + assert buckets[manager.id]["performance"] == Decimal("0.20") + + +def test_生成标记独立于审批状态(payroll_case): + s, user, _ = payroll_case + control = payroll_months.lock_month(s, "2026-03", user) + from datetime import datetime + + control.generated_at = datetime(2026, 3, 31) + s.commit() + with pytest.raises(BadRequestError, match="已锁定"): + payroll_months.ensure_allocation_editable(s, "2026-03", user) + s.rollback() + assert payroll_months.ensure_allocation_editable(s, "2026-04", user).generated_at is None + + +def test_生成失败不留下月份锁(payroll_case): + s, user, _ = payroll_case + from app.services.settlement_service import generate + + with pytest.raises(BadRequestError, match="无可结算"): + generate(s, "2026-03", operator=user) + assert s.scalar(select(PayrollMonth)) is None + + +def test_非制作人无法生成工资(payroll_case): + s, user, _ = payroll_case + with pytest.raises(ForbiddenError): + payroll_months.lock_month(s, "2026-03", SimpleNamespace(id=user.id + 1, status=1)) + + +def replace_input(s, record, values): + newer = PayrollInput( + business_id=record.business_id, + version=record.version + 1, + source_database=record.source_database, + group_key=record.group_key, + fingerprint=digest(values), + data_json=values, + requested_by=record.requested_by, + ) + s.add(newer) + s.commit() + return newer + + +@pytest.mark.parametrize( + "amount,performance,payable,start", + [("50", "0.10", "0.00", False), ("60", "0.12", "0.04", True), ("100", "0.20", "0.20", True)], +) +def test_起付百分之六十与平方权重(payroll_case, amount, performance, payable, start): + s, user, _ = payroll_case + business = imported(payroll_case) + record = payroll_calculation.latest_input(s, business.id) + data = copy.deepcopy(record.data_json) + data["receipts"][0]["amount"] = amount + receipt = s.scalar(select(Receipt)) + receipt.amount = Decimal(amount) + replace_input(s, record, data) + result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert (result["performance"], result["payable"], result["start_payment_ok"]) == ( + performance, + payable, + start, + ) + + +def test_跨月续算仅计应付差额且离职未发给接手人(payroll_case): + s, user, _manager = payroll_case + business = imported(payroll_case) + record = payroll_calculation.latest_input(s, business.id) + data = copy.deepcopy(record.data_json) + receipt = s.scalar(select(Receipt)) + receipt.amount = Decimal("80") + data["receipts"][0]["amount"] = "80" + record = replace_input(s, record, data) + march = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert march["payable"] == "0.10" + old = s.get(PerformanceLedger, march["ledger_ids"][0]) + new_person = Personnel( + personnel_name="接手业务员", + business_role_id=s.get(Personnel, old.personnel_id).business_role_id, + status=1, + ) + s.add(new_person) + s.flush() + second = Receipt( + business_id=business.id, + receipt_batch_no="HK-NEXT", + receipt_date=date(2026, 4, 10), + amount=Decimal("20"), + is_barter=0, + settlement_status=0, + ) + s.add(second) + s.flush() + data = copy.deepcopy(record.data_json) + data["receipts"].append( + { + "source_id": "11", + "source_no": "HK-NEXT", + "date": "2026-04-10", + "group_key": "g", + "amount": "20", + "voucher_confirmed": True, + } + ) + data["maps"]["receipts"]["11"] = second.id + data["maps"]["personnel"]["next-person"] = new_person.id + data["facts"]["personnel"]["next-person"] = {"role_code": "salesperson", "status": 1} + data["facts"]["handovers"] = [ + { + "mode": "RESIGNATION", + "business_key": "biz", + "from_personnel_id": "00043", + "to_personnel_id": "next-person", + "effective_date": "2026-04-01", + } + ] + replace_input(s, record, data) + april = payroll_calculation.calculate(s, business.id, "2026-04", operator=user) + assert april["payable"] == "0.10" + assert old.payable_amount == Decimal("0.10") and old.personnel_id != new_person.id + assert all( + s.get(PerformanceLedger, lid).personnel_id == new_person.id for lid in april["ledger_ids"] + ) + assert sum(s.scalars(select(PerformanceLedger.payable_amount))) == Decimal("0.20") + rebuilt = payroll_calculation.calculate( + s, business.id, "2026-03", operator=user, reason="复核分次回款" + ) + assert rebuilt["recalculated_months"] == ["2026-03", "2026-04"] + active = list(s.scalars(select(PerformanceLedger).where(PerformanceLedger.is_active == 1))) + assert sum(r.payable_amount for r in active) == Decimal("0.20") + assert all(r.calc_reason == "复核分次回款" for r in active) + + +def test_已核算源金额变化不静默改工资(payroll_case): + s, user, _ = payroll_case + business = imported(payroll_case) + receipt = s.scalar(select(Receipt)) + receipt.amount = Decimal("99") + s.commit() + with pytest.raises(BadRequestError, match="不一致"): + payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 + + +def test_同客户不同合同合并平账_一款对应多发货(payroll_case): + from tests.unit.test_tplus_workbench import source + + s, user, _ = payroll_case + source( + s, + "shipments", + [ + { + "id": 20, + "code": "SH-ONE", + "idcustomer": 8, + "voucherdate": "2026-03-10", + "voucherState": 189, + }, + { + "id": 22, + "code": "SH-TWO", + "idcustomer": 8, + "voucherdate": "2026-03-11", + "voucherState": 189, + }, + ], + ) + source( + s, + "shipment_items", + [ + { + "id": 21, + "source_parent_id": 20, + "idinventory": 9, + "taxAmount": "100", + "quantity": "2", + }, + { + "id": 23, + "source_parent_id": 22, + "idinventory": 9, + "taxAmount": "100", + "quantity": "2", + }, + ], + ) + source( + s, + "receipts", + [ + { + "id": 10, + "code": "RC-ONE", + "idpartner": 8, + "voucherDate": "2026-03-12", + "isReceiveFlag": 1, + "voucherstate": 189, + "amount": "200", + } + ], + ) + fact( + s, + "SHIPMENT", + "23", + { + "sales_amount_excludes_freight": True, + "business_key": "second-contract", + "personnel_id": "00043", + }, + ) + fact( + s, + "MANAGER", + "second-manager", + {"business_key": "second-contract", "manager_id": "90001", "effective_date": "2026-03-01"}, + ) + business = imported(payroll_case) + assert s.scalar(select(func.count()).select_from(Business)) == 1 + result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + assert len(result["ledger_ids"]) == 2 + assert sum(s.scalars(select(PayrollAllocation.amount))) == Decimal("200.00") + record = payroll_calculation.latest_input(s, business.id) + assert record.data_json["group"]["shipment_ids"] == ["20", "22"] + + +def test_封存发货月指导成本不随后续回款改变(payroll_case): + from app.models import TPlusFact + from app.schemas.tplus_workbench import FactIn + from app.services.tplus import workbench + + s, user, _ = payroll_case + cost = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE")) + with pytest.raises(BadRequestError, match="已封存"): + workbench.save_fact( + s, + FactIn( + kind="GUIDANCE", + fact_key=cost.fact_key, + expected_version=cost.version, + evidence="试图修改已封存月份", + data={**cost.data_json, "amount": "30"}, + ), + user.id, + ) + + +def test_同客户旧项目期初不一致必须先合并事实(payroll_case): + from app.models import TPlusFact + + s, user, _ = payroll_case + group = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GROUP")).data_json + fact(s, "GROUP", "old-contract-group", {**group, "opening_balance": "10"}) + run = business_work(s, user, "BUSINESS_PREVIEW") + assert run.status == "PARTIAL" + assert run.summary_json["counts"] == {"PENDING": 1} + assert s.scalar(select(func.count()).select_from(Business)) == 0 diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 204dfb3..e396439 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -12,6 +12,9 @@ from sqlalchemy.dialects import mysql from app.models import Base EXPECTED_TABLES = { + "tplus_automation_states", + "payroll_months", "payroll_inputs", "payroll_allocations", + "tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines", "users", "roles", "user_roles", diff --git a/backend/tests/unit/test_payroll_isolation.py b/backend/tests/unit/test_payroll_isolation.py new file mode 100644 index 0000000..0d3648f --- /dev/null +++ b/backend/tests/unit/test_payroll_isolation.py @@ -0,0 +1,23 @@ +"""隔离库校验拒绝开发库、相似前缀及遗漏授权标记。""" + +import pytest + +from tests.isolation import ensure_test_database + + +@pytest.mark.parametrize( + "database,expected", + [ + ("payroll", "payroll"), + ("payroll_test_prod", "payroll_test_prod"), + ("payroll_wbtest_1234abcd", None), + ("payroll_wbtest_1234abcd", "payroll_wbtest_5678abcd"), + ], +) +def test_拒绝非隔离目标(database, expected): + with pytest.raises(RuntimeError, match="拒绝清理"): + ensure_test_database(database, expected) + + +def test_接受显式隔离库(): + ensure_test_database("payroll_wbtest_1234abcd", "payroll_wbtest_1234abcd") diff --git a/backend/tests/unit/test_tplus_automation.py b/backend/tests/unit/test_tplus_automation.py new file mode 100644 index 0000000..8c27ac8 --- /dev/null +++ b/backend/tests/unit/test_tplus_automation.py @@ -0,0 +1,160 @@ +"""周期和自动重试只能生成原始采集,验证重启防重与人工介入边界。""" + +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import func, select + +from app.core.config import Settings +from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError +from app.services.tplus import automation +from tests.unit.test_tplus_workbench import work_session as work_session + +NOW = datetime(2026, 10, 30, 10) + + +@pytest.fixture +def settings(): + return Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True, + t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit", + t_plus_automation_enabled=True) + + +def run(s, settings, now=NOW): + return automation.tick(s, settings=settings, now=now) + + +def fail(s, number, *, status="FAILED", code="SOURCE_TIMEOUT", at=NOW): + row = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number)) + row.status, row.finished_at = status, at + s.add(TPlusSyncError(batch_no=number, entity_type="system", source_table="system", error_code=code, error_message="固定错误说明")) + s.commit() + + +def test_默认关闭不会排队(work_session, settings): + result = run(work_session, settings.model_copy(update={"t_plus_automation_enabled": False})) + assert result == {"status": "DISABLED", "queued": False} + assert work_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 + + +def test_同周期重复检查和重启只有一次九类采集(work_session, settings): + s = work_session + first = run(s, settings) + for _ in range(3): + assert not run(s, settings)["queued"] + batch = s.scalar(select(TPlusSyncBatch)) + assert batch.batch_no == first["batch_no"] and batch.sync_mode == "FULL" + assert len(batch.entity_types_json) == 9 and batch.trigger_type == "SCHEDULED" + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 + + +def test_停机多周只补最近一次(work_session, settings): + s = work_session + first = run(s, settings) + batch = s.scalar(select(TPlusSyncBatch)) + batch.status, batch.finished_at = "SUCCEEDED", NOW + s.commit() + later = run(s, settings, NOW + timedelta(weeks=4)) + assert later["queued"] and later["period"] != first["period"] + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 2 + + +def test_人工任务占用时不堆积周期任务(work_session, settings): + s = work_session + s.add(TPlusSyncBatch(batch_no="manual", sync_mode="FULL", status="RUNNING", source_database="test_source", entity_types_json=["personnel"])) + s.commit() + assert run(s, settings)["status"] == "DEFERRED" + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 + + +def test_明确超时仅按五十五分钟最多重试两次(work_session, settings): + s = work_session + first = run(s, settings) + fail(s, first["batch_no"]) + assert not run(s, settings, NOW + timedelta(minutes=4))["queued"] + second = run(s, settings, NOW + timedelta(minutes=5)) + assert second["queued"] + fail(s, second["batch_no"], at=NOW + timedelta(minutes=5)) + assert not run(s, settings, NOW + timedelta(minutes=19))["queued"] + third = run(s, settings, NOW + timedelta(minutes=20)) + assert third["queued"] + fail(s, third["batch_no"], at=NOW + timedelta(minutes=21)) + assert not run(s, settings, NOW + timedelta(hours=2))["queued"] + rows = list(s.scalars(select(TPlusSyncBatch).order_by(TPlusSyncBatch.id))) + assert [(r.trigger_type, r.automatic_attempt) for r in rows] == [("SCHEDULED", 0), ("AUTO_RETRY", 1), ("AUTO_RETRY", 2)] + assert rows[1].parent_batch_no == rows[0].batch_no and rows[2].parent_batch_no == rows[1].batch_no + + +@pytest.mark.parametrize("status,code", [("PARTIAL", "SOURCE_TIMEOUT"), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "STAGE_ERROR"), ("FAILED", "EXECUTION_ERROR")]) +def test_部分失败或不明确错误不自动重试(work_session, settings, status, code): + s = work_session + first = run(s, settings) + fail(s, first["batch_no"], status=status, code=code) + assert not run(s, settings, NOW + timedelta(hours=2))["queued"] + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1 + + +def test_ISO年周和到期时间边界稳定(): + assert automation.due_period(datetime(2027, 1, 1, 10))[0] == "2026-W53" + assert automation.due_period(datetime(2026, 11, 2, 1, 59))[0] == "2026-W44" + assert automation.due_period(datetime(2026, 11, 2, 2))[0] == "2026-W45" + + +def test_站内提示覆盖排队超时和无成功同步(work_session, settings): + s = work_session + run(s, settings) + batch = s.scalar(select(TPlusSyncBatch)) + batch.create_time = NOW - timedelta(minutes=16) + s.commit() + result = automation.overview(s, settings=settings, now=NOW) + assert {r["code"] for r in result["alerts"]} >= {"QUEUE_OVERDUE", "SYNC_STALE", "WORKER_OFFLINE"} + + +def test_读取心跳不把当前采集写成工资(work_session, settings): + from sqlalchemy.orm import sessionmaker + + s = work_session + automation.heartbeat(sessionmaker(s.get_bind()), settings, "owner", "RUNNING", now=NOW) + s.expire_all() + state = s.scalar(select(TPlusAutomationState)) + assert state.heartbeat_at == NOW and state.worker_phase == "RUNNING" + assert s.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 + + +@pytest.mark.parametrize("status,code", [("SUCCEEDED", None), ("FAILED", "SOURCE_CONFIG_ERROR"), ("FAILED", "SOURCE_TIMEOUT")]) +def test_人工重试结果接入周期且不重置自动次数(work_session, settings, monkeypatch, status, code): + from uuid import uuid4 + + from app.schemas.tplus import TPlusRetryIn + from app.services.tplus import task_service + + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + s = work_session + first = run(s, settings) + fail(s, first["batch_no"]) + auto = run(s, settings, NOW + timedelta(minutes=5)) + fail(s, auto["batch_no"], at=NOW + timedelta(minutes=5)) + manual = task_service.retry_task(s, auto["batch_no"], TPlusRetryIn(request_key=uuid4())) + assert run(s, settings, NOW + timedelta(minutes=6))["status"] == "DEFERRED" + if code: + fail(s, manual.batch_no, status=status, code=code, at=NOW + timedelta(minutes=6)) + else: + manual.status, manual.finished_at = status, NOW + timedelta(minutes=6) + s.commit() + outcome = run(s, settings, NOW + timedelta(minutes=30)) + assert outcome["queued"] == (code == "SOURCE_TIMEOUT") + state = s.scalar(select(TPlusAutomationState)) + assert state.retry_attempts == (2 if outcome["queued"] else 1) + if status == "SUCCEEDED": + assert state.last_success_at == manual.finished_at + if outcome["queued"]: + child = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == outcome["batch_no"])) + assert child.parent_batch_no == manual.batch_no + + +def test_停止标记阻止周期排队且不报告进程离线(work_session, settings, tmp_path): + marker = tmp_path / "stop.requested" + marker.touch() + stopped = settings.model_copy(update={"t_plus_stop_file": str(marker)}) + assert run(work_session, stopped)["status"] == "STOP_REQUESTED" + assert not automation.overview(work_session, settings=stopped, now=NOW)["alerts"] diff --git a/backend/tests/unit/test_tplus_config.py b/backend/tests/unit/test_tplus_config.py index 016690a..1a565c3 100644 --- a/backend/tests/unit/test_tplus_config.py +++ b/backend/tests/unit/test_tplus_config.py @@ -79,7 +79,7 @@ def test_T加查询文件覆盖六类实体() -> None: def test_T加高风险映射保持待确认() -> None: assert get_mapping("businesses").status is MappingStatus.PENDING - assert "成本口径" in get_mapping("businesses").unresolved + assert "指导价取值时点" in get_mapping("businesses").unresolved def test_未知T加实体被拒绝() -> None: diff --git a/backend/tests/unit/test_tplus_error_classes.py b/backend/tests/unit/test_tplus_error_classes.py new file mode 100644 index 0000000..659ebd2 --- /dev/null +++ b/backend/tests/unit/test_tplus_error_classes.py @@ -0,0 +1,15 @@ +"""只接受明确驱动状态,不把含密码的未知异常当作可重试错误。""" + +import pytest + +from app.services.tplus.errors import classify_source_error + + +@pytest.mark.parametrize("state,expected", [("08S01", "SOURCE_CONNECTION_LOST"), ("HYT00", "SOURCE_TIMEOUT"), ("28000", "SOURCE_AUTH_ERROR"), ("42S22", "SOURCE_SCHEMA_ERROR"), ("42000", "READ_ERROR")]) +def test_源错误分类只依赖固定状态(state, expected): + assert classify_source_error(Exception(state, "password=不可回显")) == expected + + +def test_未知异常不宽泛自动重试(): + assert classify_source_error(RuntimeError("secret=不可回显")) == "READ_ERROR" + assert classify_source_error(TimeoutError()) == "SOURCE_TIMEOUT" diff --git a/backend/tests/unit/test_tplus_fifo.py b/backend/tests/unit/test_tplus_fifo.py new file mode 100644 index 0000000..2e9b6a8 --- /dev/null +++ b/backend/tests/unit/test_tplus_fifo.py @@ -0,0 +1,252 @@ +"""预演只分配资金,覆盖先旧后新、预收与缺条件的核算边界。""" + +from datetime import date +from decimal import Decimal + +import pytest + +from app.services.tplus.fifo import money, preview_group + + +def inputs(): + """明确口径的简单业务,测试不依赖真实客户数据。""" + group = { + "key": "g", + "scope_confirmed": True, + "opening_confirmed": True, + "opening_balance": "0", + "historical_settlement_confirmed": True, + } + ship = { + "source_id": "1", + "line_id": "1", + "date": "2026-03-10", + "group_key": "g", + "amount": "100", + "cost_total": "50", + "category": "municipal_water", + "personnel_id": "001", + "customer_type": "personal", + "business_key": "business1", + } + cash = { + "source_id": "2", + "date": "2026-03-10", + "group_key": "g", + "amount": "100", + "voucher_confirmed": True, + } + facts = {"personnel": {"001": {"role_code": "salesperson"}}} + params = { + "2026-03-10": { + "thresholds": {"municipal_water": "30"}, + "start": "10", + "slope": "4/360", + "floor": "4", + "boundary": 360, + "enterprise": "3", + } + } + return group, ship, cash, facts, params + + +def run(group, ships, cash, facts, params, until=date(2026, 3, 31)): + return preview_group(group, ships, cash, facts, params, until) + + +def test_跨月预演区分累计与当月计提(): + g, s, c, f, p = inputs() + result = run(g, [s], [c], f, p, date(2026, 4, 30)) + assert Decimal(result["performance_total"]) > 0 + assert result["month_performance_total"] == "0.00" + assert result["lines"][0]["shipment_remaining_after"] == "0.00" + + +def test_经理本人业务交接后仍排除(): + g, s, c, f, p = inputs() + f["personnel"]["001"]["role_code"] = "sales_manager" + f["personnel"]["002"] = {"role_code": "salesperson"} + f["handovers"] = [ + { + "business_key": "business1", + "mode": "RESIGNATION", + "effective_date": "2026-03-15", + "from_personnel_id": "001", + "to_personnel_id": "002", + } + ] + line = run(g, [s], [c], f, p)["lines"][0] + assert line["excluded_manager_own"] and line["performance"] == "0.00" + + +def test_未知运费影响同组顺序时不猜零费用(): + g, s, c, f, p = inputs() + unknown = {**s, "pending": ["FREIGHT_PENDING"]} + result = run(g, [unknown, {**s, "source_id": "2"}], [c], f, p) + assert result["status"] == "PENDING" + assert result["lines"] == [] and result["reasons"] == ["FREIGHT_PENDING"] + + +def test_范围外的未来负向单据不阻塞本月(): + g, s, c, f, p = inputs() + future = {**s, "source_id": "99", "date": "2026-05-01", "amount": "-100"} + result = run(g, [s, future], [c], f, p) + assert result["status"] == "SUCCEEDED" + assert result["receipt_total"] == "100.00" + + +def test_一款多发货金额守恒且同日按源id排序(): + g, s, c, f, p = inputs() + result = run( + g, + [ + {**s, "source_id": "9", "amount": "60", "cost_total": "30"}, + {**s, "source_id": "3", "amount": "60", "cost_total": "30"}, + ], + [c], + f, + p, + ) + assert [r["shipment_id"] for r in result["lines"]] == ["3", "9"] + assert result["receipt_total"] == result["allocated_total"] == "100.00" + assert result["shipment_remaining"] == "20.00" + + +def test_多款一发货和成本分摊尾差(): + g, s, c, f, p = inputs() + s = {**s, "amount": "3", "cost_total": "1"} + cash = [{**c, "source_id": str(i), "amount": "1"} for i in range(3)] + result = run(g, [s], cash, f, p) + assert sum(Decimal(r["allocated_cost"]) for r in result["lines"]) == Decimal("1") + assert result["shipment_remaining"] == "0.00" + + +def test_旧应收优先冲平且不算新绩效(): + g, s, c, f, p = inputs() + g["opening_balance"] = "70" + result = run(g, [s], [c], f, p) + assert result["lines"][0]["kind"] == "OPENING" + assert result["lines"][0]["performance"] is None + assert result["lines"][1]["amount"] == "30.00" + assert result["opening_remaining"] == "0.00" + + +def test_明确未生成凭证的款排除且不阻塞合格回款(): + g, s, c, f, p = inputs() + excluded = { + **c, + "source_id": "99", + "amount": "45", + "voucher_confirmed": False, + "exclusion": "NOT_GENERATED_VOUCHER", + } + result = run(g, [s], [excluded, c], f, p) + assert result["status"] == "SUCCEEDED" + assert result["receipt_total"] == result["allocated_total"] == "100.00" + assert result["excluded_receipt_total"] == "45.00" + line = next(row for row in result["lines"] if row["kind"] == "EXCLUDED") + assert line["performance"] is None and line["reasons"] == ["NOT_GENERATED_VOUCHER"] + + +def test_离职人员缺交接依据不猜绩效归属(): + g, s, c, f, p = inputs() + f["personnel"]["001"]["status"] = 0 + line = run(g, [s], [c], f, p)["lines"][0] + assert line["status"] == "PENDING" and line["performance"] is None + assert "HANDOVER_PENDING" in line["reasons"] + + +def test_预收参数取值时点不明确时不猜测(): + g, s, c, f, p = inputs() + c["date"] = "2026-03-01" + line = run(g, [s], [c], f, p)["lines"][0] + assert line["status"] == "PENDING" + assert "PREPAYMENT_PARAMETERS_PENDING" in line["reasons"] + + +def test_预收未发货不计提_发货后最高比例(): + g, s, c, f, p = inputs() + c["date"] = "2026-03-01" + p["prepayment_parameter_basis"] = "SHIPMENT" + before = run(g, [s], [c], f, p, date(2026, 3, 5)) + assert before["prepayment_remaining"] == "100.00" + after = run(g, [s], [c], f, p) + assert after["lines"][0]["rate"] == "1/100" + assert after["lines"][0]["effective_date"] == "2026-03-10" + + +@pytest.mark.parametrize( + "missing,reason", [("cost_total", "GUIDANCE_PENDING"), ("category", "PRODUCT_CATEGORY_PENDING")] +) +def test_缺必要字段不按零计算(missing, reason): + g, s, c, f, p = inputs() + s.pop(missing) + result = run(g, [s], [c], f, p) + assert result["lines"][0]["performance"] is None + assert reason in result["lines"][0]["reasons"] + + +def test_凭证状态未确认阻止本组分配(): + g, s, c, f, p = inputs() + c["voucher_confirmed"] = False + assert run(g, [s], [c], f, p)["reasons"] == ["VOUCHER_STATE_PENDING"] + + +def test_经理本人业务排除(): + g, s, c, f, p = inputs() + f["personnel"]["001"]["role_code"] = "sales_manager" + assert run(g, [s], [c], f, p)["lines"][0]["performance"] == "0.00" + + +def test_负单据不进入普通fifo(): + g, s, c, f, p = inputs() + c["amount"] = "-1" + assert run(g, [s], [c], f, p)["reasons"] == ["NEGATIVE_OR_ZERO_DOCUMENT"] + + +def test_已核对更正发货保留源记录但排除绩效(): + g, s, c, f, p = inputs() + correction = { + **s, + "source_id": "99", + "amount": "-100", + "performance_excluded": True, + "exclusion_reason": "CORRECTION_EXCLUDED", + "correction_pair_key": "pair-1", + } + result = run(g, [correction, s], [c], f, p) + excluded = next(row for row in result["lines"] if row["kind"] == "EXCLUDED") + assert result["status"] == "SUCCEEDED" + assert result["excluded_shipment_total"] == "-100.00" + assert excluded["reasons"] == ["CORRECTION_EXCLUDED"] + assert result["performance_total"] != "0.00" + + +def test_禁止跨组及重复输入(): + g, s, c, f, p = inputs() + with pytest.raises(ValueError, match="跨"): + run(g, [s], [{**c, "group_key": "other"}], f, p) + with pytest.raises(ValueError, match="重复"): + run(g, [s, s], [c], f, p) + + +def test_离职交接转给新人员_在职交接保留发货人(): + g, s, c, f, p = inputs() + f["personnel"]["002"] = {"role_code": "salesperson"} + handover = { + "business_key": "business1", + "mode": "RESIGNATION", + "effective_date": "2026-03-05", + "from_personnel_id": "001", + "to_personnel_id": "002", + } + f["handovers"] = [handover] + assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002" + handover["mode"] = "ACTIVE" + assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001" + + +def test_输入金额拒绝浮点与无穷(): + for value in [1.1, "NaN", "Infinity"]: + with pytest.raises(ValueError): + money(value) diff --git a/backend/tests/unit/test_tplus_materials.py b/backend/tests/unit/test_tplus_materials.py new file mode 100644 index 0000000..bae3ade --- /dev/null +++ b/backend/tests/unit/test_tplus_materials.py @@ -0,0 +1,40 @@ +"""恢复库核对包不能用缺记录证明零运费、已发绩效或已确认零期初。""" + +from app.services.tplus.materials import QUERIES, render_markdown, summarize + + +def records(): + data = {name: [] for name in QUERIES} + data["customer"] = [{"id": 9395, "code": "0060201", "name": "测试客户", "customerType": None}] + return data + + +def test_缺初始化记录只形成候选余额(): + report = summarize(records(), 9395, "2026-03") + assert report["opening_candidate"] == "0.00" and not report["opening_confirmed"] + assert not report["ready_for_amount"] and not report["formal_payroll_written"] + assert {m["key"] for m in report["missing"]} >= {"GUIDANCE", "FREIGHT", "HISTORICAL_PAYROLL"} + + +def test_2017初始化不直接当作切换余额(): + data = records() + data["opening"] = [{"origDate": "2017-05-31", "amount": "10000"}] + report = summarize(data, 9395, "2026-03") + assert report["opening_candidate"] is None + assert "2026-02-28" in report["opening_explanation"] + + +def test_超过范围明确标记核对包不完整(): + data = records() + data["items"] = [{"id": i} for i in range(201)] + report = summarize(data, 9395, "2026-03") + assert report["truncated"] == ["items"] + assert len(report["evidence"]["items"]) == 200 + + +def test_报告明确待补不制造实际金额(): + report = summarize(records(), 9395, "2026-03") + report["evidence_hash"] = "test" + output = render_markdown(report) + assert "候选期初余额:0.00" in output + assert "实际工资金额尚未验收" in output diff --git a/backend/tests/unit/test_tplus_relationship_audit.py b/backend/tests/unit/test_tplus_relationship_audit.py index ff832d5..562ed64 100644 --- a/backend/tests/unit/test_tplus_relationship_audit.py +++ b/backend/tests/unit/test_tplus_relationship_audit.py @@ -80,7 +80,8 @@ def test_多订单和成本日期仍需业务确认不自动选择(): result = _audit().run() assert result["order_coverage"]["multiple_orders"] == 2 keys = {item["key"] for item in result["pending_confirmations"]} - assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys + assert {"cost_basis", "voucher_state", "opening_balance", "allocation"} <= keys + assert not {"receipt_date", "ship_date"} & keys def test_SQL查询有固定白名单且不接受自定义表名(): diff --git a/backend/tests/unit/test_tplus_sync.py b/backend/tests/unit/test_tplus_sync.py index eda67f5..dd7a67a 100644 --- a/backend/tests/unit/test_tplus_sync.py +++ b/backend/tests/unit/test_tplus_sync.py @@ -108,3 +108,19 @@ def test_未确认时间增量不能创建误导性批次(target): with pytest.raises(ValueError, match="增量水位语义尚未确认"): service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27)) assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 + + +def test_已读数据后明确超时仍标记可重试失败(target): + service = _service(target, []) + + def source(entity, **kwargs): + if entity == "products": + return iter([{"id": 1}]) + raise TimeoutError("敏感驱动信息不得回显") + + service.reader.iter_entity.side_effect = source + batch = service.run(entity_types=["products", "customers", "personnel"]) + assert batch.status == "FAILED" + assert batch.staged_record_count == 1 + assert service.reader.iter_entity.call_count == 2 + assert target.scalar(select(TPlusSyncError.error_code)) == "SOURCE_TIMEOUT" diff --git a/backend/tests/unit/test_tplus_workbench.py b/backend/tests/unit/test_tplus_workbench.py new file mode 100644 index 0000000..7f67438 --- /dev/null +++ b/backend/tests/unit/test_tplus_workbench.py @@ -0,0 +1,822 @@ +"""真实事务验证导入、资料版本和预演隔离;SQLite 仅替代存储。""" + +from contextlib import nullcontext +from datetime import datetime +from decimal import Decimal +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text +from sqlalchemy.orm import Session + +from app.models import ( + Base, + BusinessRole, + Customer, + PerformanceLedger, + Personnel, + Product, + TPlusFact, + TPlusSourceHead, + TPlusSourceLink, + TPlusSyncBatch, + TPlusSyncRecord, + TPlusWorkRun, +) +from app.schemas.tplus_workbench import FactIn, WorkCreateIn +from app.services.tplus import workbench +from app.services.tplus.mapping import get_mapping +from app.services.tplus.master_import import execute_master +from app.services.tplus.source_heads import observe_rows +from app.services.tplus.staging import stage_row + + +@pytest.fixture +def work_session(monkeypatch): + engine = create_engine("sqlite://") + metadata = MetaData() + for model_table in Base.metadata.sorted_tables: + table = model_table.to_metadata(metadata) + for column in table.columns: + if column.type.__class__.__name__ in ("BIGINT", "TINYINT"): + column.type = Integer() + if column.name == "update_time": + column.server_default = DefaultClause(text("CURRENT_TIMESTAMP")) + metadata.create_all(engine) + monkeypatch.setattr(workbench, "_source_database", lambda: "test_source") + monkeypatch.setattr(workbench, "sync_lock", lambda s: nullcontext()) + with Session(engine, expire_on_commit=False) as session: + session.add( + BusinessRole( + role_name="营销员", + role_code="salesperson", + calc_template="SALESPERSON", + sort_order=1, + status=1, + ) + ) + session.commit() + yield session + engine.dispose() + + +def source(session, entity, rows, batch_no=None): + number = batch_no or uuid4().hex[:24] + batch = TPlusSyncBatch( + batch_no=number, + sync_mode="FULL", + status="SUCCEEDED", + source_database="test_source", + entity_types_json=[entity], + audit_result_json={"source_head_version": 1, "observed_entities": [entity]}, + finished_at=datetime.now(), + ) + session.add(batch) + session.flush() + for row in rows: + stage_row( + session, + batch_no=number, + entity_type=entity, + source_table=get_mapping(entity).source_table, + row=row, + ) + observe_rows(session, batch, get_mapping(entity), rows) + session.commit() + return batch + + +def fact(session, kind, key, data): + return workbench.save_fact( + session, + FactIn( + kind=kind, fact_key=key, data=data, evidence="测试用明确客户答复", expected_version=0 + ), + 1, + ) + + +def preview(session, entities, ids=None): + run = TPlusWorkRun( + run_no=uuid4().hex[:24], + request_key=str(uuid4()), + source_database="test_source", + requested_by=1, + kind="MASTER_PREVIEW", + status="RUNNING", + input_json={"entity_types": entities, "source_ids": ids or []}, + ) + session.add(run) + execute_master(session, run) + session.commit() + return run + + +def apply(session, run): + task = TPlusWorkRun( + run_no=uuid4().hex[:24], + request_key=str(uuid4()), + source_database="test_source", + requested_by=1, + kind="MASTER_APPLY", + status="RUNNING", + input_json={"preview_no": run.run_no}, + ) + session.add(task) + execute_master(session, task) + session.commit() + return task + + +def prepare_person(session): + source(session, "personnel", [{"id": 1, "code": "00043", "name": "测试人员"}]) + fact( + session, + "PERSONNEL", + "00043", + {"code": "00043", "name": "测试人员", "role_code": "salesperson", "status": 1}, + ) + + +def test_人员真实导入且重跑不重复_保留人工工资与岗位(work_session): + s = work_session + prepare_person(s) + result = apply(s, preview(s, ["personnel"])) + assert result.summary_json["counts"]["CREATE"] == 1 + person = s.scalar(select(Personnel)) + assert person.source_no == "00043" and person.manager_id is None + person.salary_standard = Decimal("1234.56") + person.is_probation = 1 + s.commit() + again = preview(s, ["personnel"]) + assert again.summary_json["counts"] == {"SKIP": 1} + apply(s, again) + assert s.scalar(select(func.count()).select_from(Personnel)) == 1 + assert person.salary_standard == Decimal("1234.56") and person.is_probation == 1 + assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 + + +@pytest.mark.parametrize("change", ["source", "fact", "target"]) +def test_旧预览源或人工资料或目标变化必须拒绝(work_session, change): + s = work_session + prepare_person(s) + first = preview(s, ["personnel"]) + if change == "source": + source(s, "personnel", [{"id": 1, "code": "00043", "name": "新名字"}]) + elif change == "fact": + s.add( + TPlusFact( + source_database="test_source", + kind="PRODUCT", + fact_key="1", + version=1, + data_json={"category": "rail_transit", "unit": "件"}, + evidence="新资料", + requested_by=1, + ) + ) + s.commit() + else: + # 新增相同身份的人工人员也应使原 CREATE 预览过期。 + s.add(Personnel(personnel_name="测试人员", business_role_id=1)) + s.commit() + from app.services.tplus.workbench import WorkbenchError + + with pytest.raises(WorkbenchError, match="PREVIEW_STALE"): + apply(s, first) + + +def test_源A变B再变A指向本次观察的A(work_session): + s = work_session + for name in ("A", "B", "A"): + source(s, "customers", [{"id": 1, "name": name}]) + head = s.scalar(select(TPlusSourceHead)) + assert s.get(TPlusSyncRecord, head.record_id).payload_json["name"] == "A" + assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2 + + +def test_没有当前读取证据不能将历史staging直接导入(work_session): + from app.services.tplus.workbench import WorkbenchError + + with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"): + preview(work_session, ["personnel"]) + + +def test_空客户分类及产品类别保持待补_不取Tplus成本(work_session): + s = work_session + source(s, "products", [{"id": 1, "name": "产品", "unit_name": "件", "invSCost": "123"}]) + source(s, "customers", [{"id": 2, "name": "客户", "partnerType": 211, "customerType": None}]) + run = preview(s, ["products", "customers"]) + assert run.summary_json["counts"] == {"PENDING": 2} + apply(s, run) + assert s.scalar(select(func.count()).select_from(Product)) == 0 + assert s.scalar(select(func.count()).select_from(Customer)) == 0 + + +def test_客户改名按源ID更新_人工分类不覆盖(work_session): + s = work_session + fact(s, "CUSTOMER", "2", {"customer_type": "personal", "owner_name": "客户负责人"}) + source(s, "customers", [{"id": 2, "name": "旧名", "partnerType": 211}]) + apply(s, preview(s, ["customers"])) + customer = s.scalar(select(Customer)) + customer.customer_type = "enterprise" + s.commit() + source(s, "customers", [{"id": 2, "name": "新名", "partnerType": 211}]) + apply(s, preview(s, ["customers"])) + assert customer.company_name == "新名" and customer.customer_type == "enterprise" + assert s.scalar(select(func.count()).select_from(Customer)) == 1 + + +def test_客户供应商混合性质不导入(work_session): + s = work_session + source(s, "customers", [{"id": 2, "name": "混合", "partnerType": 228}]) + assert preview(s, ["customers"]).summary_json["counts"] == {"SKIP": 1} + + +def test_未关联的同名目标不按名字合并(work_session): + s = work_session + prepare_person(s) + s.add(Personnel(personnel_name="测试人员", business_role_id=1)) + s.commit() + run = preview(s, ["personnel"]) + assert run.summary_json["counts"] == {"CONFLICT": 1} + assert s.scalar(select(func.count()).select_from(TPlusSourceLink)) == 0 + + +def test_重复源工号不生成两个人(work_session): + s = work_session + prepare_person(s) + source( + s, + "personnel", + [ + {"id": 1, "code": "00043", "name": "测试人员"}, + {"id": 2, "code": "00043", "name": "测试人员"}, + ], + ) + assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2} + + +def test_资料版本冲突不覆盖_历史保留(work_session): + from app.core.exceptions import BadRequestError + + s = work_session + data = {"category": "rail_transit", "unit": "件"} + fact(s, "PRODUCT", "1", data) + with pytest.raises(BadRequestError): + fact(s, "PRODUCT", "1", data) + workbench.save_fact( + s, + FactIn(kind="PRODUCT", fact_key="1", data=data, expected_version=1, evidence="核对后补充"), + 1, + ) + assert s.scalar(select(func.count()).select_from(TPlusFact)) == 2 + + +def test_更正事实要求排除或原业务归属(): + with pytest.raises(ValidationError): + FactIn( + kind="CORRECTION", + fact_key="1", + evidence="客户确认", + data={"entity": "SHIPMENT", "pair_key": "pair-1"}, + ) + shipment = FactIn( + kind="CORRECTION", + fact_key="1", + evidence="客户确认", + data={ + "entity": "SHIPMENT", + "pair_key": "pair-1", + "exclude_performance": True, + }, + ) + assert shipment.data["exclude_performance"] is True + receipt = FactIn( + kind="CORRECTION", + fact_key="2", + evidence="客户确认", + data={ + "entity": "RECEIPT", + "pair_key": "pair-1", + "redirect_business_key": "group-a", + }, + ) + assert receipt.data["redirect_business_key"] == "group-a" + + +def test_资料入口拒绝任意字段与非有限金额(): + with pytest.raises(ValidationError): + FactIn( + kind="PRODUCT", + fact_key="1", + evidence="依据", + data={"category": "rail_transit", "unit": "件", "password": "not-allowed"}, + ) + with pytest.raises(ValidationError): + FactIn( + kind="GUIDANCE", + fact_key="1", + evidence="依据", + data={ + "product_id": "1", + "month": "2026-03", + "basis": "UNIT", + "amount": "NaN", + "unit": "件", + }, + ) + + +def test_工作请求幂等且不同意图不复用(work_session): + from app.core.exceptions import BadRequestError + + s = work_session + payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"]) + a = workbench.queue_work(s, payload, 1) + assert workbench.queue_work(s, payload, 1)["run_no"] == a["run_no"] + with pytest.raises(BadRequestError): + workbench.queue_work(s, payload.model_copy(update={"entity_types": ["customers"]}), 1) + + +def test_工作区接口拒绝未登录请求(client): + for url in ( + "/api/v1/system/tplus/facts", + "/api/v1/system/tplus/work-runs", + "/api/v1/system/tplus/source-records?entity=personnel", + ): + assert client.get(url).status_code == 401 + + +def prepare_calc(session): + """完整准备一个已确认口径的隔离组,不借用真实客户数据。""" + prepare_person(session) + source( + session, + "shipments", + [ + { + "id": 20, + "code": "SH-DEMO", + "idcustomer": 8, + "voucherdate": "2026-03-10", + "voucherState": 189, + } + ], + ) + source( + session, + "shipment_items", + [{"id": 21, "source_parent_id": 20, "idinventory": 9, "taxAmount": "100", "quantity": "2"}], + ) + source( + session, + "receipts", + [ + { + "id": 10, + "code": "RC-DEMO", + "idpartner": 8, + "voucherDate": "2026-03-10", + "isReceiveFlag": 1, + "voucherstate": 189, + "amount": "100", + } + ], + ) + fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "件"}) + fact(session, "CUSTOMER", "8", {"customer_type": "personal", "owner_name": "负责人"}) + fact( + session, + "GUIDANCE", + "price9-03", + {"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"}, + ) + fact(session, "RECEIPT", "10", {"voucher_confirmed": True, "voucher_reference": "测试会计凭证"}) + fact( + session, + "SHIPMENT", + "21", + {"sales_amount_excludes_freight": True, "business_key": "biz", "personnel_id": "00043"}, + ) + group = { + "customer_id": "8", + "shipment_ids": ["20"], + "receipt_ids": ["10"], + "scope_confirmed": True, + "opening_confirmed": True, + "opening_balance": "0", + "historical_settlement_confirmed": True, + "guidance_month_basis": "SHIPMENT", + } + fact(session, "GROUP", "g", group) + session.commit() + return group + + +def calc(session, keys=None): + from app.services.tplus.calculation_preview import execute_preview + + run = TPlusWorkRun( + run_no=uuid4().hex[:24], + request_key=str(uuid4()), + kind="CALC_PREVIEW", + source_database="test_source", + requested_by=1, + status="RUNNING", + input_json={"month": "2026-03", "group_keys": keys or ["g"]}, + ) + session.add(run) + execute_preview(session, run) + session.commit() + return run + + +def test_真实暂存组装预演结果可复现_正式表不变(work_session): + s = work_session + prepare_calc(s) + first = calc(s) + second = calc(s) + assert first.snapshot_hash == second.snapshot_hash + assert first.summary_json == second.summary_json + assert first.summary_json["groups"]["g"]["performance_total"] == "0.20" + assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 + from app.models import MonthlySettlement, Receipt + + assert s.scalar(select(func.count()).select_from(Receipt)) == 0 + assert s.scalar(select(func.count()).select_from(MonthlySettlement)) == 0 + + +def test_一个组资料缺失不阻塞另一组计算(work_session): + s = work_session + prepare_calc(s) + fact( + s, + "GROUP", + "missing", + { + "customer_id": "9", + "shipment_ids": ["9000"], + "receipt_ids": [], + "scope_confirmed": True, + "opening_confirmed": True, + "opening_balance": "0", + "historical_settlement_confirmed": True, + }, + ) + run = calc(s, ["g", "missing"]) + assert run.status == "PARTIAL" + assert run.summary_json["groups"]["g"]["performance_total"] == "0.20" + assert run.summary_json["groups"]["missing"]["reasons"] == ["GROUP_SOURCE_MISSING"] + + +def test_跨客户和跨组重复消耗被拦截(work_session): + s = work_session + group = prepare_calc(s) + fact(s, "GROUP", "other", group) + run = calc(s) + assert run.summary_json["groups"]["g"]["reasons"] == ["OVERLAPPING_GROUPS"] + assert run.status == "PARTIAL" + + +def test_重叠组不阻塞无关正常组(work_session): + s = work_session + prepare_calc(s) + for key in ("bad1", "bad2"): + fact(s, "GROUP", key, {"customer_id": "999", "shipment_ids": ["9999"], "receipt_ids": []}) + run = calc(s, ["g", "bad1", "bad2"]) + assert run.summary_json["groups"]["g"]["performance_total"] == "0.20" + assert run.summary_json["groups"]["bad1"]["reasons"] == ["OVERLAPPING_GROUPS"] + assert run.summary_json["groups"]["bad2"]["reasons"] == ["OVERLAPPING_GROUPS"] + + +def test_已核销不能代替凭证确认(work_session): + s = work_session + prepare_calc(s) + existing = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")] + workbench.save_fact( + s, + FactIn( + kind="RECEIPT", + fact_key="10", + expected_version=existing.version, + evidence="凭证尚未生成", + data={"voucher_confirmed": False, "voucher_reference": "仅已核销"}, + ), + 1, + ) + assert calc(s).summary_json["groups"]["g"]["reasons"] == ["VOUCHER_STATE_PENDING"] + + +@pytest.mark.parametrize( + "count,valid,manual,confirmed,reason", + [ + (1, 1, None, True, None), + (0, 0, None, False, None), + (1, 0, None, False, "ACCOUNTING_VOUCHER_INVALID"), + (2, 1, None, False, "ACCOUNTING_VOUCHER_INVALID"), + (1, 1, False, False, "VOUCHER_EVIDENCE_CONFLICT"), + (0, 0, True, False, "VOUCHER_EVIDENCE_CONFLICT"), + ], +) +def test_生成凭证按真实总账关联核实而非审核状态(count, valid, manual, confirmed, reason): + from types import SimpleNamespace + + from app.services.tplus.calculation_preview import _voucher_evidence + + raw = { + "accounting_voucher_count": count, + "valid_accounting_voucher_count": valid, + "accounting_voucher_id": 123 if valid else None, + "accounting_voucher_code": "GL-EXAMPLE", + "accounting_voucher_state": 181, + } + fact = ( + SimpleNamespace(data_json={"voucher_confirmed": manual, "voucher_reference": "人工核对"}) + if manual is not None + else None + ) + allowed, evidence, pending = _voucher_evidence(raw, fact) + assert allowed is confirmed + assert evidence["basis"] == "GL_SrcVoucherInfo/GL_Doc" + if reason: + assert reason in pending + else: + assert pending == [] + + +def test_总账已生成无需逐单手工勾选且凭证编号可追溯(work_session): + from app.models import TPlusWorkLine + + s = work_session + prepare_calc(s) + original = workbench.latest_facts(s, "test_source")[("RECEIPT", "10")] + s.delete(original) + source( + s, + "receipts", + [ + { + "id": 10, + "code": "RC-DEMO", + "idpartner": 8, + "voucherDate": "2026-03-10", + "isReceiveFlag": 1, + "voucherstate": 189, + "amount": "100", + "accounting_voucher_count": 1, + "valid_accounting_voucher_count": 1, + "accounting_voucher_id": 123, + "accounting_voucher_code": "GL-EXAMPLE", + } + ], + ) + run = calc(s) + assert run.status == "SUCCEEDED" + line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no)) + assert line.data_json["accounting_voucher_code"] == "GL-EXAMPLE" + assert line.data_json["voucher_evidence"]["basis"] == "GL_SrcVoucherInfo/GL_Doc" + + +def test_新回执固定发货月指导成本_历史空开关不再阻塞(work_session): + s = work_session + group = prepare_calc(s) + group["guidance_month_basis"] = None + workbench.save_fact( + s, + FactIn( + kind="GROUP", fact_key="g", expected_version=1, evidence="历史空开关由2026-10-30客户答复替代", data=group + ), + 1, + ) + assert calc(s).summary_json["groups"]["g"]["status"] == "SUCCEEDED" + from app.models import TPlusWorkLine + + assert ( + s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.entity_type == "group")).data_json[ + "performance" + ] + == "0.20" + ) + + +def test_后续读取中断不发布部分当前指针(work_session): + from app.services.tplus.snapshots import source_records + from app.services.tplus.workbench import WorkbenchError + + s = work_session + prepare_person(s) + s.add( + TPlusSyncBatch( + batch_no=uuid4().hex[:24], + sync_mode="FULL", + status="FAILED", + source_database="test_source", + entity_types_json=["personnel"], + ) + ) + s.commit() + with pytest.raises(WorkbenchError, match="SOURCE_REFRESH_REQUIRED"): + source_records(s, "test_source", "personnel") + + +def test_冻结重跑不读取后来修改的指导价(work_session): + from app.services.tplus.calculation_preview import execute_preview + + s = work_session + prepare_calc(s) + first = calc(s) + old = workbench.latest_facts(s, "test_source")[("GUIDANCE", "price9-03")] + workbench.save_fact( + s, + FactIn( + kind="GUIDANCE", + fact_key="price9-03", + expected_version=old.version, + evidence="新资料不应改变冻结预演", + data={**old.data_json, "amount": "49"}, + ), + 1, + ) + replay = TPlusWorkRun( + run_no=uuid4().hex[:24], + request_key=str(uuid4()), + kind="CALC_PREVIEW", + source_database="test_source", + requested_by=1, + status="RUNNING", + input_json={"replay_no": first.run_no}, + ) + s.add(replay) + execute_preview(s, replay) + s.commit() + assert first.snapshot_hash == replay.snapshot_hash + assert first.summary_json == replay.summary_json + assert calc(s).snapshot_hash != first.snapshot_hash + + +@pytest.mark.parametrize("settle_state", [428, 180, 21, 283, 1645, 1646]) +def test_真实结清状态枚举不误判为作废(work_session, settle_state): + s = work_session + prepare_calc(s) + source( + s, + "shipments", + [ + { + "id": 20, + "code": "SH-DEMO", + "idcustomer": 8, + "voucherdate": "2026-03-10", + "voucherState": 189, + "isCancel": settle_state, + "isCounteractDelivery": 0, + } + ], + ) + run = calc(s) + assert run.status == "SUCCEEDED" + assert run.summary_json["groups"]["g"]["performance_total"] == "0.20" + + +@pytest.mark.parametrize( + "state,reverse,expected", + [(181, 0, "SHIPMENT_STATE_PENDING"), (189, 1, "NEGATIVE_EVENT_REVIEW_REQUIRED")], +) +def test_非生效或红冲的销货仍被阻止(work_session, state, reverse, expected): + from app.models import TPlusWorkLine + + s = work_session + prepare_calc(s) + source( + s, + "shipments", + [ + { + "id": 20, + "code": "SH-DEMO", + "idcustomer": 8, + "voucherdate": "2026-03-10", + "voucherState": state, + "isCancel": 428, + "isCounteractDelivery": reverse, + } + ], + ) + run = calc(s) + assert run.status == "PARTIAL" + line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no)) + assert expected in line.data_json["reasons"] + + +def test_运费明确后从含税销售额扣除(work_session): + s = work_session + prepare_calc(s) + old = workbench.latest_facts(s, "test_source")[("SHIPMENT", "21")] + workbench.save_fact( + s, + FactIn( + kind="SHIPMENT", + fact_key="21", + expected_version=1, + evidence="例单核实含10元运费", + data={**old.data_json, "sales_amount_excludes_freight": False, "freight_amount": "10"}, + ), + 1, + ) + result = calc(s) + assert result.summary_json["groups"]["g"]["prepayment_remaining"] == "10.00" + ship = result.snapshot_json["calculation_inputs"]["g"]["shipments"][0] + assert ship["amount"] == "90" and ship["gross_tax_amount"] == "100" + + +def test_预演采用已导入基础记录的人工分类(work_session): + s = work_session + prepare_calc(s) + product = Product( + product_name="人工分类产品", spec_model="", unit="件", product_category="rail_transit" + ) + customer = Customer( + company_name="人工分类客户", owner_name="负责人", customer_type="enterprise" + ) + s.add_all([product, customer]) + s.flush() + s.add_all( + [ + TPlusSourceLink( + source_database="test_source", + entity_type="products", + source_id="9", + target_id=product.id, + ), + TPlusSourceLink( + source_database="test_source", + entity_type="customers", + source_id="8", + target_id=customer.id, + ), + ] + ) + s.commit() + run = calc(s) + ship = run.snapshot_json["calculation_inputs"]["g"]["shipments"][0] + assert ship["category"] == "rail_transit" and ship["customer_type"] == "enterprise" + assert ship["classification_evidence"]["product_target_id"] == product.id + + +@pytest.mark.parametrize("value", ["", "abc", "1,200", "NaN", "-1"]) +def test_非法指导价被校验拒绝(value): + with pytest.raises(ValidationError): + FactIn( + kind="GUIDANCE", + fact_key="price", + evidence="输入校验", + data={ + "product_id": "9", + "month": "2026-03", + "basis": "UNIT", + "unit": "件", + "amount": value, + }, + ) + + +def test_同名人员不同状态在预览即标为冲突(work_session): + s = work_session + source( + s, + "personnel", + [{"id": 1, "code": "001", "name": "重名"}, {"id": 2, "code": "002", "name": "重名"}], + ) + for code, status in (("001", 1), ("002", 0)): + fact( + s, + "PERSONNEL", + code, + {"code": code, "name": "重名", "role_code": "salesperson", "status": status}, + ) + assert preview(s, ["personnel"]).summary_json["counts"] == {"CONFLICT": 2} + + +def test_报告文本防公式注入且保留前导零(): + from app.services.tplus.report_labels import spreadsheet_value + + assert spreadsheet_value("00043") == "'00043" + assert spreadsheet_value("\t=1+1") == "'\t=1+1" + assert spreadsheet_value("普通单号-1") == "普通单号-1" + + +def test_未确认日期更正不开放(): + with pytest.raises(ValidationError, match="日期更正规则尚未确认"): + FactIn( + kind="SHIPMENT", + fact_key="21", + evidence="未确认的建议", + data={ + "sales_amount_excludes_freight": True, + "business_key": "b", + "personnel_id": "001", + "date_override": "2026-03-01", + }, + ) diff --git a/backend/tests/unit/test_tplus_worker_stop.py b/backend/tests/unit/test_tplus_worker_stop.py new file mode 100644 index 0000000..593c1ad --- /dev/null +++ b/backend/tests/unit/test_tplus_worker_stop.py @@ -0,0 +1,35 @@ +"""停止标记控制真实CLI领取边界,不中断正在完成的单次处理。""" + +from argparse import Namespace +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app import cli +from app.core.config import Settings + + +@pytest.mark.parametrize("already_stopped", [False, True]) +def test_停止后不再领取下一任务(monkeypatch, tmp_path, already_stopped): + marker = tmp_path / "stop.requested" + if already_stopped: + marker.touch() + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(cli, "get_settings", lambda: settings) + monkeypatch.setattr(cli, "get_session_factory", lambda: lambda: nullcontext(object())) + + def finish(_session, **_kwargs): + marker.touch() + return "completed" + + consume = Mock(side_effect=finish) + pulse = Mock() + monkeypatch.setattr(cli, "run_once", consume) + monkeypatch.setattr(cli, "get_batch", lambda *_: SimpleNamespace(status="SUCCEEDED")) + monkeypatch.setattr("app.services.tplus.automation.heartbeat", pulse) + result = cli._tplus_worker(Namespace(connection_file=None, poll_seconds=2, watch=True, stop_file=str(marker))) + assert result == 0 + assert consume.call_count == (0 if already_stopped else 1) + assert pulse.call_args.args[3] == "STOPPED" diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 78108e2..652478a 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -824,6 +824,9 @@ CREATE TABLE settlement_items ( )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci; CREATE TABLE adjustment_orders ( + adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额', + source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID', + attribution_json JSON NULL COMMENT '原台账业务与分管归属快照', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}', settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)', @@ -846,6 +849,7 @@ CREATE TABLE adjustment_orders ( CREATE TABLE performance_ledgers ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)', + allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串', calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1', is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1', calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1', @@ -876,7 +880,8 @@ CREATE TABLE performance_ledgers ( create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT pk_performance_ledgers PRIMARY KEY (id), - CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id), + INDEX idx_performance_ledgers_receipt_id (receipt_id), + CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key), CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT, CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT @@ -894,6 +899,8 @@ CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (person -- T+ 同步批次、错误、原始 staging 与增量水位表(V14) -- --------------------------------------------------------------------------- CREATE TABLE tplus_sync_batches ( + trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY', + automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号', sync_mode VARCHAR(16) NOT NULL COMMENT '任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留', @@ -907,14 +914,15 @@ CREATE TABLE tplus_sync_batches ( staged_record_count INT NOT NULL DEFAULT 0 COMMENT '写入 staging 的记录数', failed_record_count INT NOT NULL DEFAULT 0 COMMENT '失败记录数', error_message TEXT COMMENT '批次错误摘要', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + -- 以下 6 列对应 V15 的 ADD COLUMN:物理列序须与迁移终态一致(表尾追加)。 entity_types_json JSON COMMENT '请求读取的实体类型;历史批次可为空', skipped_record_count INT NOT NULL DEFAULT 0 COMMENT '重复快照跳过条数', parent_batch_no VARCHAR(32) COMMENT '重试来源批次号,保留原批次记录', request_key VARCHAR(36) COMMENT '请求幂等键,空值用于历史和直接CLI任务', requested_by BIGINT UNSIGNED COMMENT '请求用户ID,CLI任务为空,无物理外键', audit_result_json JSON COMMENT '关联核验结果,只含系统字典和汇总', - create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', CONSTRAINT pk_tplus_sync_batches PRIMARY KEY (id), CONSTRAINT uk_tplus_sync_batches_batch_no UNIQUE (batch_no), CONSTRAINT uk_tplus_sync_batches_request_key UNIQUE (request_key) @@ -982,9 +990,155 @@ INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集 -- --------------------------------------------------------------------------- -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- +-- V16:基础导入与核算预演的隔离工作区;无正式工资写入。 +CREATE TABLE tplus_source_heads ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + entity_type VARCHAR(32) NOT NULL COMMENT '实体类型', + source_id VARCHAR(64) NOT NULL COMMENT '源内部ID', + record_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', + batch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', + CONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), + CONSTRAINT uk_tplus_source_heads_source_entity_id UNIQUE (source_database, entity_type, source_id) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_tplus_source_heads_batch_entity ON tplus_source_heads (batch_no, entity_type); + +CREATE TABLE tplus_facts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + kind VARCHAR(32) NOT NULL COMMENT '事实类别', + fact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', + version INTEGER NOT NULL COMMENT '版本号', + data_json JSON NOT NULL COMMENT '经校验的事实内容', + evidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_facts PRIMARY KEY (id), + CONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci; + +CREATE TABLE tplus_source_links ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + entity_type VARCHAR(32) NOT NULL COMMENT '实体类型', + source_id VARCHAR(64) NOT NULL COMMENT '源内部ID', + target_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_source_links PRIMARY KEY (id), + CONSTRAINT uk_tplus_source_links_source_entity_id UNIQUE (source_database, entity_type, source_id), + CONSTRAINT uk_tplus_source_links_entity_target UNIQUE (entity_type, target_id) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci; + +CREATE TABLE tplus_work_runs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + run_no VARCHAR(32) NOT NULL COMMENT '运行编号', + request_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', + kind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', + status VARCHAR(16) NOT NULL COMMENT '运行状态', + source_database VARCHAR(128) NOT NULL COMMENT '固定源账套', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + input_json JSON NOT NULL COMMENT '请求输入', + snapshot_json JSON COMMENT '规则和补充资料的固定快照', + snapshot_hash VARCHAR(64) COMMENT '输入指纹', + summary_json JSON COMMENT '结果汇总', + error_code VARCHAR(64) COMMENT '固定错误编码', + started_at DATETIME COMMENT '开始时间', + finished_at DATETIME COMMENT '结束时间', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), + CONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), + CONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id); + +CREATE TABLE tplus_work_lines ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + run_no VARCHAR(32) NOT NULL COMMENT '运行编号', + line_no INTEGER NOT NULL COMMENT '稳定行序号', + entity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', + source_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', + action VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', + reason VARCHAR(128) COMMENT '固定原因编码', + data_json JSON NOT NULL COMMENT '最小必要的业务明细', + CONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), + CONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci; + CREATE TABLE alembic_version ( version_num VARCHAR(32) NOT NULL, CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('e5f607a8b9c0'); +INSERT INTO alembic_version (version_num) VALUES ('21f2a3b4c5d6'); + +-- 正式工资输入、FIFO过程及生成锁定;新增表,不改写历史结果。 + +CREATE TABLE payroll_months ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + month VARCHAR(7) NOT NULL COMMENT '工资月份', + maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', + maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', + generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_months PRIMARY KEY (id), + CONSTRAINT uk_payroll_months_month UNIQUE (month) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci; + +CREATE TABLE payroll_inputs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', + version INTEGER NOT NULL COMMENT '输入版本', + source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', + group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', + fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', + data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), + CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key); + +CREATE TABLE payroll_allocations ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', + business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', + line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', + input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', + amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', + data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), + CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id); + +CREATE TABLE tplus_automation_states ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', + root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', + current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', + retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', + next_retry_at DATETIME COMMENT '下一次自动重试时间', + last_success_at DATETIME COMMENT '最近成功采集时间', + maintenance_at DATETIME COMMENT '最近周期检查时间', + heartbeat_at DATETIME COMMENT '独立进程心跳时间', + worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', + worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT 'STOPPED', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), + CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci; diff --git a/db/migration/V16__tplus_workbench.sql b/db/migration/V16__tplus_workbench.sql new file mode 100644 index 0000000..2d01f93 --- /dev/null +++ b/db/migration/V16__tplus_workbench.sql @@ -0,0 +1,78 @@ +-- V16:基础导入与核算预演的隔离工作区;无正式工资写入。 +CREATE TABLE tplus_source_heads ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + entity_type VARCHAR(32) NOT NULL COMMENT '实体类型', + source_id VARCHAR(64) NOT NULL COMMENT '源内部ID', + record_id BIGINT UNSIGNED NOT NULL COMMENT '原始快照ID', + batch_no VARCHAR(32) NOT NULL COMMENT '最后观察批次', + CONSTRAINT pk_tplus_source_heads PRIMARY KEY (id), + CONSTRAINT uk_tplus_heads_source_entity_id UNIQUE (source_database, entity_type, source_id) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_tplus_heads_batch_entity ON tplus_source_heads (batch_no, entity_type); + +CREATE TABLE tplus_facts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + kind VARCHAR(32) NOT NULL COMMENT '事实类别', + fact_key VARCHAR(128) NOT NULL COMMENT '事实业务键', + version INTEGER NOT NULL COMMENT '版本号', + data_json JSON NOT NULL COMMENT '经校验的事实内容', + evidence VARCHAR(500) NOT NULL COMMENT '依据和修改原因', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_facts PRIMARY KEY (id), + CONSTRAINT uk_tplus_facts_source_kind_key_version UNIQUE (source_database, kind, fact_key, version) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 人工补充事实版本表' COLLATE utf8mb4_general_ci; + +CREATE TABLE tplus_source_links ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + entity_type VARCHAR(32) NOT NULL COMMENT '实体类型', + source_id VARCHAR(64) NOT NULL COMMENT '源内部ID', + target_id BIGINT UNSIGNED NOT NULL COMMENT '业务目标ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_source_links PRIMARY KEY (id), + CONSTRAINT uk_tplus_links_source_entity_id UNIQUE (source_database, entity_type, source_id), + CONSTRAINT uk_tplus_links_entity_target UNIQUE (entity_type, target_id) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 基础数据源目标关联' COLLATE utf8mb4_general_ci; + +CREATE TABLE tplus_work_runs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + run_no VARCHAR(32) NOT NULL COMMENT '运行编号', + request_key VARCHAR(36) NOT NULL COMMENT '请求幂等键', + kind VARCHAR(32) NOT NULL COMMENT 'MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW', + status VARCHAR(16) NOT NULL COMMENT '运行状态', + source_database VARCHAR(128) NOT NULL COMMENT '固定源账套', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + input_json JSON NOT NULL COMMENT '请求输入', + snapshot_json JSON COMMENT '规则和补充资料的固定快照', + snapshot_hash VARCHAR(64) COMMENT '输入指纹', + summary_json JSON COMMENT '结果汇总', + error_code VARCHAR(64) COMMENT '固定错误编码', + started_at DATETIME COMMENT '开始时间', + finished_at DATETIME COMMENT '结束时间', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_work_runs PRIMARY KEY (id), + CONSTRAINT uk_tplus_work_runs_run_no UNIQUE (run_no), + CONSTRAINT uk_tplus_work_runs_request_key UNIQUE (request_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入与预演运行表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_tplus_work_runs_status_id ON tplus_work_runs (status, id); + +CREATE TABLE tplus_work_lines ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + run_no VARCHAR(32) NOT NULL COMMENT '运行编号', + line_no INTEGER NOT NULL COMMENT '稳定行序号', + entity_type VARCHAR(32) NOT NULL COMMENT '实体或分组类型', + source_id VARCHAR(128) NOT NULL COMMENT '源ID或平账组', + action VARCHAR(32) NOT NULL COMMENT '新增/更新/跳过/待补/冲突/分配', + reason VARCHAR(128) COMMENT '固定原因编码', + data_json JSON NOT NULL COMMENT '最小必要的业务明细', + CONSTRAINT pk_tplus_work_lines PRIMARY KEY (id), + CONSTRAINT uk_tplus_work_lines_run_line UNIQUE (run_no, line_no) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+ 导入预览与核算预演明细' COLLATE utf8mb4_general_ci; diff --git a/db/migration/V17__tplus_index_names.sql b/db/migration/V17__tplus_index_names.sql new file mode 100644 index 0000000..2c66e57 --- /dev/null +++ b/db/migration/V17__tplus_index_names.sql @@ -0,0 +1,5 @@ +-- 仅重命名 V16 固定索引,不改变数据。 +ALTER TABLE tplus_source_heads RENAME INDEX uk_tplus_heads_source_entity_id TO uk_tplus_source_heads_source_entity_id; +ALTER TABLE tplus_source_heads RENAME INDEX idx_tplus_heads_batch_entity TO idx_tplus_source_heads_batch_entity; +ALTER TABLE tplus_source_links RENAME INDEX uk_tplus_links_source_entity_id TO uk_tplus_source_links_source_entity_id; +ALTER TABLE tplus_source_links RENAME INDEX uk_tplus_links_entity_target TO uk_tplus_source_links_entity_target; diff --git a/db/migration/V18__formal_payroll.sql b/db/migration/V18__formal_payroll.sql new file mode 100644 index 0000000..d22f0ce --- /dev/null +++ b/db/migration/V18__formal_payroll.sql @@ -0,0 +1,51 @@ +-- 正式工资输入、FIFO过程及生成锁定;新增表,不改写历史结果。 + +CREATE TABLE payroll_months ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + month VARCHAR(7) NOT NULL COMMENT '工资月份', + maker_id BIGINT UNSIGNED NOT NULL COMMENT '实际工资制作人账号ID', + maker_name VARCHAR(64) NOT NULL COMMENT '制作人用户名快照', + generated_at DATETIME COMMENT '首次成功生成时间(不可解锁)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_months PRIMARY KEY (id), + CONSTRAINT uk_payroll_months_month UNIQUE (month) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='工资月份控制表(首次生成即锁定分配,审批不解锁)' COLLATE utf8mb4_general_ci; + +CREATE TABLE payroll_inputs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', + version INTEGER NOT NULL COMMENT '输入版本', + source_database VARCHAR(128) NOT NULL COMMENT '源账套或人工来源', + group_key VARCHAR(128) NOT NULL COMMENT '独立业务平账组', + fingerprint VARCHAR(64) NOT NULL COMMENT '来源及事实指纹', + data_json JSON NOT NULL COMMENT '经核实的源数据及人工事实快照', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '录入人账号ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_inputs PRIMARY KEY (id), + CONSTRAINT uk_payroll_inputs_business_version UNIQUE (business_id, version) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式工资输入版本表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_payroll_inputs_source_group ON payroll_inputs (source_database, group_key); + +CREATE TABLE payroll_allocations ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + ledger_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式台账ID', + business_id BIGINT UNSIGNED NOT NULL COMMENT '正式业务ID', + line_no INTEGER NOT NULL COMMENT '本台账稳定明细序号', + input_id BIGINT UNSIGNED NOT NULL COMMENT '所用输入版本ID', + amount NUMERIC(14, 2) NOT NULL COMMENT '分配回款金额', + data_json JSON NOT NULL COMMENT '单据、成本、归属、参数和逐段计算依据', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_payroll_allocations PRIMARY KEY (id), + CONSTRAINT uk_payroll_allocations_ledger_line UNIQUE (ledger_id, line_no) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='正式FIFO分配明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_payroll_allocations_business_id ON payroll_allocations (business_id); + +CREATE INDEX idx_performance_ledgers_receipt_id ON performance_ledgers (receipt_id); +ALTER TABLE performance_ledgers ADD COLUMN allocation_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式FIFO事件键;历史单笔核算为空串'; +ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version_personnel; +ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id, allocation_key); diff --git a/db/migration/V19__allocation_adjustment_attribution.sql b/db/migration/V19__allocation_adjustment_attribution.sql new file mode 100644 index 0000000..dd10204 --- /dev/null +++ b/db/migration/V19__allocation_adjustment_attribution.sql @@ -0,0 +1,4 @@ +-- 分配调整固化原台账与业务分管;普通调整也统一计生成当月。 +ALTER TABLE adjustment_orders ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT 'GENERAL' COMMENT '调整类型:GENERAL普通 ALLOCATION分配差额'; +ALTER TABLE adjustment_orders ADD COLUMN source_ledger_id BIGINT UNSIGNED NULL COMMENT '分配差额对应的原台账ID'; +ALTER TABLE adjustment_orders ADD COLUMN attribution_json JSON NULL COMMENT '原台账业务与分管归属快照'; diff --git a/db/migration/V20__tplus_automation.sql b/db/migration/V20__tplus_automation.sql new file mode 100644 index 0000000..37b842f --- /dev/null +++ b/db/migration/V20__tplus_automation.sql @@ -0,0 +1,25 @@ +-- 自动采集状态;不自动应用业务或生成工资。 + +CREATE TABLE tplus_automation_states ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要(不含凭据)', + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + scheduled_period VARCHAR(16) COMMENT '最近已排队ISO周', + root_batch_no VARCHAR(32) COMMENT '本周期原始采集批次', + current_batch_no VARCHAR(32) COMMENT '当前原始或重试批次', + retry_attempts INTEGER NOT NULL COMMENT '本周期自动重试次数' DEFAULT '0', + next_retry_at DATETIME COMMENT '下一次自动重试时间', + last_success_at DATETIME COMMENT '最近成功采集时间', + maintenance_at DATETIME COMMENT '最近周期检查时间', + heartbeat_at DATETIME COMMENT '独立进程心跳时间', + worker_owner VARCHAR(36) COMMENT '当前持锁进程身份', + worker_phase VARCHAR(16) NOT NULL COMMENT '进程状态:IDLE/RUNNING/STOPPED' DEFAULT '''STOPPED''', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), + CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci; + +ALTER TABLE tplus_sync_batches ADD COLUMN trigger_type VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '触发来源:MANUAL/SCHEDULED/AUTO_RETRY'; + +ALTER TABLE tplus_sync_batches ADD COLUMN automatic_attempt INTEGER NOT NULL DEFAULT 0 COMMENT '自动重试序号(0为原始任务)'; diff --git a/db/migration/V21__automation_phase_default.sql b/db/migration/V21__automation_phase_default.sql new file mode 100644 index 0000000..9cfd980 --- /dev/null +++ b/db/migration/V21__automation_phase_default.sql @@ -0,0 +1,3 @@ +-- 修正V20状态默认值的额外引号;不改历史业务、工资或核算数据。 +ALTER TABLE tplus_automation_states ALTER COLUMN worker_phase SET DEFAULT 'STOPPED'; +UPDATE tplus_automation_states SET worker_phase='STOPPED' WHERE worker_phase='''STOPPED'''; diff --git a/db/queries/tplus/customers.sql b/db/queries/tplus/customers.sql index 8e02165..6899714 100644 --- a/db/queries/tplus/customers.sql +++ b/db/queries/tplus/customers.sql @@ -9,6 +9,7 @@ SELECT TOP (:batch_size) p.disabled, p.idsaleman, p.partnerType, + p.customerType, p.updated AS source_updated_at, p.ts AS source_rowversion FROM dbo.AA_Partner AS p diff --git a/db/queries/tplus/materials/customer.sql b/db/queries/tplus/materials/customer.sql new file mode 100644 index 0000000..17fc44f --- /dev/null +++ b/db/queries/tplus/materials/customer.sql @@ -0,0 +1,2 @@ +SELECT id,code,name,partnerType,customerType,idsaleman,disabled +FROM dbo.AA_Partner WHERE id=:customer_id; diff --git a/db/queries/tplus/materials/expenses.sql b/db/queries/tplus/materials/expenses.sql new file mode 100644 index 0000000..d78e3e0 --- /dev/null +++ b/db/queries/tplus/materials/expenses.sql @@ -0,0 +1,4 @@ +SELECT TOP (201) d.idsaledelivery,d.idsaledeliverydetail,d.amount, + d.idSaleExpenseAllocationDTO,d.SourceVoucherCode +FROM dbo.SA_SaleExpenseAllocationDetail d JOIN dbo.SA_SaleDelivery h ON h.id=d.idsaledelivery +WHERE h.idcustomer=:customer_id AND h.voucherdate<:until ORDER BY h.id,d.id; diff --git a/db/queries/tplus/materials/history.sql b/db/queries/tplus/materials/history.sql new file mode 100644 index 0000000..3fd9a4a --- /dev/null +++ b/db/queries/tplus/materials/history.sql @@ -0,0 +1,6 @@ +SELECT d.idvouchertype,t.name AS voucher_type,d.flag,d.prepayFlag, + COUNT(*) AS row_count,SUM(d.amount) AS amount,SUM(d.settleAmount) AS settled_amount, + MIN(d.voucherDate) AS first_date,MAX(d.voucherDate) AS last_date +FROM dbo.ARAP_Detail d LEFT JOIN dbo.SM_VoucherType t ON t.id=d.idvouchertype +WHERE d.idpartner=:customer_id AND d.voucherDate<:cutoff AND d.isArFlag=1 +GROUP BY d.idvouchertype,t.name,d.flag,d.prepayFlag; diff --git a/db/queries/tplus/materials/items.sql b/db/queries/tplus/materials/items.sql new file mode 100644 index 0000000..9cc7b5e --- /dev/null +++ b/db/queries/tplus/materials/items.sql @@ -0,0 +1,8 @@ +SELECT TOP (201) b.id,b.idSaleDeliveryDTO,b.idinventory,i.code AS product_code, + i.name AS product_name,i.specification,cl.name AS source_class, + b.quantity,b.taxPrice,b.taxAmount,b.expenseAmount,b.costPrice,b.costAmount +FROM dbo.SA_SaleDelivery_b b JOIN dbo.SA_SaleDelivery h ON h.id=b.idSaleDeliveryDTO +LEFT JOIN dbo.AA_Inventory i ON i.id=b.idinventory +LEFT JOIN dbo.AA_InventoryClass cl ON cl.id=i.idinventoryclass +WHERE h.idcustomer=:customer_id AND h.voucherdate<:until +ORDER BY h.voucherdate,h.id,b.id; diff --git a/db/queries/tplus/materials/opening.sql b/db/queries/tplus/materials/opening.sql new file mode 100644 index 0000000..7c43c72 --- /dev/null +++ b/db/queries/tplus/materials/opening.sql @@ -0,0 +1,2 @@ +SELECT TOP (201) id,idpartner,origDate,amount,balanceAmount,prepayAmount,totalAmount +FROM dbo.ARAP_OriginalAmount_ArDetail WHERE idpartner=:customer_id ORDER BY origDate,id; diff --git a/db/queries/tplus/materials/receipts.sql b/db/queries/tplus/materials/receipts.sql new file mode 100644 index 0000000..9ce0330 --- /dev/null +++ b/db/queries/tplus/materials/receipts.sql @@ -0,0 +1,10 @@ +SELECT TOP (201) h.id,h.code,h.voucherDate,h.idperson,p.code AS personnel_code, + h.amount,h.isReceiveFlag,h.voucherstate, + doc.code AS accounting_voucher_code,doc.isinvalidate,st.Code AS accounting_state +FROM dbo.ARAP_ReceivePayment h LEFT JOIN dbo.AA_Person p ON p.id=h.idperson +LEFT JOIN dbo.SM_VoucherType typ ON typ.Code='ARAP01' +LEFT JOIN dbo.GL_SrcVoucherInfo src ON src.srcvoucherid=h.id AND src.idSrcVoucherType=typ.id +LEFT JOIN dbo.GL_Doc doc ON doc.id=src.idDocDTO +LEFT JOIN dbo.eap_EnumItem st ON st.id=doc.makeErrorState +WHERE h.idpartner=:customer_id AND h.voucherDate<:until +ORDER BY h.voucherDate,h.id,doc.id; diff --git a/db/queries/tplus/materials/shipments.sql b/db/queries/tplus/materials/shipments.sql new file mode 100644 index 0000000..419b7bf --- /dev/null +++ b/db/queries/tplus/materials/shipments.sql @@ -0,0 +1,5 @@ +SELECT TOP (201) h.id,h.code,h.voucherdate,h.idclerk,p.code AS personnel_code, + h.SaleOrderCode,h.taxAmount,h.voucherState,h.isCounteractDelivery +FROM dbo.SA_SaleDelivery h LEFT JOIN dbo.AA_Person p ON p.id=h.idclerk +WHERE h.idcustomer=:customer_id AND h.voucherdate<:until +ORDER BY h.voucherdate,h.id; diff --git a/db/queries/tplus/products.sql b/db/queries/tplus/products.sql index 6683689..fb2979b 100644 --- a/db/queries/tplus/products.sql +++ b/db/queries/tplus/products.sql @@ -6,11 +6,13 @@ SELECT TOP (:batch_size) i.name, i.specification, i.idunit, + u.name AS unit_name, i.idinventoryclass, i.disabled, i.invSCost, i.updated AS source_updated_at, i.ts AS source_rowversion FROM dbo.AA_Inventory AS i +LEFT JOIN dbo.AA_Unit AS u ON u.id = i.idunit WHERE i.id > :after_id ORDER BY i.id; diff --git a/db/queries/tplus/receipts.sql b/db/queries/tplus/receipts.sql index f38bfcf..d93a455 100644 --- a/db/queries/tplus/receipts.sql +++ b/db/queries/tplus/receipts.sql @@ -1,4 +1,5 @@ --- 已在客户备份核对列结构;业务语义仍待确认。 +-- 已核实生成凭证路径:来源单据类型 ARAP01 → GL_SrcVoucherInfo → GL_Doc。 +-- “未审”的总账凭证也可能已经生成,不能用单据已审核/已核销替代本关联。 -- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。 SELECT TOP (:batch_size) h.ID AS id, @@ -17,8 +18,27 @@ SELECT TOP (:batch_size) h.voucherstate, h.amount, h.paymentAmount, + ISNULL(accounting.accounting_voucher_count, 0) AS accounting_voucher_count, + ISNULL(accounting.valid_accounting_voucher_count, 0) AS valid_accounting_voucher_count, + accounting.accounting_voucher_id, + accounting_doc.code AS accounting_voucher_code, CAST(NULL AS datetime) AS source_updated_at, h.ts AS source_rowversion FROM dbo.ARAP_ReceivePayment AS h +LEFT JOIN ( + SELECT src.srcvoucherid, + COUNT(*) AS accounting_voucher_count, + SUM(CASE WHEN doc.id IS NOT NULL AND doc.isinvalidate = 0 + AND make_state.Code = 'Ringht' THEN 1 ELSE 0 END) AS valid_accounting_voucher_count, + MIN(CASE WHEN doc.id IS NOT NULL AND doc.isinvalidate = 0 + AND make_state.Code = 'Ringht' THEN doc.id ELSE NULL END) AS accounting_voucher_id + FROM dbo.GL_SrcVoucherInfo AS src + INNER JOIN dbo.SM_VoucherType AS source_type ON source_type.ID = src.idSrcVoucherType + LEFT JOIN dbo.GL_Doc AS doc ON doc.id = src.idDocDTO + LEFT JOIN dbo.eap_EnumItem AS make_state ON make_state.ID = doc.makeErrorState + WHERE source_type.Code = 'ARAP01' + GROUP BY src.srcvoucherid +) AS accounting ON accounting.srcvoucherid = h.ID +LEFT JOIN dbo.GL_Doc AS accounting_doc ON accounting_doc.id = accounting.accounting_voucher_id WHERE h.id > :after_id ORDER BY h.id; diff --git a/docs/同步运维自动化与样本核对开发记录_2026-10-30.md b/docs/同步运维自动化与样本核对开发记录_2026-10-30.md new file mode 100644 index 0000000..d44fa6f --- /dev/null +++ b/docs/同步运维自动化与样本核对开发记录_2026-10-30.md @@ -0,0 +1,71 @@ +# 同步运维自动化与样本核对开发记录 + +日期:2026-10-30。依据用户批准的 T1~T6 实施计划,保留既有正式工资闭环及未提交改动;本轮未提交或推送。 + +## 交付状态 + +| 任务 | 当前结果 | 仍需验收的部分 | +|---|---|---| +| T1 样本核对包 | 已从恢复库只读整理客户、发货、明细、回款凭证、工号、期初和费用证据 | 候选期初及空运费不自动认定为零 | +| T2 每周自动排队 | 已实现持久化周期、幂等、手动任务协调及停机补最近一周 | 生产自动排队关闭,目标环境启用另行确认 | +| T3 有限重试与提示 | 已实现错误分类、5/15 分钟两次重试、独立心跳及站内提示 | 外部邮件/企业消息不在本轮范围 | +| T4 Windows 托管包 | 启动、安装预演、身份/ACL 校验、检查及停用脚本已交付;本机隔离验证通过 | 客户服务账号、实际计划注册、开机及自动重启需专用目标环境验证 | +| T5 真实金额验收 | 待资料;没有正式工资或金额验收结果 | 用户明确没有样本人工指导成本,不能用源成本或零成本替代 | +| T6 回归及说明 | 完整单元、隔离集成、迁移、类型构建、浏览器及脚本验证已完成 | 真实金额、目标环境托管及生产启用分别记录 | + +## 样本依据及缺口 + +核对包:`docs/样本核对包_2026-10-30/materials-9395-2026-03.md`,同名 JSON 保留原始查询证据及指纹。源库始终只读,查询参数化并限制 201 行(交付最多 200 行,超限明确标记)。未读取生产连接文件或使用源写权限。 + +- 客户 9395,代码 0060201;2026-03-05 发货 SA-2026-03-0034,1125.00 元。 +- 明细 392858,产品 32584/02020276,承插口排水管 GIIΦ300*2500,10 根,含税单价 112.50 元。 +- 2026-03-18 回款 SK-2026-03-0016,1125.00 元,关联有效总账凭证 0035。发货和回款均关联业务员工号 010110,不再向客户重复索要单据归属。 +- 样本未查到初始化应收和切换日前应收台账,只形成候选零余额依据。总表存在 2017-05-31 初始化数据,不能当作 2026-02-28 余额。 +- 存在运费字典和费用分摊结构,但样本无关联费用,明细费用为空;仍待核实,不能当作零运费。 +- 产品源分类 GII级不能直接映射绩效分类,客户绩效类型为空。人工指导成本、样本历史已核算/已发金额和当时分管/交接依据不能从恢复库直接确认。 +- 用户两次表示未找到/没有该样本人工指导成本资料。本轮据此标记 T5 待补,不重复索要同一资料,不生成模拟工资冒充实测。 + +## 调度、重试和运行状态 + +新增 `TPlusAutomationState` 保存源身份摘要、ISO 周、原始/当前批次、累计自动重试次数、下次重试、维护时间及独立心跳。摘要不含凭据。`tplus-maintain --once` 复用原任务队列、九类白名单源实体及 worker,不调用正式导入或工资生成。 + +默认每周一北京时间 02:00、自动化关闭。相同源及周使用稳定请求键;维护锁与长采集锁独立,同周期竞争只排队一次。手动或工作区任务占用时延后,停机仅补最近已到期周期。 + +仅明确断连/超时,且本批次所有错误均可重试时,终止后 5 分钟、15 分钟各重试一次。读取中途断连仍记 FAILED,已保存页保留,由全量哈希去重恢复;行级资料错误/PARTIAL、配置/字段错误及中断转人工处理。自动次数累计不被人工重试清零。维护锁定并核对本周期重试树,人工恢复成功后不再重试原失败批次;人工与自动竞争同一父批次仅创建一个活动重试。 + +心跳由独立线程/会话每 30 秒写入,不受长查询阻塞。停止标记阻止排队及后续领取,完成当前任务后记 STOPPED。概况接口新增 `automation`,前端显示采集开关、心跳、最近成功、下次重试,以及失败/部分失败、排队 15 分钟、8 天无成功和心跳 2 分钟中断提示。提示可打开相关批次,写按钮遵循实际制作人权限。 + +## 迁移及托管 + +V20 增加自动化状态与批次触发来源、自动重试序号;发现状态默认值包含额外引号后,以新增 V21 修正,保留已应用 V20。同步迁移 SQL、ORM 和开发初始化结构。最新 head:`21f2a3b4c5d6`。仅在明确隔离测试库运行升级/回退,原业务库未迁移。 + +Windows 包位于 `scripts/windows/`,操作说明见该目录 README。默认预演,注册需显式 Apply;注册后仍禁用且存在停止标记,自动采集开关保持关闭。生产使用已有受限服务账号;安装清单保存 SID、登录方式并核对 Limited 权限,拒绝账号/动作漂移。安装和 Start 校验关键文件、所有者与上层目录权限,不自动变更现有 ACL。 + +任务设置为 IgnoreNew、每分钟异常重启、最多三次、无执行时限。启动器隐藏运行并传递子进程退出码,含空格路径及尾反斜线按 Windows 引号处理。Stop 先禁用并写标记,默认等待当前任务完成;显式 Force 才强停已核对项目任务。日志按次保存且不自动删除。实际客户任务未安装或启用。 + +## 验证证据 + +| 范围 | 结果 | 证据 | +|---|---|---| +| 最终完整单元(自动化阶段) | 784 项通过 | `.ai/automation-unit-final.log` | +| 完整隔离集成(自动化阶段) | 30 个文件,A 186+B 219=405 项通过 | `.ai/automation-integration-A.log`、`automation-integration-B.log` | +| 最新迁移往返及结构/调度专项 | V21 升级、回退、再升级及 16 项通过;新增默认值测试在本组覆盖 | `.ai/automation-migration-final.log` | +| Ruff | 后端检查通过 | `backend/.venv-workbench/Scripts/ruff.exe check backend` | +| 前端类型及生产构建 | 通过;既有大块体积提示保留 | `.ai/automation-frontend-build.log` | +| 真实 Edge 浏览器,合成接口 | 7 项检查通过,无 pageerror、无写请求;320/768/1440 无横向溢出 | `.ai/automation-browser-result.json`、`automation-ui-*.png` | +| Windows PowerShell 5 | 任务计划参数、语法、ACL规则及身份漂移、停止标记、空格路径、隐藏子进程和退出码验证通过 | `Test-PayrollTaskPlan.ps1`、`Test-PayrollHostRuntime.ps1` | + +完整隔离集成在 V20 时覆盖全部文件;V21 仅修正状态默认值,随后迁移往返和受影响 16 项再通过。新增默认值测试不重复计入前述 405 项完整基线。单元既有 Starlette 弃用提示未改变测试结果。 + +### 项目收尾复验补充(2026-10-30) + +正式工资与更正场景继续收口后,后端单元重新执行为 `786 passed`,代码变更后的隔离数据库完整集成为 `406 passed`,失败 0、跳过 0;Ruff、前端类型检查与生产构建通过。新增 `CORRECTION` 事实支持已确认的一正一负更正单排除绩效及跨客户回款归入原业务,详见正式工资开发记录。 + +托管复测中,测试脚本最初未提前持有进程句柄,导致正常退出被误判;确认 worker.stopped 和空错误日志后,修正测试脚本并重新隔离执行通过。生产启动器已保留句柄并正确传递失败码。浏览器最初的请求拦截范围和选择器属于测试脚本问题,已修正后完整通过;未因此修改产品行为。 + +## 后续事项 + +1. 取得有实际依据的最小成本、分类、运费、期初/历史及分管资料,执行一组真实样本从导入到工资/报表逐笔核对,再扩到整月。资料缺失时保留待补。 +2. 在专用目标环境准备服务账号与目录权限,确认后实际安装、验证开机及异常恢复,再决定生产自动化开关。 +3. 通用多原单更正金额与日期依据、SQL Server 2008 R2 实机兼容、目标环境全量/增量恢复演练继续单独跟踪;已确认的一正一负更正示例已实现。 +4. 历史数据和备份永久留存;本轮未执行清理、客户业务迁移或生产启用。 diff --git a/docs/正式工资闭环开发记录_2026-10-30.md b/docs/正式工资闭环开发记录_2026-10-30.md new file mode 100644 index 0000000..eb7dd7d --- /dev/null +++ b/docs/正式工资闭环开发记录_2026-10-30.md @@ -0,0 +1,82 @@ +# 正式工资闭环开发记录 + +日期:2026-10-30。按用户批准的四阶段计划实施;保留此前未提交内容,不提交或推送。真实客户金额验收与开发测试分别记录。 + +## 当前实现 + +- `PAYROLL_MAKER_USER_ID` 绑定现有指定账号。未配置时关闭业务写入;岗位和管理员身份不代替实际制作人。登录响应返回 `can_write_payroll`,页面同步禁用写按钮。工资台账、月结和调整单查询必须登录。 +- `payroll_months` 独立记录首次生成时间和实际制作人。月结草稿首次成功生成即锁定分配,失败事务回滚;退回审批和重新生成不解锁。 +- `payroll_inputs` 追加保存正式输入版本;`payroll_allocations` 保存逐段回款、发货、指导成本、原始参数、受益人员、分管及应付差额。预演不直接升级为工资。 +- 新增正式导入预览/执行任务,沿用 worker 和稳定源关联;源/事实/目标变化使旧预览失效。只允许追加新单据和事实版本,旧单据金额或日期变化形成冲突,不静默覆盖。 +- 正式FIFO共享已有分配内核;保留期初先冲、预收待实际发货、60%起付和平方权重,累计应付减去历史已计入应付。重算按月份顺序重建尚未生成工资的后续月份;已生成工资走当月调整。 +- 正式分管取核算时固化事实,支持同一业务员的不同业务由不同经理分管;离职承接未发部分,经理本人业务排除。新核算不消费旧任意比例/负责人余额方案,旧方案仅历史查看。 +- 调整单增加类型、原台账和业务分管快照。分配调整与通用新调整的服务层统一将月份设为生成当月,不接受客户端回写历史工资月份;旧引擎兼容测试显式启用旧月份行为。 +- 备份脚本停止按30天自动删除归档文件;本轮未执行备份清理或恢复。 + +## 新回执修订 + +详见 `客户补充回执核对_2026-10-30.md`。 + +1. 同一 T+ 发货客户名头合并平账,不按合同数量分组。正式源数据使用账套内稳定客户身份聚合,导入时自动纳入该客户截至所选月的发货/回款,保留逐次发货的人员、分管及交接依据。 +2. 指导成本固定取发货月份。资料录入完成后显式封存,后续回款或重算不改变原发货月份指导成本。历史回款月取价开关不再影响正式口径。 +3. 一正一负更正单排除绩效、跨客户回款归入原业务的示例已确认并实现;多原单金额分配与通用日期依据仍只阻塞对应特殊分支。 +4. 保证金、代收款回复“不涉及”,不新增相应计提。 +5. 第05项已由用户及后续截图澄清:审核能力保留,默认关闭。`PAYROLL_APPROVAL_ENABLED=false` 时生成工资即完成,不创建审批单、审批步骤或自动通过痕迹;需要时可显式开启原财务/领导流程,仍受各审批对象参数控制。与旧核算引擎兼容开关独立。 + +## 接口与调用 + +正式导入继续调用 `POST /api/v1/system/tplus/work-runs`: + +```json +{"kind":"BUSINESS_PREVIEW","request_key":"<本次意图的UUID>","month":"2026-03","group_keys":["<客户平账资料标识>"]} +``` + +预览完成后以 `BUSINESS_APPLY`、同次预览的 `preview_no` 和新意图UUID执行。网络重试复用同一请求键。人员、产品、客户基础导入须先完成,缺数据行保留待补。 + +人工核算准备:`POST /api/v1/payroll/businesses/{id}/input`,明确 `month`、`expected_version`、`evidence`、`opening_balance`、`history_confirmed=true`、`guidance_finalized=true`,并逐条提供 `shipment_ownerships` 中的发货明细ID、业务员、经理、含税单位指导成本。归属变动和离职交接使用版本化事实;不按当前人员直属关系推算历史。 + +正式计算:`POST /api/v1/payroll/businesses/{id}/calculate`,请求 `{"month":"2026-03"}`;重算增加非空 `reason`,返回受影响的 `recalculated_months`。页面调用见 `frontend/src/api/payroll.ts`,业务列表提供正式核算入口。 + +查询:`GET /api/v1/payroll/months/{month}`、`/businesses/{id}/input`、`/ledger/{id}/allocations`;分配调整:`POST /api/v1/payroll/allocation-adjustments`,必须关联已生成工资的原台账,月份由服务端确定。 + +## 迁移与验证 + +- V18:正式输入、分配明细、生成锁与FIFO事件唯一键。替换台账唯一索引前先建立回款外键支撑索引。 +- V19:调整类型、原台账和分管快照。模型、Alembic、迁移SQL与开发初始化同步;已有正式结果时拒绝破坏性结构回退,应回退应用版本并保留数据。 +- 集成测试入口:`python scripts/run_isolated_tests.py`;校验测试库名称、显式授权标记及真实引擎URL。运行器新建独立数据库及受限测试账号,凭据只经环境传入,不输出、不写源文件。 +- 已有本轮证据:单元746项通过;正式链路5项MySQL通过;V18空库升级/回退/再升级通过。新回执修订后的最终完整回归、V19结构、最终浏览器与构建结果待末次运行回填,不提前记为全部通过。 + +## 尚未完成的交付条件 + +真实样本仍需核实分类、成本、期初及历史已处理金额,不填默认值。当前未对原开发业务库或生产库执行迁移,未绑定实际生产制作人、未生成真实工资。SQL Server2008R2兼容、每周同步、服务托管、告警和目标环境全量/增量恢复演练仍保留后续任务。实际资金支付属于财务系统范围。 + +## 本轮最终验证证据 + +- 完整单元:749 passed,1条既有Starlette测试客户端弃用提醒,59.88秒;`.ai/formal-payroll-unit-delivery.log`。 +- 完整隔离集成基线:396 passed、1 failed,850.91秒;唯一失败是原毛利润用例的制作人夹具被参数管理员夹具覆盖。已将 writer 显式依赖 admin 保证绑定顺序,未放宽产品权限;该用例及受影响模块复测75 passed,137.32秒,`.ai/formal-payroll-latest-focused.log`。本轮不把“基线+定点”写成最新代码一次性完整回归全绿。 +- V18/V19隔离迁移:从当前head回退至07b8c9d0e1f2,再升级至19d0e1f2a3b4;结构同步9 passed,3.00秒,`.ai/formal-payroll-migration-final.log`。 +- Ruff、差异空白检查通过;前端vue-tsc与Vite生产构建通过。Vite仍有既有的大资源包体积提示,不作为功能测试失败;`.ai/formal-payroll-frontend-delivery.log`。 +- 浏览器:使用合成接口数据验证真实Vue页面的正式核算入口、资料填写及提交请求(含成本封存确认)、768宽弹窗、默认关闭审核时无提交按钮;浏览器错误0。此项为界面/请求契约检查,不冒充真实客户金额验收。 +- MySQL正式链路覆盖:默认关闭审核时工资/调整单不建ApprovalOrder或ApprovalStep;开启审核后财务和领导节点可用;指定账号写入、其他管理员拒绝、并发防重、失败不锁及成功锁定、固化分管和部门对账。 + +原开发库、T+生产连接与真实工资未被本轮测试写入。后续目标环境仍需应用迁移、绑定实际制作人、按实际资料完成样本验收。新回执的已确认更正示例已实现,通用多原单/日期规则仍按范围保留。 + +### 最新完整回归收口(优先于上方中间运行记录) + +- 最新完整集成覆盖全部29个测试文件,在两套独立库分组执行:A组215 passed(523.98秒),B组184 passed(407.76秒),共399 passed,无失败、无跳过。日志`.ai/formal-payroll-full-current-a.log`和`.ai/formal-payroll-full-current-b.log`。此前旧夹具失败已关闭,不再作为当前遗留项。 +- 完整单元749 passed;V18/V19隔离迁移往返及结构9 passed;最终类型与生产构建通过。浏览器还验证非制作人的管理员无法新增/正式核算,以及默认关闭审核时无审批列与提交按钮。 +- 当前阶段代码和自动化验证完成,真实样本金额、实际账号绑定及目标环境部署未验收,不将项目整体标为已交付。 + +### 2026-10-30 项目收尾复验 + +- 后端单元测试重新执行:`786 passed`,失败 0;仅有既有 Starlette/httpx 弃用提醒。 +- 隔离数据库完整集成重新执行:`406 passed`,失败 0、跳过 0,退出码 0;测试库由 `scripts/run_isolated_tests.py` 创建并在隔离环境中运行。 +- `ruff check app tests`、前端 `vue-tsc --noEmit`、Vite 生产构建通过;构建保留既有大资源包体积提示。 +- Windows `Test-PayrollTaskPlan.ps1` 与 `Test-PayrollHostRuntime.ps1` 通过,验证了默认禁用、单实例、重启策略、停止标记、路径空格和失败退出码传递;未注册计划任务。 +- 本次未连接生产 T+、未绑定生产制作人、未生成真实工资、未运行生产备份删除或恢复操作。外部验收条件仍按计划节点 6 保留。 + +### 第 03 项更正场景实现 + +- 新增版本化 `CORRECTION` 事实,要求记录更正配对标识;发货更正必须明确排除绩效,跨客户回款必须指定原业务标识。 +- 预演会保留更正发货源记录并输出排除明细,不进入 FIFO、销售额和正式绩效;B 客户侧的被转归回款自动排除,A 原业务侧按人工事实纳入,正式导入沿用同一输入版本和幂等保护。 +- 前端工作台已增加更正事实录入;新增 FIFO 与事实校验用例。该项已完成代码实现,真实样本仍需按客户资料做金额验收。 diff --git a/frontend/README.md b/frontend/README.md index 451f229..a5cbdb7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -287,3 +287,9 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py - 预警徽标用后端 `warnings.total`(全部命中数),`items` 仅为展示上限内的子集。 - `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。 - e2e `e2e/role_home_flow.py`:普通用户登录落地、菜单隐藏、刷新保持、直连 `/dashboard` 与报表页回退;需 `E2E_ADMIN_PASSWORD`。 + +## 2026-10-30 正式核算入口 + +业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。 + +本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index ddfef52..7c75c20 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -20,6 +20,8 @@ export interface UserInfo { username: string real_name: string | null roles: string[] + can_write_payroll?: boolean + payroll_approval_enabled?: boolean last_login_time?: string | null } diff --git a/frontend/src/api/calc.ts b/frontend/src/api/calc.ts index 61c7061..9b51331 100644 --- a/frontend/src/api/calc.ts +++ b/frontend/src/api/calc.ts @@ -26,6 +26,7 @@ export interface LedgerRow { payment_weight: string | null payable_amount: string | null settlement_month: string + settlement_id?: number | null is_probation: number missing_cost: number snapshot_json: Record | null diff --git a/frontend/src/api/payroll.ts b/frontend/src/api/payroll.ts new file mode 100644 index 0000000..de5ee50 --- /dev/null +++ b/frontend/src/api/payroll.ts @@ -0,0 +1,14 @@ +import { request } from './request' +import type { PageData } from './types' + +export interface PayrollInput { id: number; version: number; source: string; fingerprint: string; data: Record } +export interface MonthState { month: string; allocation_locked: boolean; maker_name: string | null } +export interface Ownership { shipment_item_id: number; personnel_id?: number; manager_id?: number; unit_cost: string } +export interface CalculationResult { month: string; business_id: number; ledger_ids: number[]; performance: string; payable: string; start_payment_ok: boolean } +const base = '/v1/payroll' +export const getInput = (id: number): Promise => request({url: `${base}/businesses/${id}/input`}) +export const monthState = (month: string): Promise => request({url: `${base}/months/${month}`}) +export const saveInput = (id: number, data: {month: string; expected_version: number; evidence: string; opening_balance: string; history_confirmed: true; guidance_finalized: true; shipment_ownerships: Ownership[]}): Promise<{version: number}> => request({url: `${base}/businesses/${id}/input`, method: 'post', data}) +export const calculate = (id: number, month: string, reason?: string): Promise => request({url: `${base}/businesses/${id}/calculate`, method: 'post', data: {month, reason}}) +export const allocations = (ledgerId: number): Promise>> => request({url: `${base}/ledger/${ledgerId}/allocations`, params: {pageSize: 200}}) +export const allocationAdjustment = (data: {ledger_id: number; personnel_id: number; direction: number; amount: string; reason: string}): Promise => request({url: `${base}/allocation-adjustments`, method: 'post', data}) diff --git a/frontend/src/api/settlements.ts b/frontend/src/api/settlements.ts index 6c72a84..6b67f4e 100644 --- a/frontend/src/api/settlements.ts +++ b/frontend/src/api/settlements.ts @@ -11,7 +11,7 @@ export const SETTLEMENT_STATUS = { export function settlementStatusLabel(status: number): string { return ( - { 1: '草稿', 2: '待审批', 3: '已审定(锁定)', 4: '已驳回' }[status] ?? String(status) + { 1: '草稿', 2: '待审批', 3: '已完成(锁定)', 4: '已驳回' }[status] ?? String(status) ) } @@ -46,6 +46,7 @@ export interface Settlement { total_payable: string | null person_count: number approval_order_no: string | null + approval_required: boolean submitter_name: string locked_at: string | null locked_by: string | null diff --git a/frontend/src/api/tplus-workbench.ts b/frontend/src/api/tplus-workbench.ts new file mode 100644 index 0000000..1169f0a --- /dev/null +++ b/frontend/src/api/tplus-workbench.ts @@ -0,0 +1,22 @@ +import service, { request } from './request' +import type { PageData } from './types' + +export type FactKind = 'PERSONNEL' | 'PRODUCT' | 'CUSTOMER' | 'GROUP' | 'GUIDANCE' | 'RECEIPT' | 'SHIPMENT' | 'HANDOVER' | 'MANAGER' | 'CORRECTION' +export type WorkKind = 'MASTER_PREVIEW' | 'MASTER_APPLY' | 'CALC_PREVIEW' | 'BUSINESS_PREVIEW' | 'BUSINESS_APPLY' +export interface Fact { id: number; kind: FactKind; fact_key: string; version: number; data: Record; evidence: string; requested_by: number; create_time: string } +export interface WorkRun { run_no: string; kind: WorkKind; status: string; input: Record; summary: Record | null; snapshot_hash: string | null; error_code: string | null; started_at: string | null; finished_at: string | null; create_time: string; request_key: string; formal_payroll_written: false } +export interface WorkLine { line_no: number; entity_type: string; source_id: string; action: string; reason: string | null; data: Record } +export interface SourceChoice { source_id: string; source_no: string | null; name: string; parent_id: string; date: string } +const base = '/v1/system/tplus' +export function listFacts(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise> { return request({url: `${base}/facts`, params}) } +export function saveFact(data: {kind: FactKind; fact_key: string; expected_version: number; evidence: string; data: Record}): Promise { return request({url: `${base}/facts`, method: 'post', data}) } +export function factHistory(id: number): Promise> { return request({url: `${base}/facts/${id}/history`, params: {pageSize: 200}}) } +export function listWorks(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise> { return request({url: `${base}/work-runs`, params}) } +export function createWork(data: {kind: WorkKind; request_key: string; entity_types?: string[]; source_ids?: string[]; preview_no?: string; replay_no?: string; month?: string; group_keys?: string[]}): Promise { return request({url: `${base}/work-runs`, method: 'post', data}) } +export function getWork(number: string): Promise { return request({url: `${base}/work-runs/${encodeURIComponent(number)}`}) } +export function workLines(number: string, params: {pageNum?: number; pageSize?: number; action?: string}): Promise> { return request({url: `${base}/work-runs/${encodeURIComponent(number)}/lines`, params}) } +export function sourceChoices(entity: string, search = ''): Promise> { return request({url: `${base}/source-records`, params: {entity, search, pageSize: 200}}) } +export async function downloadReport(number: string): Promise { + const response = await service.get(`${base}/work-runs/${encodeURIComponent(number)}/report`, {responseType: 'blob'}) + return response.data as Blob +} diff --git a/frontend/src/api/tplus.ts b/frontend/src/api/tplus.ts index debef48..d2cbff3 100644 --- a/frontend/src/api/tplus.ts +++ b/frontend/src/api/tplus.ts @@ -34,6 +34,8 @@ export interface TPlusBatch { finished_at: string | null can_retry: boolean error_summary: string | null + trigger_type?: 'MANUAL' | 'SCHEDULED' | 'AUTO_RETRY' + automatic_attempt?: number } export interface TPlusDetail extends TPlusBatch { audit_result: AuditReport | null } export interface TPlusError { @@ -53,6 +55,19 @@ export interface TPlusOverview { compatibility_status: string business_import_enabled: boolean latest_audit: AuditReport | null + automation?: AutomationStatus +} +export interface AutomationStatus { + enabled: boolean + weekday: number + time: string + timezone: string + stop_requested: boolean + worker_phase: string + heartbeat_at: string | null + last_success_at: string | null + next_retry_at: string | null + alerts: {code: string; message: string; batch_no?: string | null}[] } export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number } export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number } diff --git a/frontend/src/router/access.ts b/frontend/src/router/access.ts index 4d0cda9..a7b9470 100644 --- a/frontend/src/router/access.ts +++ b/frontend/src/router/access.ts @@ -1,5 +1,7 @@ import type { RouteRecordNormalized, Router } from 'vue-router' +import { useUserStore } from '@/store/user' + import { ROLE_ADMIN } from '@/utils/constants' /** @@ -18,7 +20,8 @@ export function isMenuRecord(record: RouteRecordNormalized): boolean { /** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */ export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean { if (record.meta.public) return false - if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false + if (record.meta.requiresPayrollApproval && !useUserStore().userInfo?.payroll_approval_enabled) return false + if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN) && !(record.meta.allowPayrollMaker && useUserStore().canWritePayroll)) return false if ( record.meta.allowedRoles && !record.meta.allowedRoles.some((role) => roles.includes(role)) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ec8ec25..2d26d6c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,8 @@ declare module 'vue-router' { title?: string /** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */ requiresAdmin?: boolean + allowPayrollMaker?: boolean + requiresPayrollApproval?: boolean /** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)—— * 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */ allowedRoles?: string[] @@ -67,7 +69,7 @@ const routes: RouteRecordRaw[] = [ path: '/system/tplus', name: 'system-tplus', component: () => import('@/views/system/tplus/index.vue'), - meta: { title: 'T+ 数据同步', icon: 'tplus', group: '系统管理', requiresAdmin: true, order: 10 }, + meta: { title: 'T+ 数据同步', icon: 'tplus', group: '系统管理', requiresAdmin: true, allowPayrollMaker: true, order: 10 }, }, { path: '/system/roles', @@ -119,7 +121,8 @@ const routes: RouteRecordRaw[] = [ name: 'system-approvals', component: () => import('@/views/system/approvals/index.vue'), meta: { - title: '审批管理', + title: '审核记录', + requiresPayrollApproval: true, icon: 'approvals', group: '系统管理', allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], @@ -295,7 +298,11 @@ router.beforeEach(async (to) => { } } - if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) { + if (to.meta.requiresPayrollApproval && !userStore.userInfo?.payroll_approval_enabled) { + return firstAccessiblePath(router, userStore.roles) + } + + if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN) && !(to.meta.allowPayrollMaker && userStore.canWritePayroll)) { const home = firstAccessiblePath(router, userStore.roles) // 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示 if (!to.redirectedFrom) ElMessage.error('需要管理员权限') diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts index 5c4fe25..fc55f6b 100644 --- a/frontend/src/store/user.ts +++ b/frontend/src/store/user.ts @@ -14,6 +14,7 @@ export const useUserStore = defineStore('user', () => { const token = ref(getToken()) const userInfo = ref(null) + const canWritePayroll = computed(() => userInfo.value?.can_write_payroll === true) const roles = computed(() => userInfo.value?.roles ?? []) const displayName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '') @@ -49,5 +50,5 @@ export const useUserStore = defineStore('user', () => { clearToken() } - return { token, userInfo, roles, displayName, login, fetchUserInfo, logout, resetAuth } + return { token, userInfo, canWritePayroll, roles, displayName, login, fetchUserInfo, logout, resetAuth } }) diff --git a/frontend/src/views/base/customers/index.vue b/frontend/src/views/base/customers/index.vue index e0ceb16..7f3c76d 100644 --- a/frontend/src/views/base/customers/index.vue +++ b/frontend/src/views/base/customers/index.vue @@ -1,4 +1,7 @@ + + diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index 26186b9..d0b8f52 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -1,4 +1,5 @@ @@ -131,7 +125,7 @@ async function handleSubmit(row: Settlement): Promise {

月度结算

- 按月生成绩效结算批次;送审后锁定,人数与金额不再随源数据变化 + 按月生成工资并锁定分配;审核功能默认关闭,完成后可直接查看和导出

@@ -163,7 +157,7 @@ async function handleSubmit(row: Settlement): Promise { style="width: 160px" maxlength="7" /> - + 生成月结快照 @@ -176,9 +170,9 @@ async function handleSubmit(row: Settlement): Promise { - - - + + +
@@ -212,13 +206,13 @@ async function handleSubmit(row: Settlement): Promise { {{ row.person_count }} - + - + @@ -226,16 +220,8 @@ async function handleSubmit(row: Settlement): Promise { @@ -268,9 +254,8 @@ async function handleSubmit(row: Settlement): Promise { 审批人才能区分「没有这个字段」与「有字段但为空」。 -->
-
审批单号
-
{{ detail.approval_order_no || '—' }}
-
提交人
+ +
制作人
{{ detail.submitter_name || '—' }}
锁定人
{{ detail.locked_by || '—' }}
@@ -305,7 +290,7 @@ async function handleSubmit(row: Settlement): Promise {

- 草稿/已驳回状态可重新生成快照后再次提交。 + 审核默认关闭,工资生成后直接完成并锁定。开启审核时,草稿或已驳回记录可重新生成后提交。

已锁定:源数据改动只产生提示与调整单。 diff --git a/frontend/src/views/system/tplus/FactEditor.vue b/frontend/src/views/system/tplus/FactEditor.vue new file mode 100644 index 0000000..bc263b5 --- /dev/null +++ b/frontend/src/views/system/tplus/FactEditor.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/frontend/src/views/system/tplus/WorkbenchPanel.vue b/frontend/src/views/system/tplus/WorkbenchPanel.vue new file mode 100644 index 0000000..10fb3f1 --- /dev/null +++ b/frontend/src/views/system/tplus/WorkbenchPanel.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/views/system/tplus/index.vue b/frontend/src/views/system/tplus/index.vue index a45503e..eee7766 100644 --- a/frontend/src/views/system/tplus/index.vue +++ b/frontend/src/views/system/tplus/index.vue @@ -6,10 +6,17 @@ import AppPagination from '@/components/table/AppPagination.vue' import { usePagedList } from '@/composables/usePagedList' import { handledError } from '@/utils/error' import { formatDateTime } from '@/utils/format' +import { newRequestKey } from '@/utils/uuid' import AuditResult from './AuditResult.vue' +import WorkbenchPanel from './WorkbenchPanel.vue' import BatchDetailDialog from './BatchDetailDialog.vue' import CreateTaskDialog from './CreateTaskDialog.vue' import { scopeLabel, statuses } from './labels' +import { useUserStore } from '@/store/user' + +const user = useUserStore() +const weekdays = ['一', '二', '三', '四', '五', '六', '日'] +const phases: Record = {UNKNOWN: '尚无心跳记录', IDLE: '等待任务', RUNNING: '处理中', STOPPED: '已停止'} const overview = ref(null) const overviewFailed = ref(false) @@ -35,7 +42,7 @@ async function refresh(): Promise { async function retry(row: api.TPlusBatch): Promise { if (retrying.value) return retrying.value = row.batch_no - const key = retryKeys.get(row.batch_no) ?? crypto.randomUUID() + const key = retryKeys.get(row.batch_no) ?? newRequestKey() retryKeys.set(row.batch_no, key) try { const batch = await api.retryTask(row.batch_no, key) @@ -60,11 +67,17 @@ onMounted(refresh)

T+ 数据同步

读取源数据、检查关联,并追踪每次处理结果。

-
刷新新建任务
+
刷新新建任务
- +

{{ overview?.configuration_message ?? '正在加载配置状态…' }}

+
+

自动采集与运行状态

{{ overview.automation.stop_requested ? '已请求停止' : overview.automation.enabled ? '自动排队已启用' : '自动排队未启用' }}
+

每周{{ weekdays[overview.automation.weekday] }} {{ overview.automation.time }}(北京时间)检查采集;正式导入和工资生成仍需制作人确认。

+
采集进程
{{ phases[overview.automation.worker_phase] ?? '待检查' }}
最近心跳
{{ formatDateTime(overview.automation.heartbeat_at) }}
最近成功采集
{{ formatDateTime(overview.automation.last_success_at) }}
下次有限重试
{{ formatDateTime(overview.automation.next_retry_at) }}
+ 查看相关任务 +
{{ label }} {{ (overview.status_counts[key] ?? 0).toLocaleString() }}累计新增快照 {{ overview.total_staged_records.toLocaleString() }}
@@ -73,13 +86,13 @@ onMounted(refresh)
- - - - - - - + + + + + + +
@@ -88,6 +101,7 @@ onMounted(refresh)

暂无关联检查结果。新建“关联检查”任务后,可在此查看覆盖数量与待确认口径。

+
@@ -105,6 +119,13 @@ onMounted(refresh) .batch-time, .batch-scope { margin: 4px 0 0; color: var(--ys-ink-2); font-size: 12px; } .latest-audit { margin-top: 20px; } .latest-audit h2 { margin: 0 0 12px; font-size: 16px; } +.automation-status { margin-top: 16px; } +.automation-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.automation-heading h2 { font-size: 16px; margin: 0; } +.automation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; } +.automation-grid dt { color: var(--ys-ink-2); font-size: 12px; } +.automation-grid dd { margin: 8px 0 0; font-variant-numeric: tabular-nums; } +.automation-alert { margin-top: 8px; } .pager-scroll { overflow-x: auto; } @media (max-width: 640px) { .page-head { align-items: flex-start; gap: 16px; flex-direction: column; } diff --git a/frontend/src/views/system/tplus/workbench-labels.ts b/frontend/src/views/system/tplus/workbench-labels.ts new file mode 100644 index 0000000..304ce1b --- /dev/null +++ b/frontend/src/views/system/tplus/workbench-labels.ts @@ -0,0 +1,52 @@ +export const factKinds = { PERSONNEL: '人员名单', PRODUCT: '产品分类与单位', CUSTOMER: '客户分类与负责人', GROUP: '平账范围与期初应收', GUIDANCE: '月度指导成本', RECEIPT: '收款凭证核实', SHIPMENT: '发货归属与运费核实', HANDOVER: '业务交接', MANAGER: '经理分管业务', CORRECTION: 'T+更正单处理' } +export const workKinds = { MASTER_PREVIEW: '基础导入预览', MASTER_APPLY: '基础数据导入', CALC_PREVIEW: '核算预演', BUSINESS_PREVIEW: '正式业务导入预览', BUSINESS_APPLY: '正式业务导入' } +export const actions: Record = { EXCLUDED: '不纳入本次预演', CREATE: '新增', UPDATE: '更新', SKIP: '跳过', PENDING: '待补充', CONFLICT: '冲突', READY: '可核对', LEGACY: '旧应收冲平', WAITING_SHIPMENT: '预收待发货', QUEUED: '排队中', RUNNING: '处理中', SUCCEEDED: '处理完成', PARTIAL: '部分待处理', FAILED: '失败' } +export const reasons: Record = { + CUSTOMER_SCOPE_CONFLICT: '同一客户的期初或历史范围不一致,请先合并核对资料', + GUIDANCE_FINALIZATION_REQUIRED: '发货月份指导成本尚未录入完毕并封存', + CUSTOMER_SCOPE_ALREADY_SELECTED: '该客户已纳入本次平账,重复选择已跳过', + FORMAL_SOURCE_CHANGED: '源单据或依据与正式版本不同,请先核对差异', + FORMAL_TARGET_CHANGED: '正式单据与已导入资料不一致,请先核对差异', + SOURCE_ALREADY_IMPORTED: '该源单据已经导入,不能重复占用', + CUSTOMER_IMPORT_REQUIRED: '请先完成客户基础导入', + PRODUCT_IMPORT_REQUIRED: '请先完成产品基础导入', + PERSONNEL_IMPORT_REQUIRED: '相关人员尚未在本账套唯一关联,请核对工号及基础导入', + BUSINESS_DATA_PENDING: '该客户业务资料仍待补充,请查看对应预演结果', + NOT_IN_PERSONNEL_ROSTER: '不在已确认人员名单中', PERSONNEL_IDENTITY_CONFLICT: '工号与名单姓名不一致', + BUSINESS_ROLE_MISSING: '系统岗位不存在', PRODUCT_CATEGORY_PENDING: '产品类型待填写', + PRODUCT_UNIT_PENDING: '产品单位待核对', NOT_CUSTOMER_ONLY: '往来单位性质不是仅客户', + CUSTOMER_ATTRIBUTE_PENDING: '客户属性或负责人待核对', TARGET_MISSING: '原导入目标已不存在', + TARGET_UNIQUE_CONFLICT: '与现有数据重名或唯一键冲突', DUPLICATE_SOURCE_IDENTITY: '源数据身份重复', + ROSTER_SOURCE_MISSING: '名单中的工号未在T+找到', PREVIEW_STALE: '数据或资料已变化,请重新预览', + SOURCE_REFRESH_REQUIRED: '请先完整读取相关源数据', SOURCE_CHANGED: '源账套配置已变化', + INTERRUPTED: '任务被中断,请重新创建任务', EXECUTION_ERROR: '执行失败,请核对配置和输入', + GROUP_SCOPE_PENDING: '平账范围待明确', OPENING_BALANCE_PENDING: '期初应收待核实', + HISTORICAL_SETTLEMENT_PENDING: '历史已核算及已发放款待核实', + VOUCHER_STATE_PENDING: '收款尚未核实已生成凭证', GUIDANCE_PENDING: '缺少对应指导成本', + GUIDANCE_MONTH_BASIS_PENDING: '指导价取发货月还是回款月尚未明确', + AUGUST_RULE_PENDING: '2026年8月后的调整参数尚未确认', PERSONNEL_PENDING: '归属人员待确认', + CUSTOMER_CATEGORY_PENDING: '客户分类待确认', PARAMETERS_PENDING: '计提参数待补充', + NEGATIVE_OR_ZERO_DOCUMENT: '负数或零金额单据需单独核对', NEGATIVE_EVENT_REVIEW_REQUIRED: '更正、红冲或作废业务需人工核对', + LEGACY_SYSTEM_CALCULATES: '此款只冲旧应收,绩效由原软件计算', NO_ACTUAL_SHIPMENT: '尚无实际发货,不计提绩效', + FREIGHT_PENDING: '含税销售额是否已剔除运费待核实', SHIPPING_OWNERSHIP_PENDING: '发货所属业务和人员待核实', + HANDOVER_SHIPMENTS_PENDING: '在职交接的发货归属待确认', TAX_AMOUNT_PENDING: '源含税金额缺失', + GROUP_SOURCE_MISSING: '所选单据未在本次完整快照中找到', GROUP_CUSTOMER_MISMATCH: '平账组包含其他客户的单据', + SHIPMENT_DETAILS_MISSING: '发货明细不完整', OVERLAPPING_GROUPS: '单据被多个平账组重复选取', + AMOUNT_PRECISION_PENDING: '金额精度超过分,需核对舍入口径', DATA_VALUE_INVALID: '源日期或金额格式需核对', + REPLAY_UNAVAILABLE: '该记录缺少可重跑快照或规则版本已变化,请保留原报告并新建预演', + QUANTITY_PENDING: '单位成本对应的发货数量需核对', + GROUP_FACT_REQUIRED: '尚未登记该平账组', + PREPAYMENT_PARAMETERS_PENDING: '预收业务采用发货日还是收款日参数待确认', + HANDOVER_PENDING: '离职日期或接手依据待补,不能猜测归属', + ACCOUNTING_VOUCHER_INVALID: '关联凭证缺失、已作废或制单状态异常', + VOUCHER_EVIDENCE_CONFLICT: '凭证关联与人工核实不一致,请复核', + NOT_GENERATED_VOUCHER: '无关联总账凭证,不纳入本次预演', + NOT_RECEIPT: '属于付款单,不作为销售回款核算', + SHIPMENT_STATE_PENDING: '销货单尚未核实为生效状态', + CORRECTION_EXCLUDED: '已核对更正单,保留源记录但不参与绩效核算', + REDIRECTED_RECEIPT: '回款已按核对结果归入原业务,当前客户不重复计提', +} +export function reasonText(value: unknown): string { + const parts = Array.isArray(value) ? value : String(value ?? '').split(',') + return parts.filter(Boolean).map(v => reasons[String(v)] ?? String(v)).join(';') +} diff --git a/scripts/backup_full.sh b/scripts/backup_full.sh index f64a4c1..2030a4d 100644 --- a/scripts/backup_full.sh +++ b/scripts/backup_full.sh @@ -5,7 +5,7 @@ # 也避免宿主机装 MySQL 客户端。凭据从 docker/.env 读取(不入脚本、不入版本库)。 # # 用法:在项目根目录执行 bash scripts/backup_full.sh -# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,保留最近 30 天。 +# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,永久保留,不自动清理。 set -euo pipefail @@ -57,10 +57,7 @@ if [[ -s "${OUT}.pos" ]]; then echo "binlog 位点:$(awk '{print $1, "pos="$2}' "${OUT}.pos") → $(basename "$OUT").pos" fi -# 保留 30 天 -find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.sql.gz' -mtime +30 -print -delete -find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.pos' -mtime +30 -print -delete -echo "已清理 30 天前的旧备份" +echo "永久留存:未自动清理任何备份" echo "" echo "最新备份:" diff --git a/scripts/backup_incremental.sh b/scripts/backup_incremental.sh index 795eaf3..088ab87 100644 --- a/scripts/backup_incremental.sh +++ b/scripts/backup_incremental.sh @@ -14,7 +14,7 @@ # 留到下次收取,配合 30 天 binlog 过期窗口余量充足)。 # # 用法:在项目根目录执行 bash scripts/backup_incremental.sh -# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),保留 30 天。 +# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),永久保留,不自动清理。 set -euo pipefail @@ -85,7 +85,5 @@ for f in "${NEW[@]}"; do done echo "$STAMP ${NEW[-1]}" > "$STATE_FILE" -# 保留 30 天(与全量一致;binlog 服务端默认 30 天过期,先于服务端消失无妨, -# 全量 + 其后增量链完整即满足恢复目标) -find "$BACKUP_DIR" -maxdepth 1 -name 'binlog.*.gz' -mtime +30 -print -delete +# 归档文件永久留存;服务器原始 binlog 过期与归档留存是不同配置,部署前验证归档链完整。 echo "增量归档完成,最新至 ${NEW[-1]}" diff --git a/scripts/run_isolated_tests.py b/scripts/run_isolated_tests.py new file mode 100644 index 0000000..9feff43 --- /dev/null +++ b/scripts/run_isolated_tests.py @@ -0,0 +1,51 @@ +"""使用唯一的本地测试库运行迁移和集成测试,绝不清理原业务库。""" + +import os +import argparse +import re +import secrets +import subprocess +import sys +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--reuse-database') + parser.add_argument('--migration-roundtrip', action='store_true') + parsed, test_args = parser.parse_known_args() + if parsed.reuse_database and not re.fullmatch(r'payroll_test_[0-9a-f]{12}', parsed.reuse_database): + raise SystemExit('只允许复用本运行器创建的隔离测试库') + root = Path(__file__).resolve().parents[1] + python = root / "backend/.venv-workbench/Scripts/python.exe" + if not python.exists(): + python = Path(sys.executable) + suffix = secrets.token_hex(6) + database, username = parsed.reuse_database or "payroll_test_" + suffix, "pytest_" + suffix + password = secrets.token_urlsafe(30) + sql = ("" if parsed.reuse_database else f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; ") + f"CREATE USER '{username}'@'%' IDENTIFIED BY '{password}'; GRANT ALL PRIVILEGES ON `{database}`.* TO '{username}'@'%';" + setup = subprocess.run( + ["docker", "exec", "-i", "ys-mysql-dev", "sh", "-c", 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysql -uroot'], + input=sql.encode(), capture_output=True, + ) + if setup.returncode: + raise SystemExit("隔离库创建失败,未执行迁移或测试") + env = os.environ | { + "MYSQL_DATABASE": database, "MYSQL_USER": username, "MYSQL_PASSWORD": password, + "PAYROLL_TEST_DATABASE": database, "REDIS_DB": "15", "PYTHONIOENCODING": "utf-8", + } + print("独立测试库:" + database, flush=True) + commands = [["-m", "alembic", "upgrade", "head"]] + if parsed.migration_roundtrip: + commands += [["-m", "alembic", "downgrade", "07b8c9d0e1f2"], ["-m", "alembic", "upgrade", "head"]] + args = test_args + commands.append(["-m", "pytest", *(args or ["tests/integration"]), "-q", "--tb=short"]) + for args in commands: + result = subprocess.run([str(python), *args], cwd=root / "backend", env=env) + if result.returncode: + return result.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tplus/prepare_sql2008r2_vm.ps1 b/scripts/tplus/prepare_sql2008r2_vm.ps1 index 3acbf16..9f836a9 100644 --- a/scripts/tplus/prepare_sql2008r2_vm.ps1 +++ b/scripts/tplus/prepare_sql2008r2_vm.ps1 @@ -1,14 +1,14 @@ [CmdletBinding()] param( [string]$VmName = "tplus-sql2008r2-test", - [string]$Root = "D:\work\app\tplus-vm", + [string]$Root = "E:\work\app\tplus-vm", [string]$WindowsIso = "" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $rootPath = [IO.Path]::GetFullPath($Root) -if (-not $rootPath.StartsWith("D:\work\app\", [StringComparison]::OrdinalIgnoreCase)) { - throw "虚拟机目录必须位于 D:\work\app 下。" +if (-not $rootPath.StartsWith("E:\work\app\", [StringComparison]::OrdinalIgnoreCase)) { + throw "虚拟机目录必须位于 E:\work\app 下。" } if ($WindowsIso -and -not (Test-Path -LiteralPath $WindowsIso -PathType Leaf)) { throw "Windows 安装介质不存在。" @@ -16,7 +16,7 @@ if ($WindowsIso -and -not (Test-Path -LiteralPath $WindowsIso -PathType Leaf)) { if (Get-VM -Name $VmName -ErrorAction SilentlyContinue) { throw "同名虚拟机已存在,拒绝覆盖。" } -if ((Get-PSDrive D).Free -lt 40GB) { throw "D 盘剩余不足 40GB,暂不创建测试环境。" } +if ((Get-PSDrive E).Free -lt 40GB) { throw "E 盘剩余不足 40GB,暂不创建测试环境。" } New-Item -ItemType Directory -Path $rootPath -Force | Out-Null $disk = Join-Path $rootPath "sqlserver2008r2.vhdx" if (Test-Path -LiteralPath $disk) { throw "测试磁盘已存在,拒绝覆盖。" } diff --git a/scripts/windows/Install-PayrollTasks.ps1 b/scripts/windows/Install-PayrollTasks.ps1 new file mode 100644 index 0000000..0ce1e83 --- /dev/null +++ b/scripts/windows/Install-PayrollTasks.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)][string]$ProjectRoot, + [Parameter(Mandatory = $true)][string]$PythonPath, + [Parameter(Mandatory = $true)][string]$RuntimeDirectory, + [Parameter(Mandatory = $true)][string]$ConnectionFile, + [string]$TaskPath = '\PayrollManagementSystem\', + [string]$ServiceAccount, + [int]$ApiPort = 8000, + [switch]$Apply, + [switch]$TestMode +) +. (Join-Path $PSScriptRoot 'TaskHost.Common.ps1') +$plan = Get-PayrollTaskPlan -ProjectRoot $ProjectRoot -PythonPath $PythonPath -RuntimeDirectory $RuntimeDirectory ` + -ConnectionFile $ConnectionFile -TaskPath $TaskPath -ApiPort $ApiPort +if ($TestMode -and $TaskPath -notmatch '-Test-[0-9a-f]{8}\\$') { throw '测试模式必须使用独立测试任务目录。' } +if (-not $Apply -or -not $PSCmdlet.ShouldProcess($TaskPath, '注册默认禁用的三个项目托管任务')) { + $plan | ConvertTo-Json -Depth 5 + return +} +foreach ($task in $plan.Tasks) { + if (Get-ScheduledTask -TaskPath $TaskPath -TaskName $task.Name -ErrorAction SilentlyContinue) { throw '任务已存在,拒绝覆盖;请先核对当前安装。' } +} +if ($TestMode) { + $account = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited + $credential = $null +} else { + Assert-TaskValue $ServiceAccount + if ($ServiceAccount -match '^(SYSTEM|LocalSystem|NT AUTHORITY\\SYSTEM)$') { throw '请使用已有受限服务账号,不使用SYSTEM。' } + $credential = Get-Credential -UserName $ServiceAccount -Message '请输入已有受限服务账号的凭据(不会写入命令行或清单)' + if ($null -eq $credential) { throw '未提供服务账号凭据,未注册任务。' } + $account = $credential.UserName + $principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Password -RunLevel Limited +} +$serviceSid = Resolve-PayrollAccountSid $account +if ($serviceSid -eq 'S-1-5-18') { throw '请使用已有受限服务账号,不使用SYSTEM。' } +$plan | Add-Member -NotePropertyName ServiceSid -NotePropertyValue $serviceSid +$plan | Add-Member -NotePropertyName LogonType -NotePropertyValue ([string]$principal.LogonType) +Assert-PayrollHostAccess -Plan $plan -ServiceSid $serviceSid +if (Test-Path -LiteralPath (Join-Path $plan.RuntimeDirectory 'task-manifest.json')) { throw '运行目录已有安装清单,拒绝覆盖。' } +'任务已安装但未启用' | Set-Content -LiteralPath $plan.StopFile -Encoding UTF8 +$registered = @() +try { + foreach ($task in $plan.Tasks) { + $action = New-ScheduledTaskAction -Execute $task.Execute -Argument $task.Arguments -WorkingDirectory $task.WorkingDirectory + $triggers = @(New-ScheduledTaskTrigger -AtStartup) + if ($task.Role -eq 'maintenance') { + $triggers += New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) + } + $definition = New-ScheduledTask -Action $action -Trigger $triggers -Settings (Get-PayrollTaskSettings) -Principal $principal + if ($TestMode) { + Register-ScheduledTask -TaskPath $TaskPath -TaskName $task.Name -InputObject $definition | Out-Null + } else { + $secretPassword = $credential.GetNetworkCredential().Password + try { Register-ScheduledTask -TaskPath $TaskPath -TaskName $task.Name -InputObject $definition -User $account -Password $secretPassword | Out-Null } + finally { $secretPassword = $null } + } + $registered += $task.Name + } + $plan | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $plan.RuntimeDirectory 'task-manifest.json') -Encoding UTF8 + Write-Output '三个任务已注册且默认禁用;检查配置后显式执行Start。自动采集开关仍由应用配置控制。' +} catch { + Write-Error ('安装未完成,已注册的任务保持禁用:' + ($registered -join ', ') + '。请核对项目任务目录。') + throw +} diff --git a/scripts/windows/Manage-PayrollTasks.ps1 b/scripts/windows/Manage-PayrollTasks.ps1 new file mode 100644 index 0000000..aecb30f --- /dev/null +++ b/scripts/windows/Manage-PayrollTasks.ps1 @@ -0,0 +1,63 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)][ValidateSet('Check', 'Start', 'Stop')][string]$Action, + [Parameter(Mandatory = $true)][string]$RuntimeDirectory, + [switch]$Apply, + [switch]$Force, + [ValidateRange(0, 600)][int]$GraceSeconds = 30 +) +. (Join-Path $PSScriptRoot 'TaskHost.Common.ps1') +$runtime = [IO.Path]::GetFullPath($RuntimeDirectory) +$manifest = Join-Path $runtime 'task-manifest.json' +if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { throw '未找到项目安装清单,拒绝管理其他任务。' } +$saved = Get-Content -LiteralPath $manifest -Raw | ConvertFrom-Json +if (-not $saved.PSObject.Properties['ServiceSid'] -or -not $saved.PSObject.Properties['LogonType']) { + throw '安装清单缺少账号核验信息,请管理员核对后重新准备安装。' +} +$plan = Get-PayrollTaskPlan -ProjectRoot $saved.ProjectRoot -PythonPath $saved.PythonPath -RuntimeDirectory $runtime ` + -ConnectionFile $saved.ConnectionFile -TaskPath $saved.TaskPath -ApiPort $saved.ApiPort +$tasks = foreach ($expected in $plan.Tasks) { + $actual = Get-ScheduledTask -TaskPath $plan.TaskPath -TaskName $expected.Name -ErrorAction Stop + Assert-PayrollTaskPrincipal -Actual $actual.Principal -Expected $saved + if (@($actual.Actions).Count -ne 1 -or $actual.Actions[0].Execute -ne $expected.Execute -or + $actual.Actions[0].Arguments -ne $expected.Arguments -or $actual.Actions[0].WorkingDirectory -ne $expected.WorkingDirectory) { + throw '任务动作与项目清单不一致,拒绝启动或停止。' + } + $info = Get-ScheduledTaskInfo -TaskPath $plan.TaskPath -TaskName $expected.Name + [pscustomobject]@{ Name = $expected.Name; State = [string]$actual.State; + LastTaskResult = $info.LastTaskResult; LastRunTime = $info.LastRunTime; NextRunTime = $info.NextRunTime; + SettingsValid = ($actual.Settings.MultipleInstances -eq 'IgnoreNew' -and $actual.Settings.RestartCount -eq 3 -and + $actual.Settings.RestartInterval -eq 'PT1M' -and $actual.Settings.ExecutionTimeLimit -eq 'PT0S') } +} +if ($Action -eq 'Check') { + [pscustomobject]@{ TaskPath = $plan.TaskPath; StopRequested = (Test-Path -LiteralPath $plan.StopFile); Tasks = @($tasks) } | ConvertTo-Json -Depth 4 + if (@($tasks | Where-Object { -not $_.SettingsValid }).Count) { exit 1 } + exit 0 +} +if (-not $Apply -or -not $PSCmdlet.ShouldProcess($plan.TaskPath, $Action)) { + [pscustomobject]@{ Preview = $true; Action = $Action; TaskPath = $plan.TaskPath; Names = @($tasks.Name) } | ConvertTo-Json + return +} +if (@($tasks | Where-Object { -not $_.SettingsValid }).Count) { throw '任务恢复设置不符合交付约定,拒绝改变运行状态。' } +if ($Action -eq 'Start') { + Assert-PayrollHostAccess -Plan $plan -ServiceSid $saved.ServiceSid + if (Test-Path -LiteralPath $plan.StopFile) { Remove-Item -LiteralPath $plan.StopFile } + foreach ($task in $tasks) { Enable-ScheduledTask -TaskPath $plan.TaskPath -TaskName $task.Name | Out-Null; Start-ScheduledTask -TaskPath $plan.TaskPath -TaskName $task.Name } + Write-Output '托管任务已启动;是否自动排队仍由T_PLUS_AUTOMATION_ENABLED决定。' +} else { + '运维请求停止领取任务' | Set-Content -LiteralPath $plan.StopFile -Encoding UTF8 + foreach ($task in $tasks) { Disable-ScheduledTask -TaskPath $plan.TaskPath -TaskName $task.Name | Out-Null } + $deadline = (Get-Date).AddSeconds($GraceSeconds) + do { + $running = @($tasks | Where-Object { (Get-ScheduledTask -TaskPath $plan.TaskPath -TaskName $_.Name).State -eq 'Running' }) + if (-not $running.Count) { break } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + if ($running.Count -and $Force) { + foreach ($task in $running) { Stop-ScheduledTask -TaskPath $plan.TaskPath -TaskName $task.Name } + Write-Output '已强制停止明确的项目任务;未结束的采集需在重启后人工核对中断记录。' + } elseif ($running.Count) { + Write-Output '已停止领取新任务,现有长采集尚在退出;未强制终止,可稍后Check。' + exit 2 + } else { Write-Output '项目托管任务已停止。' } +} diff --git a/scripts/windows/README.md b/scripts/windows/README.md new file mode 100644 index 0000000..a2c86d9 --- /dev/null +++ b/scripts/windows/README.md @@ -0,0 +1,77 @@ +# Windows 任务计划托管 + +本包托管后端、T+ worker 和每分钟周期检查。首次安装生成三个禁用任务并保留停止标记;自动周采集另外由应用配置启用。采集不会自动导入正式业务、核算或生成工资。 + +## 配置及账号准备 + +使用目标服务器已有受限服务账号,配置“作为批处理作业登录”、MySQL/Redis 连接和项目外的 T+ 只读连接文件。生产不能使用 SYSTEM。管理员须预先准备运行目录及文件权限: + +- 部署目录、Python 环境和全部程序文件,只允许管理员及指定服务账号维护,不允许普通 Users、Everyone、Authenticated Users 写入。 +- 只读连接文件和 `backend/.env`,仅限管理员、SYSTEM 与服务账号读取;连接文件不放进仓库。 +- 运行目录供服务账号写入日志、安装清单和停止标记,拒绝其他普通用户写入。上层目录也不能给普通用户删除/替换这些目录的权限。 +- 脚本安装前和 Start 前检查关键入口、文件所有者和上层目录 ACL;不会自动改变 ACL。管理员仍需确保部署树全部内容和 Python 依赖都受相同保护。 +- 安装清单保存服务账号 SID 和登录方式,Check/Start/Stop 均核验实际任务账号及 Limited 权限,发现漂移则拒绝操作。 + +应用需先完成经授权的迁移;当前 head 为 `21f2a3b4c5d6`(V20 调度状态、V21 状态默认值修正)。本次仅在隔离库验证,未迁移客户业务库。 + +`backend/.env` 中的非敏感开关: + +```dotenv +T_PLUS_AUTOMATION_ENABLED=false +T_PLUS_SCHEDULE_WEEKDAY=0 +T_PLUS_SCHEDULE_TIME=02:00 +``` + +weekday 的 0~6 分别为周一至周日,时间固定按 `Asia/Shanghai` 解释。上线前保持自动化开关为 false。修改后重启托管进程,使后端与检查命令使用同一配置。 + +## 预检查和安装 + +在 Windows PowerShell 5 中执行,使用实际部署路径。以下为示例路径,不含真实凭据。 + +```powershell +$project = 'D:\Payroll App' +$python = 'D:\Payroll App\backend\.venv\Scripts\python.exe' +$runtime = 'D:\Payroll Runtime' +$reader = 'D:\Payroll Secrets\reader.local.json' + +# 只读预演:输出路径、动作、触发和恢复策略,不注册任务 +& "$project\scripts\windows\Install-PayrollTasks.ps1" ` + -ProjectRoot $project -PythonPath $python -RuntimeDirectory $runtime -ConnectionFile $reader + +# 检查脚本语法、任务策略、引号、ACL规则及账号核验;不注册任务 +& "$project\scripts\windows\Test-PayrollTaskPlan.ps1" ` + -ProjectRoot $project -PythonPath $python -RuntimeDirectory $runtime -ConnectionFile $reader +``` + +客户环境安装需另行确认。确认后管理员才执行 `Install-PayrollTasks.ps1` 的同组参数,并增加 `-ServiceAccount '域或主机\已有服务账号' -Apply`。凭据通过交互框输入,不写入命令行、清单或日志。目录须提前准备且 ACL 合格,否则拒绝注册;已有同名任务/安装清单不会覆盖。安装过程部分失败时,已创建的任务仍保持禁用,按提示核对专用任务目录后处理。 + +测试模式只允许 `\PayrollManagementSystem-Test-xxxxxxxx\`(8 位十六进制)的独立目录及当前用户 Interactive/Limited 身份,同样要求显式 `-Apply` 和合格 ACL。不要用测试模式替代客户服务账号验收。 + +## 检查、启动、停用 + +```powershell +# 检查状态、最近退出码、下次执行及任务身份/动作/恢复策略 +& "$project\scripts\windows\Manage-PayrollTasks.ps1" -Action Check -RuntimeDirectory $runtime + +# 预演启动或停用;增加 -Apply 才改变任务状态 +& "$project\scripts\windows\Manage-PayrollTasks.ps1" -Action Start -RuntimeDirectory $runtime +& "$project\scripts\windows\Manage-PayrollTasks.ps1" -Action Stop -RuntimeDirectory $runtime +``` + +Start 的 `-Apply` 删除项目停止标记,启用并启动三项任务。Stop 的 `-Apply` 先写停止标记并禁用任务;worker 完成当前工作后退出,不再领取下一项。默认等待 30 秒,长查询未结束则返回 2,稍后 Check。仅在核实中断影响后,才使用 `-Force -Apply` 强制停止明确的项目任务;重启后遗留 RUNNING 批次标记中断,原数据及失败批次保留,由制作人核对后重试。 + +每项任务使用 IgnoreNew,异常退出间隔 1 分钟重启,最多 3 次;启动器保留子进程退出码。后端及 worker 开机触发,维护命令开机及每分钟触发,长任务无强制执行时限。启动器子进程隐藏运行,后端绑定 `127.0.0.1`,对外访问应沿用现有网关。 + +## 调度和异常处理 + +`tplus-maintain --once` 只排队最近已到期的一周,同周期幂等;停机后补一次,不逐周补积压。手动任务/正式导入工作区正在运行时延后排队。人工重试结果接入该周期,不会在人工恢复成功后再次重读原失败批次。 + +仅定时采集且错误均为明确断连或超时,才在终止后 5 分钟、15 分钟重试,最多两次。中途断连保留已暂存页并标记失败,重新全量读取由哈希去重。行级错误/部分失败、配置错误、字段错误、进程中断和正式导入不自动重试。原批次、重试关系与错误记录一直保留。 + +T+ 同步页显示自动化开关、心跳、最近成功和下次重试,以及失败/部分失败、排队超过 15 分钟、超过 8 天无成功、心跳中断提示。独立心跳每 30 秒写入,2 分钟无更新提示离线。日志按次写入 `runtime\logs`,不自动删除;不输出驱动连接文本。外部邮件及企业消息未启用。 + +## 验证范围 + +已验证 Windows PowerShell 5 的计划参数、单实例/重启设置、含空格路径、隐藏子进程、停止标记和退出码传递;测试未注册计划任务或连接业务数据库。应用侧已完成隔离库周期竞争、人工/自动重试竞争、上限及独立长查询心跳验证。 + +客户服务器实际注册、服务账号权限、开机触发、每分钟三次恢复和停止后不再启动,仍需在专用目标环境演练并留存 Check 输出。SQL Server 2008 R2 实机、备份全量/增量恢复链及生产启用分别验收,不由本机测试替代。 diff --git a/scripts/windows/Run-PayrollHost.ps1 b/scripts/windows/Run-PayrollHost.ps1 new file mode 100644 index 0000000..c149044 --- /dev/null +++ b/scripts/windows/Run-PayrollHost.ps1 @@ -0,0 +1,35 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('backend', 'worker', 'maintenance')][string]$Role, + [Parameter(Mandatory = $true)][string]$ProjectRoot, + [Parameter(Mandatory = $true)][string]$PythonPath, + [Parameter(Mandatory = $true)][string]$RuntimeDirectory, + [Parameter(Mandatory = $true)][string]$ConnectionFile, + [int]$ApiPort = 8000 +) +. (Join-Path $PSScriptRoot 'TaskHost.Common.ps1') +$plan = Get-PayrollTaskPlan -ProjectRoot $ProjectRoot -PythonPath $PythonPath -RuntimeDirectory $RuntimeDirectory ` + -ConnectionFile $ConnectionFile -TaskPath '\PayrollManagementSystem\' -ApiPort $ApiPort +if (Test-Path -LiteralPath $plan.StopFile) { exit 0 } +$env:T_PLUS_STOP_FILE = $plan.StopFile +$env:T_PLUS_ENABLED = 'true' +$env:T_PLUS_CONNECTION_FILE = $plan.ConnectionFile +$logs = Join-Path $plan.RuntimeDirectory 'logs' +New-Item -ItemType Directory -Path $logs -Force | Out-Null +$stamp = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8) +$arguments = switch ($Role) { + 'backend' { '-m uvicorn app.main:app --host 127.0.0.1 --port ' + $ApiPort } + 'worker' { '-m app.cli tplus-worker --watch --connection-file ' + (Quote-TaskArgument $plan.ConnectionFile) + ' --stop-file ' + (Quote-TaskArgument $plan.StopFile) } + 'maintenance' { '-m app.cli tplus-maintain --once --connection-file ' + (Quote-TaskArgument $plan.ConnectionFile) + ' --stop-file ' + (Quote-TaskArgument $plan.StopFile) } +} +$process = Start-Process -FilePath $plan.PythonPath -ArgumentList $arguments -WorkingDirectory (Join-Path $plan.ProjectRoot 'backend') ` + -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $logs "$Role-$stamp.out.log") ` + -RedirectStandardError (Join-Path $logs "$Role-$stamp.err.log") +$processHandle = $process.Handle +while (-not $process.HasExited) { + if ($Role -eq 'backend' -and (Test-Path -LiteralPath $plan.StopFile)) { Stop-Process -Id $process.Id -ErrorAction Stop } + Start-Sleep -Seconds 1 + $process.Refresh() +} +$process.WaitForExit() +exit $process.ExitCode diff --git a/scripts/windows/TaskHost.Common.ps1 b/scripts/windows/TaskHost.Common.ps1 new file mode 100644 index 0000000..2e367b5 --- /dev/null +++ b/scripts/windows/TaskHost.Common.ps1 @@ -0,0 +1,119 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-TaskValue([string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value) -or $Value.IndexOfAny([char[]]@('"', "`r", "`n")) -ge 0) { + throw '任务参数不能为空或包含双引号、换行。' + } +} + +function Quote-TaskArgument([string]$Value) { + Assert-TaskValue $Value + # Windows路径不能包含双引号;尾反斜线加倍,避免转义原生命令行的闭引号。 + return '"' + ($Value -replace '(\\+)$', '$1$1') + '"' +} + +function Get-PayrollTaskPlan { + param([string]$ProjectRoot, [string]$PythonPath, [string]$RuntimeDirectory, + [string]$ConnectionFile, [string]$TaskPath, [int]$ApiPort = 8000) + foreach ($value in @($ProjectRoot, $PythonPath, $RuntimeDirectory, $ConnectionFile)) { Assert-TaskValue $value } + if ($TaskPath -notmatch '^\\PayrollManagementSystem(?:-Test-[0-9a-f]{8})?\\$') { throw '任务目录必须为项目专用目录。' } + if ($ApiPort -lt 1024 -or $ApiPort -gt 65535) { throw '应用端口必须在1024到65535之间。' } + $root = (Resolve-Path -LiteralPath $ProjectRoot).Path + $python = (Resolve-Path -LiteralPath $PythonPath).Path + $connection = (Resolve-Path -LiteralPath $ConnectionFile).Path + if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/app/cli.py') -PathType Leaf)) { throw '项目根目录缺少后端CLI。' } + if ((Get-Item -LiteralPath $connection).Length -gt 16384) { throw '只读连接文件异常。' } + $runtime = [IO.Path]::GetFullPath($RuntimeDirectory) + $launcher = Join-Path $PSScriptRoot 'Run-PayrollHost.ps1' + $shell = Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe' + $tasks = foreach ($role in @('backend', 'worker', 'maintenance')) { + $arguments = '-NoProfile -NonInteractive -File ' + (Quote-TaskArgument $launcher) + + ' -Role ' + $role + ' -ProjectRoot ' + (Quote-TaskArgument $root) + + ' -PythonPath ' + (Quote-TaskArgument $python) + ' -RuntimeDirectory ' + (Quote-TaskArgument $runtime) + + ' -ConnectionFile ' + (Quote-TaskArgument $connection) + ' -ApiPort ' + $ApiPort + [pscustomobject]@{ Name = 'Payroll-' + $role; Role = $role; Execute = $shell; + Arguments = $arguments; WorkingDirectory = (Join-Path $root 'backend'); Trigger = $(if ($role -eq 'maintenance') { 'StartupAndEveryMinute' } else { 'Startup' }) } + } + return [pscustomobject]@{ Version = 1; ProjectRoot = $root; PythonPath = $python; RuntimeDirectory = $runtime; + ConnectionFile = $connection; TaskPath = $TaskPath; StopFile = (Join-Path $runtime 'stop.requested'); + ApiPort = $ApiPort; Tasks = @($tasks); EnabledOnInstall = $false; + MultipleInstances = 'IgnoreNew'; RestartCount = 3; RestartInterval = 'PT1M'; ExecutionTimeLimit = 'PT0S' } +} + +function Get-PayrollTaskSettings { + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable + $settings.Enabled = $false + return $settings +} + +function Resolve-PayrollAccountSid([string]$Account) { + if ($Account -match '^S-1-\d+(?:-\d+)+$') { return ([Security.Principal.SecurityIdentifier]$Account).Value } + return ([Security.Principal.NTAccount]$Account).Translate([Security.Principal.SecurityIdentifier]).Value +} + +function Assert-PayrollAccessRules { + param($Rules, [string[]]$TrustedSids, [bool]$PrivateFile = $false, [switch]$Ancestor, [string]$Label) + $writeMask = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if ($Ancestor) { + # 上层目录允许创建其他名称;已有项目可被删除/替换或改ACL才构成越界。 + $writeMask = [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + } + foreach ($rule in $Rules) { + if ($rule.AccessControlType -ne 'Allow' -or + ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly)) { continue } + $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($TrustedSids -contains $sid) { continue } + if (($rule.FileSystemRights -band $writeMask) -or + ($PrivateFile -and ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::ReadData))) { + throw ('权限预检查不通过:' + $Label + '。存在非服务账号/管理员主体的写权限或连接文件读取权限,请管理员按部署说明核对ACL。') + } + } +} + +function Assert-PayrollHostAccess { + param($Plan, [string]$ServiceSid) + $trusted = @('S-1-5-18', 'S-1-5-32-544', $ServiceSid) + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + # 独立的管理员账号可以部署;普通安装者不因运行本脚本自动成为可信主体。 + if (@($identity.Groups | Where-Object { $_.Value -eq 'S-1-5-32-544' }).Count) { $trusted += $identity.User.Value } + $targets = @($Plan.ProjectRoot, (Join-Path $Plan.ProjectRoot 'backend'), + (Join-Path $Plan.ProjectRoot 'backend/app'), (Join-Path $Plan.ProjectRoot 'backend/app/cli.py'), + $PSScriptRoot, (Join-Path $PSScriptRoot 'Run-PayrollHost.ps1'), + (Join-Path $PSScriptRoot 'TaskHost.Common.ps1'), $Plan.PythonPath, + (Split-Path -Parent $Plan.PythonPath), $Plan.RuntimeDirectory, $Plan.ConnectionFile) + $environmentFile = Join-Path $Plan.ProjectRoot 'backend/.env' + if (Test-Path -LiteralPath $environmentFile) { $targets += $environmentFile } + foreach ($target in @($targets | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $target)) { throw ('权限预检查要求目录已由管理员准备:' + $target) } + $acl = Get-Acl -LiteralPath $target + Assert-PayrollAccessRules -Rules $acl.Access -TrustedSids $trusted ` + -PrivateFile ($target -eq $Plan.ConnectionFile -or $target -eq $environmentFile) -Label $target + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + if ($trusted -notcontains $ownerSid) { throw ('权限预检查不通过,所有者不是可信部署主体:' + $target) } + # 防止通过父目录的替换权限绕过文件本身ACL。逐级检查至磁盘根。 + $parent = Split-Path -Parent $target + while ($parent) { + Assert-PayrollAccessRules -Rules (Get-Acl -LiteralPath $parent).Access -TrustedSids $trusted -Ancestor -Label $parent + $next = Split-Path -Parent $parent + if ($next -eq $parent) { break } + $parent = $next + } + } +} + +function Assert-PayrollTaskPrincipal { + param($Actual, $Expected) + if ((Resolve-PayrollAccountSid $Actual.UserId) -ne $Expected.ServiceSid -or + [string]$Actual.LogonType -ne $Expected.LogonType -or [string]$Actual.RunLevel -ne 'Limited') { + throw '任务运行账号或登录/提权方式与安装清单不一致,拒绝管理。' + } +} diff --git a/scripts/windows/Test-PayrollHostRuntime.ps1 b/scripts/windows/Test-PayrollHostRuntime.ps1 new file mode 100644 index 0000000..fab499e --- /dev/null +++ b/scripts/windows/Test-PayrollHostRuntime.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$PythonPath, [Parameter(Mandatory=$true)][string]$TestRoot) +. (Join-Path $PSScriptRoot 'TaskHost.Common.ps1') +if (Test-Path -LiteralPath $TestRoot) { throw '请选择新的隔离测试目录,避免覆盖现有文件。' } +$root = [IO.Path]::GetFullPath($TestRoot) +$app = Join-Path $root 'backend/app' +New-Item -ItemType Directory -Path $app -Force | Out-Null +'' | Set-Content -LiteralPath (Join-Path $app '__init__.py') -Encoding UTF8 +@' +import os,sys,time +from pathlib import Path +if 'tplus-maintain' in sys.argv: + raise SystemExit(7) +stop=Path(os.environ['T_PLUS_STOP_FILE']) +stop.parent.mkdir(parents=True,exist_ok=True) +(stop.parent/'worker.started').write_text('ready',encoding='utf-8') +while not stop.exists(): + time.sleep(0.05) +(stop.parent/'worker.stopped').write_text('stopped',encoding='utf-8') +'@ | Set-Content -LiteralPath (Join-Path $app 'cli.py') -Encoding UTF8 +$connection = Join-Path $root 'dummy reader.json' +'{}' | Set-Content -LiteralPath $connection -Encoding UTF8 +$runtime = Join-Path $root 'runtime with spaces' +$shell = Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe' +$launcher = Join-Path $PSScriptRoot 'Run-PayrollHost.ps1' +function Start-TestHost([string]$Role) { + $argsText = '-NoProfile -NonInteractive -File ' + (Quote-TaskArgument $launcher) + ' -Role ' + $Role + + ' -ProjectRoot ' + (Quote-TaskArgument $root) + ' -PythonPath ' + (Quote-TaskArgument $PythonPath) + + ' -RuntimeDirectory ' + (Quote-TaskArgument $runtime) + ' -ConnectionFile ' + (Quote-TaskArgument $connection) + $child = Start-Process -FilePath $shell -ArgumentList $argsText -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $root "$Role-host.out.log") -RedirectStandardError (Join-Path $root "$Role-host.err.log") + $null = $child.Handle + return $child +} +$taskHostProcess = Start-TestHost 'worker' +$deadline = (Get-Date).AddSeconds(20) +while (-not (Test-Path -LiteralPath (Join-Path $runtime 'worker.started')) -and (Get-Date) -lt $deadline) { + if ($taskHostProcess.HasExited) { throw '隔离worker未能启动,请检查测试目录日志。' } + Start-Sleep -Milliseconds 100 +} +if (-not (Test-Path -LiteralPath (Join-Path $runtime 'worker.started'))) { throw '等待隔离worker启动超时。' } +'stop' | Set-Content -LiteralPath (Join-Path $runtime 'stop.requested') -Encoding UTF8 +if (-not $taskHostProcess.WaitForExit(15000)) { throw '停止标记未能结束隔离worker。' } +if ($taskHostProcess.ExitCode -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $runtime 'worker.stopped'))) { + throw '隔离worker未正常停止。' +} +Remove-Item -LiteralPath (Join-Path $runtime 'stop.requested') +$failureProcess = Start-TestHost 'maintenance' +if (-not $failureProcess.WaitForExit(15000)) { throw '隔离失败进程未退出。' } +if ($failureProcess.ExitCode -ne 7) { throw '启动器没有保留子进程失败退出码。' } +Write-Output 'Windows PowerShell5 隔离托管验证通过:路径含空格、隐藏子进程启动、停止标记退出、失败退出码传递;未注册计划任务或连接业务数据库。' diff --git a/scripts/windows/Test-PayrollTaskPlan.ps1 b/scripts/windows/Test-PayrollTaskPlan.ps1 new file mode 100644 index 0000000..47cffaa --- /dev/null +++ b/scripts/windows/Test-PayrollTaskPlan.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$ProjectRoot, [Parameter(Mandatory=$true)][string]$PythonPath, + [Parameter(Mandatory=$true)][string]$ConnectionFile, [Parameter(Mandatory=$true)][string]$RuntimeDirectory) +. (Join-Path $PSScriptRoot 'TaskHost.Common.ps1') +foreach ($value in @('C:\', '\\server\share\', 'C:\path with spaces\', 'C:\ordinary')) { + $quoted = Quote-TaskArgument $value + $tail = [regex]::Match($value, '\\+$').Value + if ($quoted -ne ('"' + $value + $tail + '"')) { throw '尾反斜线必须保留并加倍。' } +} +$trustedSid = 'S-1-5-18' +$publicSid = 'S-1-5-32-545' +function New-TestRule([string]$Sid, [Security.AccessControl.FileSystemRights]$Rights) { + return [Security.AccessControl.FileSystemAccessRule]::new( + [Security.Principal.SecurityIdentifier]::new($Sid), $Rights, [Security.AccessControl.AccessControlType]::Allow) +} +Assert-PayrollAccessRules -Rules @(New-TestRule $trustedSid FullControl) -TrustedSids @($trustedSid) -PrivateFile $true +Assert-PayrollAccessRules -Rules @(New-TestRule $publicSid ReadAndExecute) -TrustedSids @($trustedSid) +foreach ($case in @(@{Rights='Modify'; Private=$false}, @{Rights='Read'; Private=$true})) { + $rejected = $false + try { Assert-PayrollAccessRules -Rules @(New-TestRule $publicSid $case.Rights) -TrustedSids @($trustedSid) -PrivateFile $case.Private } + catch { $rejected = $true } + if (-not $rejected) { throw '必须拒绝公共主体写程序或读取连接配置。' } +} +$account = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$expectedPrincipal = [pscustomobject]@{ServiceSid=(Resolve-PayrollAccountSid $account); LogonType='Interactive'} +Assert-PayrollTaskPrincipal -Actual ([pscustomobject]@{UserId=$account; LogonType='Interactive'; RunLevel='Limited'}) -Expected $expectedPrincipal +$rejected = $false +try { Assert-PayrollTaskPrincipal -Actual ([pscustomobject]@{UserId=$account; LogonType='Interactive'; RunLevel='Highest'}) -Expected $expectedPrincipal } +catch { $rejected = $true } +if (-not $rejected) { throw '必须拒绝运行身份提权漂移。' } +$plan = Get-PayrollTaskPlan -ProjectRoot $ProjectRoot -PythonPath $PythonPath -ConnectionFile $ConnectionFile ` + -RuntimeDirectory $RuntimeDirectory -TaskPath '\PayrollManagementSystem-Test-1234abcd\' +if ($plan.Tasks.Count -ne 3 -or $plan.EnabledOnInstall) { throw '任务数量或默认启用状态错误。' } +foreach ($task in $plan.Tasks) { + if ($task.Arguments -match '(-Command|password=|--password)') { throw '动作不得包含动态命令或密码。' } + if (-not $task.Arguments.Contains('-File "')) { throw '动作须通过明确脚本入口启动。' } + if ($task.Arguments.Contains('--reload')) { throw '托管后端不能使用开发重载。' } +} +$settings = Get-PayrollTaskSettings +if ($settings.MultipleInstances -ne 'IgnoreNew' -or $settings.RestartCount -ne 3 -or + $settings.RestartInterval -ne 'PT1M' -or $settings.ExecutionTimeLimit -ne 'PT0S' -or $settings.Enabled) { + throw '重启、单实例或长任务设置不符合约定。' +} +$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) +if ($trigger.Repetition.Interval -ne 'PT1M') { throw '周期检查应每分钟触发。' } +foreach ($file in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1') { + $tokens = $null; $errors = $null + [Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count) { throw ('脚本语法错误:' + $file.Name) } +} +Write-Output '托管计划、单实例、重启次数、无执行时限、分钟触发及脚本语法检查通过;未注册或启动任何任务。'