feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
liuwenxin 2026-11-02 09:42:53 +08:00
parent 1b074576fe
commit 108bf87225
154 changed files with 9515 additions and 645 deletions

6
.gitignore vendored
View File

@ -76,3 +76,9 @@ __MACOSX/
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库 # AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
.tmpdocx/ .tmpdocx/
# 本轮原开发环境损坏后的独立恢复运行环境。
backend/.venv-tplus/
backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/

View File

@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 营销人员绩效工资核算系统。后端 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 cd backend
uv run pytest tests/unit -q # 纯单元,无需容器,秒级 uv run pytest tests/unit -q # 纯单元,无需容器,秒级
uv run pytest -q # 全部(集成部分需容器在跑) python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
uv run ruff check . # 代码检查 uv run ruff check . # 代码检查
uv run ruff format . # 代码格式化 uv run ruff format . # 代码格式化
@ -134,3 +134,13 @@ python e2e/system_management.py # 系统管理 13 项
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 | | `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 | | `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
| `docs/` | 需求文档与工资算法方案 | | `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`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。

View File

@ -46,3 +46,17 @@ T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120 T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/ # 可选:留空时使用仓库根目录下的 db/queries/tplus/
T_PLUS_QUERY_DIR= 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=

View File

@ -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}")

View File

@ -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')

View File

@ -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")

View File

@ -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")

View File

@ -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'''")

View File

@ -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)

View File

@ -119,6 +119,26 @@ AdminUser = Annotated[User, Depends(require_admin)]
LoginUser = Annotated[User, Depends(get_current_user)] 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 = "无权访问审批管理"): def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。 """要求当前用户具备任一指定角色(依赖工厂)。

View File

@ -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.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router from app.api.v1.calc import router as calc_router
from app.api.v1.dashboard import router as dashboard_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.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals from app.api.v1.system import approvals as system_approvals
@ -69,6 +70,7 @@ v1_router.include_router(business_router)
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤 # 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(calc_router) v1_router.include_router(calc_router)
v1_router.include_router(payroll_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤 # 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router) v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导 # 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导

View File

@ -5,11 +5,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token 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.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.repositories.user_repository import get_role_codes from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service from app.services import auth_service, captcha_service
from app.services.payroll_access import can_write_payroll
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
@ -71,6 +73,8 @@ def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[
username=current_user.username, username=current_user.username,
real_name=current_user.real_name, real_name=current_user.real_name,
roles=get_role_codes(current_user), 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, last_login_time=current_user.last_login_time,
) )
) )

View File

@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User from app.models import User
from app.schemas.customer import ( from app.schemas.customer import (
@ -46,7 +46,7 @@ def list_customers(
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户") @router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer( def create_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: CustomerCreateIn, payload: CustomerCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]: ) -> ApiResponse[CustomerOut]:
@ -70,7 +70,7 @@ def get_customer(
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户") @router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer( def update_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn, payload: CustomerUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -91,7 +91,7 @@ def update_customer(
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户") @router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer( def delete_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -110,7 +110,7 @@ def delete_customer(
) )
def rate_customer( def rate_customer(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn, payload: CustomerRatingIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -135,7 +135,7 @@ def rate_customer(
) )
def change_ownership( def change_ownership(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)], customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn, payload: CustomerOwnershipIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -11,7 +11,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.personnel import ( from app.schemas.personnel import (
@ -44,7 +44,7 @@ def list_personnel(
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") @router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel( def create_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: PersonnelCreateIn, payload: PersonnelCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]: ) -> ApiResponse[PersonnelOut]:
@ -68,7 +68,7 @@ def get_personnel(
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") @router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel( def update_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn, payload: PersonnelCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -85,7 +85,7 @@ def update_personnel(
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") @router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel( def delete_personnel(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -101,7 +101,7 @@ def delete_personnel(
) )
def set_status( def set_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn, payload: PersonnelStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -116,7 +116,7 @@ def set_status(
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域") @router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions( def replace_regions(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn, payload: RegionReplaceIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -131,7 +131,7 @@ def replace_regions(
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") @router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans( def replace_plans(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)], personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn, payload: PlanReplaceIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -14,7 +14,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.product import ( from app.schemas.product import (
@ -44,7 +44,7 @@ def list_products(
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品") @router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product( def create_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ProductCreateIn, payload: ProductCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ProductOut]: ) -> ApiResponse[ProductOut]:
@ -68,7 +68,7 @@ def get_product(
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品") @router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product( def update_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn, payload: ProductUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -85,7 +85,7 @@ def update_product(
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品") @router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product( def delete_product(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -100,7 +100,7 @@ def delete_product(
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品") @router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status( def set_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)], product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn, payload: ProductStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User from app.models import User
from app.schemas.business import ( from app.schemas.business import (
@ -45,7 +45,7 @@ def list_businesses(
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务") @router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business( def create_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: BusinessCreateIn, payload: BusinessCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]: ) -> ApiResponse[BusinessOut]:
@ -69,7 +69,7 @@ def get_business(
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务") @router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business( def update_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn, payload: BusinessUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -86,7 +86,7 @@ def update_business(
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务") @router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business( def delete_business(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:
@ -98,7 +98,7 @@ def delete_business(
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转") @router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status( def change_status(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn, payload: BusinessStatusIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -115,7 +115,7 @@ def change_status(
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属") @router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership( def change_ownership(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)], business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn, payload: BusinessOwnershipIn,
meta: RequestMetaDep, meta: RequestMetaDep,

View File

@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path 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.core.response import ApiResponse, ok
from app.models import User from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
@ -36,7 +36,7 @@ def list_grades(
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级") @router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade( def create_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: RatingGradeCreateIn, payload: RatingGradeCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]: ) -> ApiResponse[RatingGradeOut]:
@ -50,7 +50,7 @@ def create_grade(
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级") @router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade( def update_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)], grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn, payload: RatingGradeUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -65,7 +65,7 @@ def update_grade(
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级") @router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade( def delete_grade(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)], grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.receipt import ( from app.schemas.receipt import (
@ -39,7 +39,7 @@ def list_receipts(
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款") @router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt( def create_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ReceiptCreateIn, payload: ReceiptCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]: ) -> ApiResponse[ReceiptOut]:
@ -63,7 +63,7 @@ def get_receipt(
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款") @router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt( def update_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn, payload: ReceiptUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -80,7 +80,7 @@ def update_receipt(
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款") @router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt( def delete_receipt(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.shipment import ( from app.schemas.shipment import (
@ -39,7 +39,7 @@ def list_shipments(
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") @router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment( def create_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
payload: ShipmentCreateIn, payload: ShipmentCreateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]: ) -> ApiResponse[ShipmentOut]:
@ -63,7 +63,7 @@ def get_shipment(
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") @router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment( def update_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)], shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn, payload: ShipmentUpdateIn,
meta: RequestMetaDep, meta: RequestMetaDep,
@ -80,7 +80,7 @@ def update_shipment(
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") @router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment( def delete_shipment(
session: DbSession, session: DbSession,
current_user: CurrentUser, current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)], shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[None]: ) -> ApiResponse[None]:

View File

@ -10,18 +10,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service 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="绩效台账") @router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
@ -50,7 +46,7 @@ def get_ledger(
def calc_receipt( def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
session: DbSession, session: DbSession,
current_user: _CalcWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]: ) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。""" """把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
@ -81,7 +77,7 @@ def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)], receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn, payload: RecalcIn,
session: DbSession, session: DbSession,
current_user: _RecalcUser, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]: ) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。""" """重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
@ -97,7 +93,7 @@ def recalc_receipt(
def batch_calc( def batch_calc(
payload: BatchIn, payload: BatchIn,
session: DbSession, session: DbSession,
current_user: _CalcWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[list[dict]]: ) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。""" """批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""

View File

@ -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))

View File

@ -8,10 +8,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import ( from app.schemas.settlement import (
AdjustmentCreateIn, AdjustmentCreateIn,
SettlementDetailOut, SettlementDetailOut,
@ -21,16 +19,15 @@ from app.schemas.settlement import (
) )
from app.services import settlement_service 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="生成月结快照") @router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate( def generate(
payload: SettlementGenerateIn, payload: SettlementGenerateIn,
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]: ) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。""" """按确认回款口径生成指定月份的结算快照(草稿)。"""
@ -66,7 +63,7 @@ def get_settlement(
def submit( def submit(
settlement_id: Annotated[int, Path(ge=1)], settlement_id: Annotated[int, Path(ge=1)],
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]: ) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。""" """提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
@ -83,7 +80,7 @@ def submit(
def create_adjustment( def create_adjustment(
payload: AdjustmentCreateIn, payload: AdjustmentCreateIn,
session: DbSession, session: DbSession,
current_user: _SettlementWriter, current_user: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[dict]: ) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。""" """新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""

View File

@ -4,7 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query 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.core.response import ApiResponse, PageResult, ok
from app.schemas.tplus import ( from app.schemas.tplus import (
TPlusBatchDetailOut, TPlusBatchDetailOut,
@ -19,23 +20,24 @@ from app.schemas.tplus import (
from app.services.tplus import query_service, task_service from app.services.tplus import query_service, task_service
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"]) 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_-]+$")] BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut]) @router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
def overview(session: DbSession, _: AdminUser): def overview(session: DbSession, _: WorkbenchReader):
"""查询配置状态、任务统计和最近核验。""" """查询配置状态、任务统计和最近核验。"""
return ok(query_service.overview(session)) return ok(query_service.overview(session))
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]]) @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)) return ok(query_service.list_batches(session, query))
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut]) @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)) 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]]) @router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
def errors( def errors(
session: DbSession, session: DbSession,
_: AdminUser, _: WorkbenchReader,
batch_no: BatchNumber, batch_no: BatchNumber,
query: Annotated[TPlusErrorQuery, Query()], query: Annotated[TPlusErrorQuery, Query()],
): ):
@ -52,7 +54,7 @@ def errors(
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut]) @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)) batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
return ok(query_service.get_batch(session, batch.batch_no)) 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]) @router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
def retry( def retry(
session: DbSession, session: DbSession,
admin: AdminUser, admin: PayrollWriter,
meta: RequestMetaDep, meta: RequestMetaDep,
batch_no: BatchNumber, batch_no: BatchNumber,
payload: TPlusRetryIn, payload: TPlusRetryIn,

View File

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

View File

@ -10,9 +10,13 @@
import argparse import argparse
import getpass import getpass
import json
import signal
import sys import sys
import time import threading
from datetime import datetime from datetime import datetime
from pathlib import Path
from uuid import uuid4
from sqlalchemy import select from sqlalchemy import select
@ -115,24 +119,79 @@ def _tplus_worker(args: argparse.Namespace) -> int:
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr) print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
return 1 return 1
factory = get_session_factory() 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: try:
while True: while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
with factory() as session: 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 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 status = batch.status if batch else None
if batch_no: if batch_no:
print(f"任务完成:{batch_no} 状态={status}", flush=True) print(f"任务完成:{batch_no} 状态={status}", flush=True)
if not args.watch: if not args.watch:
return 0 if status in (None, "SUCCEEDED") else 1 return 0 if status in (None, "SUCCEEDED") else 1
if batch_no is None or status != "SUCCEEDED": 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: except KeyboardInterrupt:
print("T+ 读取服务已停止") print("T+ 读取服务已停止")
return 130 return 130
except Exception as exc: except Exception as exc:
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr) print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
return 1 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: def main() -> int:
@ -182,6 +241,17 @@ def main() -> int:
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务") modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON") 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("--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() args = parser.parse_args()
@ -190,7 +260,14 @@ def main() -> int:
return _create_admin(args) return _create_admin(args)
if args.command == "tplus-worker": if args.command == "tplus-worker":
return _tplus_worker(args) return _tplus_worker(args)
if args.command == "tplus-maintain":
return _maintenance(args)
if args.command == "tplus-materials":
return _materials(args)
return _stage_tplus(args) return _stage_tplus(args)
except Exception as exc:
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
return 1
finally: finally:
dispose_engine() dispose_engine()
dispose_tplus_engine() dispose_tplus_engine()

View File

@ -36,6 +36,11 @@ class Settings(BaseSettings):
# ===== 应用 ===== # ===== 应用 =====
app_env: str = "dev" app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符") 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 小时 # 规范第 13 节:Token 有效期 ≤ 2 小时
@ -74,6 +79,10 @@ class Settings(BaseSettings):
t_plus_connect_timeout: int = 10 t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600) t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = "" 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") @field_validator("app_secret_key")
@classmethod @classmethod
@ -120,9 +129,7 @@ class Settings(BaseSettings):
query = { query = {
"driver": self.t_plus_odbc_driver, "driver": self.t_plus_odbc_driver,
"Encrypt": "yes" if self.t_plus_encrypt else "no", "Encrypt": "yes" if self.t_plus_encrypt else "no",
"TrustServerCertificate": "yes" "TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
if self.t_plus_trust_server_certificate
else "no",
"Connection Timeout": str(self.t_plus_connect_timeout), "Connection Timeout": str(self.t_plus_connect_timeout),
} }
return URL.create( return URL.create(

View File

@ -17,6 +17,7 @@ from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue 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.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.product import Product 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.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.tplus_automation import TPlusAutomationState
from app.models.tplus_sync import ( from app.models.tplus_sync import (
TPlusSyncBatch, TPlusSyncBatch,
TPlusSyncError, TPlusSyncError,
TPlusSyncRecord, TPlusSyncRecord,
TPlusSyncWatermark, TPlusSyncWatermark,
) )
from app.models.tplus_workbench import (
TPlusFact,
TPlusSourceHead,
TPlusSourceLink,
TPlusWorkLine,
TPlusWorkRun,
)
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
@ -52,6 +61,9 @@ __all__ = [
"ParamChangeBatch", "ParamChangeBatch",
"ParamDefinition", "ParamDefinition",
"ParamValue", "ParamValue",
"PayrollAllocation",
"PayrollInput",
"PayrollMonth",
"PerformanceLedger", "PerformanceLedger",
"Permission", "Permission",
"Personnel", "Personnel",
@ -66,10 +78,16 @@ __all__ = [
"Shipment", "Shipment",
"ShipmentItem", "ShipmentItem",
"SystemSetting", "SystemSetting",
"TPlusAutomationState",
"TPlusFact",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch", "TPlusSyncBatch",
"TPlusSyncError", "TPlusSyncError",
"TPlusSyncRecord", "TPlusSyncRecord",
"TPlusSyncWatermark", "TPlusSyncWatermark",
"TPlusWorkLine",
"TPlusWorkRun",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserRole", "UserRole",

View File

@ -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="单据、成本、归属、参数和逐段计算依据")

View File

@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
"receipt_id", "receipt_id",
"calc_version", "calc_version",
"personnel_id", "personnel_id",
"allocation_key",
name="uk_performance_ledgers_receipt_id_calc_version_personnel", name="uk_performance_ledgers_receipt_id_calc_version_personnel",
), ),
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"), Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
"calc_type", "calc_type",
), ),
Index("idx_performance_ledgers_personnel_id", "personnel_id"), Index("idx_performance_ledgers_personnel_id", "personnel_id"),
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
Index("idx_performance_ledgers_business_id", "business_id"), Index("idx_performance_ledgers_business_id", "business_id"),
) )
@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
server_default="1", server_default="1",
comment="核算版本(重算+1,旧行 is_active=0)", 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( is_active: Mapped[int] = mapped_column(
TINYINT_TYPE, TINYINT_TYPE,
nullable=False, nullable=False,

View File

@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
) )
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)") 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): class SettlementItem(Base):
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。""" """月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
@ -154,6 +158,9 @@ class AdjustmentOrder(Base, TimestampMixin):
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" 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( order_no: Mapped[str] = mapped_column(
String(32), String(32),
nullable=False, nullable=False,

View File

@ -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")

View File

@ -63,6 +63,8 @@ class TPlusSyncBatch(Base, TimestampMixin):
requested_by: Mapped[int | None] = mapped_column( requested_by: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键" 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( audit_result_json: Mapped[dict | None] = mapped_column(
JSON, comment="关联核验结果,只含系统字典和汇总" JSON, comment="关联核验结果,只含系统字典和汇总"
) )

View File

@ -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)

View File

@ -56,6 +56,8 @@ class UserBrief(BaseModel):
username: str username: str
real_name: str | None = None real_name: str | None = None
roles: list[str] = Field(default_factory=list, description="角色编码列表") roles: list[str] = Field(default_factory=list, description="角色编码列表")
can_write_payroll: bool = False
payroll_approval_enabled: bool = False
class LoginOut(BaseModel): class LoginOut(BaseModel):

View File

@ -250,6 +250,8 @@ class BusinessCreateIn(BaseModel):
class BusinessUpdateIn(BaseModel): class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。""" """编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True) model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID") customer_id: int = Field(ge=1, description="客户ID")

View File

@ -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)

View File

@ -62,6 +62,7 @@ class SettlementOut(BaseModel):
total_payable: Decimal | None = None total_payable: Decimal | None = None
person_count: int person_count: int
approval_order_no: str | None = None approval_order_no: str | None = None
approval_required: bool = False
submitter_name: str submitter_name: str
locked_at: datetime | datetime | None = None locked_at: datetime | datetime | None = None
locked_by: str | None = None locked_by: str | None = None
@ -89,6 +90,7 @@ class AdjustmentCreateIn(BaseModel):
description="金额(元,两位小数,恒正)", description="金额(元,两位小数,恒正)",
) )
reason: str = Field(min_length=1, max_length=255, 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") @field_validator("settlement_month")
@classmethod @classmethod

View File

@ -77,6 +77,8 @@ class TPlusBatchOut(BaseModel):
finished_at: datetime | None finished_at: datetime | None
can_retry: bool can_retry: bool
error_summary: str | None = None error_summary: str | None = None
trigger_type: str = "MANUAL"
automatic_attempt: int = 0
class TPlusBatchDetailOut(TPlusBatchOut): class TPlusBatchDetailOut(TPlusBatchOut):
@ -97,6 +99,25 @@ class TPlusErrorOut(BaseModel):
create_time: datetime 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): class TPlusOverviewOut(BaseModel):
"""配置就绪不等同于源库连通或兼容验收通过。""" """配置就绪不等同于源库连通或兼容验收通过。"""
@ -107,3 +128,4 @@ class TPlusOverviewOut(BaseModel):
compatibility_status: str = "PENDING" compatibility_status: str = "PENDING"
business_import_enabled: bool = False business_import_enabled: bool = False
latest_audit: dict | None = None latest_audit: dict | None = None
automation: TPlusAutomationOverview = Field(default_factory=TPlusAutomationOverview)

View File

@ -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)

View File

@ -93,6 +93,13 @@ def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表 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) codes = set(role_codes)
if ROLE_ADMIN in codes: if ROLE_ADMIN in codes:
return _PENDING_STATUSES 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 = ApprovalOrder(
order_no=next_no( order_no=next_no(
session, session,
@ -250,6 +263,13 @@ def act(
""" """
codes = set(user_repository.get_role_codes(operator)) codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT 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( with audit_service.audit(
session, session,
operator=operator, operator=operator,

View File

@ -11,11 +11,13 @@ from datetime import UTC, datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
from app.core.security import create_access_token, decode_token, verify_password from app.core.security import create_access_token, decode_token, verify_password
from app.repositories import login_log_repository, user_repository from app.repositories import login_log_repository, user_repository
from app.schemas.auth import LoginOut, UserBrief from app.schemas.auth import LoginOut, UserBrief
from app.services import captcha_service, login_limiter, token_blacklist from app.services import captcha_service, login_limiter, token_blacklist
from app.services.payroll_access import can_write_payroll
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -143,7 +145,9 @@ def login(
access_token=token, access_token=token,
expires_in=expires_in, expires_in=expires_in,
user=UserBrief( 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,
), ),
) )

View File

@ -299,6 +299,10 @@ def _build_allocation_rows(
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人 余额归合同负责营销人员——超 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) business = business_repository.get_by_id(session, business_id)
if business is None: if business is None:
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
@ -476,6 +480,10 @@ def update_business(
:raises NotFoundError: 业务不存在 :raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户 :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) row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None business = row[0] if row else None
with audit_service.audit( with audit_service.audit(
@ -527,6 +535,12 @@ def update_business(
payload.secondary_allocations, payload.secondary_allocations,
old_ids={a.personnel_id for a in old_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: if not payload.secondary_allocations:
business_repository.replace_allocations(session, business_id, []) business_repository.replace_allocations(session, business_id, [])
elif _plan_changed(old_allocations, new_rows): 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 snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot( if not approval_service.pending_matches_snapshot(
session, session,
@ -563,7 +579,7 @@ def update_business(
if _is_duplicate_entry(exc): if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise raise
else: elif get_settings().payroll_allow_legacy_calculation:
approval_service.withdraw_pending_orders( approval_service.withdraw_pending_orders(
session, session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,

View File

@ -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 is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算" action = "重算" if is_recalc else "核算"
with audit_service.audit( with audit_service.audit(

View File

@ -98,6 +98,11 @@ def collect_team_buckets(
""" """
managers: dict[int, dict] = {} managers: dict[int, dict] = {}
for row in ledger_rows: 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 row.customer_type == "manager_personal":
if include_manager_personal: if include_manager_personal:
add_team_performance(managers, row.personnel_id, row) 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") r.performance_amount or Decimal("0")
for r in ledger_rows 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"), Decimal("0"),
) )

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -154,6 +154,16 @@ def department_summary(session, query) -> dict:
manager_of, manager_role, person_info = _team_relations( manager_of, manager_role, person_info = _team_relations(
session, member_map, manager_ids, adjustments 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) _merge_adjustment_only_members(member_map, adjustments, person_info)
@ -167,6 +177,7 @@ def department_summary(session, query) -> dict:
"include_mp": include_mp, "include_mp": include_mp,
"share": share, "share": share,
"company_base": company_base, "company_base": company_base,
"frozen_rows": frozen_rows,
} }
teams_all = _build_teams(session, manager_ids, context) teams_all = _build_teams(session, manager_ids, context)
if query.manager_id is None: if query.manager_id is None:
@ -186,7 +197,10 @@ def department_summary(session, query) -> dict:
for team in teams_all for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id 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 unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct) 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]: def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
"""按经理枚举生成团队行(含零成员团队)。""" """按经理枚举生成团队行(含零成员团队)。"""
teams = [] teams = []
@ -243,6 +298,8 @@ def _build_team(session, manager_id: int, context: dict) -> dict | None:
context["adjustments"], context["adjustments"],
context["include_mp"], 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_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_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", 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"), "extra": Decimal("0"),
"deduct": Decimal("0"), "deduct": Decimal("0"),
"personnel_name": order.personnel_name, "personnel_name": order.personnel_name,
"by_manager": {},
"unattributed": {"extra": Decimal(0), "deduct": Decimal(0)},
}, },
) )
if order.direction == ADJUSTMENT_DIRECTION_EXTRA: if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount acc["extra"] += order.amount
else: else:
acc["deduct"] += order.amount 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 return by_person

View File

@ -82,6 +82,9 @@ def generate(
), ),
meta=meta, meta=meta,
): ):
from app.services import payroll_months
control = payroll_months.lock_month(session, month, operator)
existing = settlement_repository.get_by_month(session, month) existing = settlement_repository.get_by_month(session, month)
_guard_regeneration(existing, month) _guard_regeneration(existing, month)
_release_previous_round(session, existing, month) _release_previous_round(session, existing, month)
@ -103,6 +106,29 @@ def generate(
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结 # 出参在 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() session.flush()
detail = _detail_of(session, settlement) detail = _detail_of(session, settlement)
session.commit() session.commit()
@ -239,6 +265,13 @@ def submit(
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
) -> SettlementDetailOut: ) -> 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( with audit_service.audit(
session, session,
operator=operator, operator=operator,
@ -456,7 +489,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
if manager.business_role_id if manager.business_role_id
else None 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 # 非营销经理的上级不参与聚合 continue # 非营销经理的上级不参与聚合
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share)) aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates return aggregates
@ -509,6 +547,47 @@ def create_adjustment(
from app.services.no_generator import next_no from app.services.no_generator import next_no
amount = _adjustment_amount(payload) 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) person = session.get(Personnel, payload.personnel_id)
if person is None: if person is None:
@ -532,7 +611,16 @@ def create_adjustment(
column_name="order_no", column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT, 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_id=payload.personnel_id,
personnel_name=person.personnel_name, personnel_name=person.personnel_name,
direction=payload.direction, direction=payload.direction,
@ -544,7 +632,14 @@ def create_adjustment(
session.add(order) session.add(order)
session.flush() 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 约定) out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit() session.commit()
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"]) logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])

View File

@ -1,4 +1,4 @@
# T+ 只读同步 # T+ 读取、基础导入与核算预演
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。 > AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
@ -9,6 +9,9 @@
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。 - `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。 - `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。 - `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`。未迁移原业务库或启用生产。

View File

@ -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

View File

@ -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"

View File

@ -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"
)

View File

@ -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

View File

@ -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,
)
),
}

View File

@ -1,7 +1,7 @@
"""T+ 字段映射元数据。 """T+ 字段映射元数据。
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。 这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。 高风险口径在客户确认前保持 ``PENDING``;源读取只写 staging,基础导入另经事实核对和预览。
""" """
from dataclasses import dataclass from dataclasses import dataclass
@ -27,7 +27,7 @@ class TPlusMapping:
target_table: str target_table: str
status: MappingStatus status: MappingStatus
unresolved: tuple[str, ...] = () unresolved: tuple[str, ...] = ()
version: str = "0.2" version: str = "0.5"
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验" schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
@ -38,7 +38,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"), ("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
"personnel", "personnel",
MappingStatus.CANDIDATE, MappingStatus.CANDIDATE,
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"), ("名单缺失编号", "经理分管业务", "入职日期", "试用期"),
), ),
TPlusMapping( TPlusMapping(
"products", "products",
@ -46,7 +46,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "specification", "idunit", "idinventoryclass"), ("id", "code", "name", "specification", "idunit", "idinventoryclass"),
"products", "products",
MappingStatus.CANDIDATE, MappingStatus.CANDIDATE,
("库存数量语义", "参考价来源", "系统产品分类"), ("人工核算分类", "指导价取值时点"),
), ),
TPlusMapping( TPlusMapping(
"customers", "customers",
@ -54,7 +54,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "Contact", "representative", "disabled", "updated"), ("id", "code", "name", "Contact", "representative", "disabled", "updated"),
"customers", "customers",
MappingStatus.CANDIDATE, MappingStatus.CANDIDATE,
("客户唯一键", "改名/合并规则", "系统客户分类"), ("源客户属性为空", "客户负责人资料"),
), ),
TPlusMapping( TPlusMapping(
"businesses", "businesses",
@ -62,7 +62,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"), ("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
"businesses", "businesses",
MappingStatus.PENDING, MappingStatus.PENDING,
("成本口径", "业务负责人覆盖规则", "含税口径"), ("指导价取值时点", "业务交接依据", "平账范围"),
), ),
TPlusMapping( TPlusMapping(
"shipments", "shipments",
@ -79,7 +79,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
), ),
"shipments", "shipments",
MappingStatus.PENDING, MappingStatus.PENDING,
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"), ("退货/红冲规则", "运费实际金额", "发货归属"),
), ),
TPlusMapping( TPlusMapping(
"receipts", "receipts",
@ -96,7 +96,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
), ),
"receipts", "receipts",
MappingStatus.PENDING, MappingStatus.PENDING,
("到账日期", "收款方式", "核销状态", "回款与发货关联"), ("凭证异常例单复核", "特殊款项识别", "平账范围"),
), ),
) )
@ -107,7 +107,7 @@ DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "idSaleOrderDTO", "LatestCost"), ("id", "idSaleOrderDTO", "LatestCost"),
"business_items", "business_items",
MappingStatus.PENDING, MappingStatus.PENDING,
("成本口径", "含税口径"), ("不导入T+成本,仅保留追溯快照",),
), ),
TPlusMapping( TPlusMapping(
"shipment_items", "shipment_items",

View File

@ -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"

View File

@ -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)

View File

@ -11,6 +11,8 @@ from app.schemas.tplus import (
TPlusOverviewOut, TPlusOverviewOut,
) )
from app.services.tplus import task_service 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.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
from app.services.tplus.relationship_audit import public_report from app.services.tplus.relationship_audit import public_report
@ -22,6 +24,7 @@ ERROR_MESSAGES = {
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一", "TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
} }
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)} _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: def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
@ -51,6 +54,8 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
error_summary="任务有异常,请查看异常明细" error_summary="任务有异常,请查看异常明细"
if batch.status in ("FAILED", "PARTIAL") if batch.status in ("FAILED", "PARTIAL")
else None, 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), status_counts=repository.status_counts(session),
total_staged_records=repository.total_staged_records(session), total_staged_records=repository.total_staged_records(session),
latest_audit=public_report(latest.audit_result_json) if latest else None, latest_audit=public_report(latest.audit_result_json) if latest else None,
automation=automation_overview(session, settings=task_service.get_settings()),
) )

View File

@ -29,10 +29,22 @@ _COLUMNS = {
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"), "order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
} }
PENDING_CONFIRMATIONS = ( PENDING_CONFIRMATIONS = (
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"), (
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"), "cost_basis",
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"), "月度指导成本",
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"), "采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确",
),
(
"voucher_state",
"凭证例外核对",
"已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替",
),
(
"opening_balance",
"历史承接",
"发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供",
),
("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"),
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"), ("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
) )

View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -11,8 +11,10 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.tplus_lock import sync_lock from app.core.tplus_lock import sync_lock
from app.models import TPlusSyncBatch, TPlusSyncError 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.mapping import TPlusMapping, get_mapping
from app.services.tplus.reader import TPlusReader 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.staging import stage_rows
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running 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: if batch.status != "RUNNING" or not batch.entity_types_json:
raise ValueError("任务尚未领取或读取范围不完整") raise ValueError("任务尚未领取或读取范围不完整")
mappings = [get_mapping(entity) for entity in batch.entity_types_json] mappings = [get_mapping(entity) for entity in batch.entity_types_json]
interrupted = False
for mapping in mappings: for mapping in mappings:
try: try:
self._read_entity(batch, mapping, batch_size) self._read_entity(batch, mapping, batch_size)
except Exception as exc: except Exception as exc:
self.target_session.rollback() 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() self.target_session.commit()
if code in TRANSIENT_CODES:
interrupted = True
break
batch.status = "SUCCEEDED" batch.status = "SUCCEEDED"
if batch.failed_record_count: if batch.failed_record_count:
failed_rows = ( failed_rows = (
@ -94,7 +101,8 @@ class TPlusSyncService:
) )
or 0 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.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
batch.finished_at = datetime.now() batch.finished_at = datetime.now()
batch.watermark_to = None batch.watermark_to = None
@ -106,12 +114,20 @@ class TPlusSyncService:
source = self.reader.iter_entity(mapping.entity_type, batch_size=size) source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
for chunk in batched(source, size): for chunk in batched(source, size):
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk)) 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.source_record_count += len(chunk)
batch.staged_record_count += inserted batch.staged_record_count += inserted
batch.skipped_record_count += len(chunk) - inserted - len(errors) batch.skipped_record_count += len(chunk) - inserted - len(errors)
for row, reason in errors: for row, reason in errors:
self._record_error(batch, mapping, row, "STAGE_ERROR", reason) self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
self.target_session.commit() 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( def _record_error(
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str

View File

@ -16,6 +16,7 @@ from app.repositories import tplus_repository as repository
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
from app.services import audit_service from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.tplus.errors import MESSAGES
@dataclass(frozen=True) @dataclass(frozen=True)
@ -164,6 +165,7 @@ def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典", "TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
} }
message = messages.get(code, "任务未完成,请核对运行配置") message = messages.get(code, "任务未完成,请核对运行配置")
message = MESSAGES.get(code, message)
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
batch.failed_record_count = (batch.failed_record_count or 0) + 1 batch.failed_record_count = (batch.failed_record_count or 0) + 1
session.add( session.add(

View File

@ -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):
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""

View File

@ -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.core.tplus_lock import TPlusBusyError, sync_lock
from app.repositories import tplus_repository as repository from app.repositories import tplus_repository as repository
from app.services.tplus import task_service 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.relationship_audit import TPlusRelationshipAudit
from app.services.tplus.sync_service import TPlusSyncService 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: 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) task_service.recover_running(session)
batch = repository.next_queued(session) batch = repository.next_queued(session)
if batch is None: if batch is None:
@ -30,10 +41,12 @@ def run_once(session, source_factory=None):
def _execute(session, batch, source_factory): def _execute(session, batch, source_factory):
configured = False
try: try:
settings = resolve_tplus_settings(task_service.get_settings()) settings = resolve_tplus_settings(task_service.get_settings())
if batch.source_database and batch.source_database != settings.t_plus_database: if batch.source_database and batch.source_database != settings.t_plus_database:
raise RuntimeError("任务排队后的源账套配置已改变") raise RuntimeError("任务排队后的源账套配置已改变")
configured = True
factory = source_factory or get_tplus_session_factory() factory = source_factory or get_tplus_session_factory()
with factory() as source: with factory() as source:
if batch.sync_mode == "AUDIT": if batch.sync_mode == "AUDIT":
@ -55,7 +68,7 @@ def _execute(session, batch, source_factory):
batch, batch,
"INTERRUPTED" "INTERRUPTED"
if isinstance(exc, (KeyboardInterrupt, SystemExit)) 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)): if isinstance(exc, (KeyboardInterrupt, SystemExit)):
raise raise

View File

@ -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-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 > 系统管理模块(用户/角色/日志查询)已于 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通过。

View File

@ -19,6 +19,7 @@
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等 评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
""" """
import os
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
@ -81,7 +82,12 @@ def _purge_test_data() -> None:
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配, ``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
会把 tn... 这类无关账号一并卷入。 会把 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() factory = get_session_factory()
ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
with factory() as session: with factory() as session:
users = list( users = list(
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
@ -104,6 +110,11 @@ def _purge_test_data() -> None:
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除—— # ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面 # 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(PerformanceLedger))
session.execute(delete(SettlementItem)) session.execute(delete(SettlementItem))
session.execute(delete(MonthlySettlement)) session.execute(delete(MonthlySettlement))
@ -216,6 +227,11 @@ def _purge_test_data() -> None:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _purge_after_each_test(): 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 yield
_purge_test_data() _purge_test_data()
@ -311,9 +327,11 @@ def make_role():
@pytest.fixture @pytest.fixture
def admin(make_user, auth_headers) -> dict: def admin(make_user, auth_headers, monkeypatch) -> dict:
"""管理员账号(含请求头)。""" """管理员账号(含请求头)。"""
user = make_user([ROLE_ADMIN]) 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) user["headers"] = auth_headers(user)
return user return user
@ -357,3 +375,18 @@ def tplus_tasks(monkeypatch):
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers))) db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers))) db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
db.commit() 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

View File

@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
from tests.integration.factories import business_payload, customer 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" _BUSINESSES = "/api/v1/business/businesses"
_APPROVALS = "/api/v1/system/approvals" _APPROVALS = "/api/v1/system/approvals"

View File

@ -33,7 +33,7 @@ from tests.integration.test_approval_flow import (
_set_switch, _set_switch,
) )
pytestmark = pytest.mark.integration pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
@pytest.fixture @pytest.fixture

View File

@ -91,11 +91,11 @@ def _log_count(username: str, *, success: bool) -> int:
return int(session.execute(stmt).scalar_one()) return int(session.execute(stmt).scalar_one())
def _baseline(normal: dict) -> tuple[int, int]: def _baseline(writer: dict) -> tuple[int, int]:
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。""" """POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
return ( return (
_log_count(normal["username"], success=True), _log_count(writer["username"], success=True),
_log_count(normal["username"], success=False), _log_count(writer["username"], success=False),
) )
@ -103,12 +103,12 @@ class TestAuditRetry:
"""重试成功与持续失败两条路径的审计记录数。""" """重试成功与持续失败两条路径的审计记录数。"""
def test_首次撞号重试成功_审计恰一条真成功( def test_首次撞号重试成功_审计恰一条真成功(
self, quiet_client: TestClient, normal: dict, monkeypatch self, quiet_client: TestClient, writer: dict, monkeypatch
) -> None: ) -> None:
client = quiet_client client = quiet_client
customer_id = _customer(client, normal["headers"]) customer_id = _customer(client, writer["headers"])
first = client.post( 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 assert first.status_code == 200, first.text
existing_no = first.json()["data"]["business_no"] existing_no = first.json()["data"]["business_no"]
@ -123,25 +123,25 @@ class TestAuditRetry:
return real_generate(session) return real_generate(session)
monkeypatch.setattr(business_service, "_generate_no", flaky_generate) monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
base_ok, base_fail = _baseline(normal) base_ok, base_fail = _baseline(writer)
second = client.post( 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 second.status_code == 200, second.text
assert calls["n"] == 2, "重试应恰好再取一次号" assert calls["n"] == 2, "重试应恰好再取一次号"
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功 # 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0 # 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
assert _log_count(normal["username"], success=True) == base_ok + 1 assert _log_count(writer["username"], success=True) == base_ok + 1
assert _log_count(normal["username"], success=False) == base_fail assert _log_count(writer["username"], success=False) == base_fail
def test_持续冲突_最终失败且审计恰一条失败( def test_持续冲突_最终失败且审计恰一条失败(
self, quiet_client: TestClient, normal: dict, monkeypatch self, quiet_client: TestClient, writer: dict, monkeypatch
) -> None: ) -> None:
client = quiet_client client = quiet_client
customer_id = _customer(client, normal["headers"]) customer_id = _customer(client, writer["headers"])
first = client.post( 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 assert first.status_code == 200, first.text
existing_no = first.json()["data"]["business_no"] existing_no = first.json()["data"]["business_no"]
@ -150,10 +150,10 @@ class TestAuditRetry:
return existing_no return existing_no
monkeypatch.setattr(business_service, "_generate_no", always_conflict) monkeypatch.setattr(business_service, "_generate_no", always_conflict)
base_ok, base_fail = _baseline(normal) base_ok, base_fail = _baseline(writer)
second = client.post( second = client.post(
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] _BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
) )
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)—— # 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实 # 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
@ -161,8 +161,8 @@ class TestAuditRetry:
assert "完整性冲突" in second.json()["message"] assert "完整性冲突" in second.json()["message"]
# 修复前的症状:return _retry_create 无界递归直至 RecursionError, # 修复前的症状:return _retry_create 无界递归直至 RecursionError,
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1 assert _log_count(writer["username"], success=False) == base_fail + 1
assert _log_count(normal["username"], success=True) == base_ok assert _log_count(writer["username"], success=True) == base_ok
def _receipt_log_count(username: str, *, success: bool) -> int: def _receipt_log_count(username: str, *, success: bool) -> int:
@ -199,12 +199,12 @@ class TestReceiptAuditRetry:
""" """
def test_首次撞号重试成功_审计恰一条真成功( def test_首次撞号重试成功_审计恰一条真成功(
self, quiet_client: TestClient, normal: dict, monkeypatch self, quiet_client: TestClient, writer: dict, monkeypatch
) -> None: ) -> None:
client = quiet_client client = quiet_client
business = create_business(client, normal["headers"]) business = create_business(client, writer["headers"])
first = client.post( 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 assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"] existing_no = first.json()["data"]["receipt_batch_no"]
@ -219,24 +219,24 @@ class TestReceiptAuditRetry:
return real_generate(session) return real_generate(session)
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate) monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
base_ok = _receipt_log_count(normal["username"], success=True) base_ok = _receipt_log_count(writer["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False) base_fail = _receipt_log_count(writer["username"], success=False)
second = client.post( 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 second.status_code == 200, second.text
assert calls["n"] == 2, "重试应恰好再取一次号" assert calls["n"] == 2, "重试应恰好再取一次号"
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1 assert _receipt_log_count(writer["username"], success=True) == base_ok + 1
assert _receipt_log_count(normal["username"], success=False) == base_fail assert _receipt_log_count(writer["username"], success=False) == base_fail
def test_持续冲突_最终失败且审计恰一条失败( def test_持续冲突_最终失败且审计恰一条失败(
self, quiet_client: TestClient, normal: dict, monkeypatch self, quiet_client: TestClient, writer: dict, monkeypatch
) -> None: ) -> None:
client = quiet_client client = quiet_client
business = create_business(client, normal["headers"]) business = create_business(client, writer["headers"])
first = client.post( 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 assert first.status_code == 200, first.text
existing_no = first.json()["data"]["receipt_batch_no"] existing_no = first.json()["data"]["receipt_batch_no"]
@ -245,12 +245,12 @@ class TestReceiptAuditRetry:
return existing_no return existing_no
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict) monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
base_ok = _receipt_log_count(normal["username"], success=True) base_ok = _receipt_log_count(writer["username"], success=True)
base_fail = _receipt_log_count(normal["username"], success=False) base_fail = _receipt_log_count(writer["username"], success=False)
second = client.post( 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 second.status_code == 400, second.text
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1 assert _receipt_log_count(writer["username"], success=False) == base_fail + 1
assert _receipt_log_count(normal["username"], success=True) == base_ok assert _receipt_log_count(writer["username"], success=True) == base_ok

View File

@ -43,7 +43,7 @@ from app.models import (
from app.models.business import BUSINESS_STATUS_ACTIVE from app.models.business import BUSINESS_STATUS_ACTIVE
from app.models.customer import OWNER_TYPE_BUSINESS 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" _BUSINESSES = "/api/v1/business/businesses"
_PERSONNEL = "/api/v1/base/personnel" _PERSONNEL = "/api/v1/base/personnel"
@ -199,8 +199,8 @@ class TestCreate:
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端 """创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
传值,列表检索与 M3 取数都会错乱。""" 传值,列表检索与 M3 取数都会错乱。"""
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None: def test_创建业务_编号格式与当日序号递增(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
first = _create(client, headers, customer["id"]) first = _create(client, headers, customer["id"])
second = _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["status"] == BUSINESS_STATUS_ACTIVE
assert first["customer_name"] == customer["company_name"] assert first["customer_name"] == customer["company_name"]
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( 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 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 的 """数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入, DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
创建响应(读内存值)与复查(读库值)前后显示不一致。""" 创建响应(读内存值)与复查(读库值)前后显示不一致。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -250,43 +250,43 @@ class TestCreate:
).scalar_one() ).scalar_one()
assert db_amount == Decimal("5.17") assert db_amount == Decimal("5.17")
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
customer = _customer(client, normal["headers"]) customer = _customer(client, writer["headers"])
resp = client.post( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=_payload(customer["id"], items=[_item(quantity="0")]), json=_payload(customer["id"], items=[_item(quantity="0")]),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "数量" in resp.json()["message"], 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( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=_payload(customer["id"], items=[_item(product_category="water")]), json=_payload(customer["id"], items=[_item(product_category="water")]),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "产品类别不合法" in resp.json()["message"], 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( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=_payload(customer["id"], items=[_item(sale_price="abc")]), json=_payload(customer["id"], items=[_item(sale_price="abc")]),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "须为有效数字" in resp.json()["message"], resp.text assert "须为有效数字" in resp.json()["message"], resp.text
def test_销售额由服务层固化_入参sale_amount不生效( def test_销售额由服务层固化_入参sale_amount不生效(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由 """schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
数量×单价计算,否则前端传值直接改业务销售额。""" 数量×单价计算,否则前端传值直接改业务销售额。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00") item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
data = _create(client, headers, customer["id"], items=[item]) 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["items"][0]["sale_amount"]) == Decimal("37.02")
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02") assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
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( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=_payload(customer["id"], is_special=1), json=_payload(customer["id"], is_special=1),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "特殊业务必须填写说明" in resp.json()["message"], resp.text assert "特殊业务必须填写说明" in resp.json()["message"], resp.text
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None: def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算") data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
assert data["is_special"] == 1 assert data["is_special"] == 1
@ -317,8 +317,8 @@ class TestEdit:
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 / """编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
数据库 SUM)不一致就说明重算有旁路。""" 数据库 SUM)不一致就说明重算有旁路。"""
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None: def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -365,10 +365,10 @@ class TestEdit:
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00") assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
def test_编辑端点塞owner不生效_归属唯一走ownership( def test_编辑端点塞owner不生效_归属唯一走ownership(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。""" """归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
someone = _personnel(client, headers) someone = _personnel(client, headers)
@ -387,10 +387,10 @@ class TestEdit:
] ]
assert logs["total"] == 0, "编辑端点改归属却无留痕" assert logs["total"] == 0, "编辑端点改归属却无留痕"
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None: def test_终态业务编辑_400且明细未动(self, client: TestClient, writer: dict) -> None:
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态 """终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。""" 业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create(client, headers, customer["id"]) data = _create(client, headers, customer["id"])
_terminate(client, headers, data["id"]) _terminate(client, headers, data["id"])
@ -412,10 +412,10 @@ class TestEdit:
assert len(detail["items"]) == 1 assert len(detail["items"]) == 1
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") 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):分配绩效、余额归合同负责人—— """口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。""" 超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
part = _personnel(client, headers) part = _personnel(client, headers)
@ -435,8 +435,8 @@ class TestEdit:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "超过 100%" in resp.json()["message"] assert "超过 100%" in resp.json()["message"]
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None: def test_二次分配参与人含负责人_保存400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) 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 resp.status_code == 400, resp.text
assert "含合同负责营销人员" in resp.json()["message"] assert "含合同负责营销人员" in resp.json()["message"]
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None: def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
part = _personnel(client, headers) part = _personnel(client, headers)
@ -479,8 +479,8 @@ class TestEdit:
class TestStatus: class TestStatus:
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None: def test_终止留痕_终态禁再变更(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create(client, headers, customer["id"]) 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[0]["old_status"], rows[0]["new_status"]) == (1, 1)
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3) assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
assert rows[1]["change_reason"] == "客户取消订单" assert rows[1]["change_reason"] == "客户取消订单"
assert rows[1]["operator_name"] == normal["username"] assert rows[1]["operator_name"] == writer["username"]
class TestOwnership: class TestOwnership:
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。""" """归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None: def test_归属变更_主表更新与留痕同生(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
old_owner = _personnel(client, headers) old_owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"]) 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["old_owner_name"] == old_owner["personnel_name"]
assert row["new_owner_name"] == new_owner["personnel_name"] assert row["new_owner_name"] == new_owner["personnel_name"]
assert row["change_reason"] == "人员调动" assert row["change_reason"] == "人员调动"
assert row["operator_name"] == normal["username"] assert row["operator_name"] == writer["username"]
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属) # owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
factory = get_session_factory() factory = get_session_factory()
@ -567,8 +567,8 @@ class TestOwnership:
f"owner_type 应为业务归属(2):{log.owner_type}" f"owner_type 应为业务归属(2):{log.owner_type}"
) )
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None: def test_归属未变化_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) 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 resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"], 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"]
quit_owner = _personnel(client, headers) quit_owner = _personnel(client, headers)
off = client.put( off = client.put(
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers 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 resp.status_code == 400, resp.text
assert "负责人员不存在或已离职" in resp.json()["message"], 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"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create(client, headers, customer["id"]) data = _create(client, headers, customer["id"])
resp = client.put( resp = client.put(
@ -613,8 +613,8 @@ class TestCustomerProtect:
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据 """客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
语义断裂 —— 服务层检查是唯一防线。""" 语义断裂 —— 服务层检查是唯一防线。"""
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None: def test_有发货或回款时换客户_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
other = _customer(client, headers) other = _customer(client, headers)
data = _create(client, headers, customer["id"]) data = _create(client, headers, customer["id"])
@ -658,8 +658,8 @@ class TestCustomerProtect:
assert blocked_receipt.status_code == 400, blocked_receipt.text assert blocked_receipt.status_code == 400, blocked_receipt.text
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.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"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create(client, headers, customer["id"]) data = _create(client, headers, customer["id"])
@ -693,8 +693,8 @@ class TestCustomerProtect:
assert blocked_receipt.status_code == 400, blocked_receipt.text assert blocked_receipt.status_code == 400, blocked_receipt.text
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None: def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
owner = _personnel(client, headers) owner = _personnel(client, headers)
new_owner = _personnel(client, headers) new_owner = _personnel(client, headers)
@ -745,10 +745,10 @@ class TestGrossProfit:
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、 """毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。""" 折算用化简式(率 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 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
折算 10000 ×(997/1000 − 30/100) = 6970。""" 折算 10000 ×(997/1000 − 30/100) = 6970。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -778,10 +778,10 @@ class TestGrossProfit:
assert cats[0]["profit_rate"] == "997/1000" assert cats[0]["profit_rate"] == "997/1000"
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970") 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 而非抛除零异常, """单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
否则录入期(补价前)打开毛利润视图就是 500。""" 否则录入期(补价前)打开毛利润视图就是 500。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -805,9 +805,9 @@ class TestGrossProfit:
assert Decimal(cats[0]["converted_profit"]) == Decimal("0") assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
def test_毛利润_成本缺失行_派生为空不计入类别汇总( def test_毛利润_成本缺失行_派生为空不计入类别汇总(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -854,11 +854,11 @@ class TestGrossProfit:
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400") assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值( def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
self, client: TestClient, normal: dict, threshold_batch self, client: TestClient, writer: dict, threshold_batch
) -> None: ) -> None:
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」 """改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
靠 asOf 回溯参数快照,M3 台账才是法定固化点。""" 靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
data = _create( data = _create(
client, client,
@ -893,13 +893,13 @@ class TestGrossProfit:
class TestAudit: class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增业务成功留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _customer(client, headers) customer = _customer(client, headers)
_create(client, headers, customer["id"]) _create(client, headers, customer["id"])
logs = [ logs = [
log 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 if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
] ]
assert logs, "新增业务未留操作日志" assert logs, "新增业务未留操作日志"
@ -907,17 +907,17 @@ class TestAudit:
assert latest.success == 1 assert latest.success == 1
assert "创建业务" in latest.summary assert "创建业务" in latest.summary
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None: def test_归属人员不存在失败留审计(self, client: TestClient, writer: dict) -> None:
customer = _customer(client, normal["headers"]) customer = _customer(client, writer["headers"])
resp = client.post( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=_payload(customer["id"], owner_personnel_id=999999999), json=_payload(customer["id"], owner_personnel_id=999999999),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_BUSINESS and log.success == 0 if log.module == MODULE_BUSINESS and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -26,7 +26,7 @@ from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocatio
from app.models.receipt import SETTLEMENT_DONE from app.models.receipt import SETTLEMENT_DONE
from tests.integration.factories import business_payload, customer 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" _CALC = "/api/v1/calc"
_BUSINESSES = "/api/v1/business/businesses" _BUSINESSES = "/api/v1/business/businesses"
@ -338,8 +338,8 @@ class TestGuards:
assert float(ledger["converted_profit"]) == -2000.0 assert float(ledger["converted_profit"]) == -2000.0
assert float(ledger["performance_amount"]) < 0 assert float(ledger["performance_amount"]) < 0
def test_准入_normal403_marketing_ops可用( def test_准入_未指定账号拒绝_指定制作人可用(
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch
) -> None: ) -> None:
_biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
@ -354,6 +354,9 @@ class TestGuards:
ops = make_user(["marketing_ops"]) ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(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"]) resp = _calc(client, ops["headers"], receipt["id"])
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@ -426,7 +429,8 @@ class TestRecalc:
) )
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200 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( resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc", f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "成本录入修正"}, json={"reason": "成本录入修正"},
@ -726,7 +730,7 @@ class TestSecondaryAllocation:
resp = client.post( resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc", f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "用例:分摊重算"}, json={"reason": "用例:分摊重算"},
headers=finance["headers"], headers=admin["headers"],
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text

View File

@ -122,38 +122,38 @@ class TestAuth:
class TestCrudFlow: class TestCrudFlow:
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。""" """正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None: def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, writer: dict) -> None:
owner = _personnel(client, normal["headers"]) owner = _personnel(client, writer["headers"])
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"]) data = _create_customer(client, writer["headers"], owner_personnel_id=owner["id"])
assert data["owner_personnel_id"] == owner["id"] assert data["owner_personnel_id"] == owner["id"]
assert data["owner_personnel_name"] == owner["personnel_name"] assert data["owner_personnel_name"] == owner["personnel_name"]
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL 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() company = _company()
first = _create_customer(client, normal["headers"], company_name=company) first = _create_customer(client, writer["headers"], company_name=company)
second = client.post( second = client.post(
_CUSTOMERS, _CUSTOMERS,
json=_cpayload(company, owner_name="李老板"), json=_cpayload(company, owner_name="李老板"),
headers=normal["headers"], headers=writer["headers"],
) )
assert second.status_code == 200, second.text assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"] 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() company = _company()
_create_customer(client, normal["headers"], company_name=company) _create_customer(client, writer["headers"], company_name=company)
dup = client.post( dup = client.post(
_CUSTOMERS, _CUSTOMERS,
json=_cpayload(company, owner_name="张老板"), json=_cpayload(company, owner_name="张老板"),
headers=normal["headers"], headers=writer["headers"],
) )
assert dup.status_code == 400, dup.text assert dup.status_code == 400, dup.text
assert "该公司名称与负责人的客户已存在" in dup.json()["message"] 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 暴露。""" """业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
headers = normal["headers"] headers = writer["headers"]
customer = _create_customer(client, headers) customer = _create_customer(client, headers)
resp = client.post( resp = client.post(
_BUSINESSES, _BUSINESSES,
@ -175,20 +175,20 @@ class TestCrudFlow:
assert blocked.status_code == 400, blocked.text assert blocked.status_code == 400, blocked.text
assert "已存在关联业务" in blocked.json()["message"] 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( resp = client.post(
_CUSTOMERS, _CUSTOMERS,
json=_cpayload(_company(), customer_type="not_a_type"), json=_cpayload(_company(), customer_type="not_a_type"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "客户分类不合法" in resp.json()["message"] assert "客户分类不合法" in resp.json()["message"]
def test_编辑其他字段不触归属_塞归属字段也被忽略( def test_编辑其他字段不触归属_塞归属字段也被忽略(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。""" """归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
headers = normal["headers"] headers = writer["headers"]
owner = _personnel(client, headers) owner = _personnel(client, headers)
stranger = _personnel(client, headers) stranger = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
@ -220,8 +220,8 @@ class TestCrudFlow:
class TestOwnership: class TestOwnership:
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。""" """归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None: def test_归属变更_主表更新且留痕新增(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
old = _personnel(client, headers) old = _personnel(client, headers)
new = _personnel(client, headers) new = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=old["id"]) 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.old_owner_personnel_id == int(old["id"])
assert log.new_owner_personnel_id == int(new["id"]) assert log.new_owner_personnel_id == int(new["id"])
assert log.change_reason == "区域调整" 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: def test_变更后归属人员已离职_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
target = _personnel(client, headers) target = _personnel(client, headers)
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers) client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
customer = _create_customer(client, headers) customer = _create_customer(client, headers)
@ -259,8 +259,8 @@ class TestOwnership:
assert "归属人员不存在或已离职" in resp.json()["message"] assert "归属人员不存在或已离职" in resp.json()["message"]
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕" assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None: def test_新旧归属相同_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
owner = _personnel(client, headers) owner = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
resp = client.put( resp = client.put(
@ -271,19 +271,19 @@ class TestOwnership:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"] 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( resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/ownership", f"{_CUSTOMERS}/{customer['id']}/ownership",
json={"new_owner_personnel_id": None}, json={"new_owner_personnel_id": None},
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "归属未变化" in resp.json()["message"] assert "归属未变化" in resp.json()["message"]
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None: def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
owner = _personnel(client, headers) owner = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
resp = client.put( resp = client.put(
@ -306,8 +306,8 @@ class TestOwnership:
class TestOwnershipLogs: class TestOwnershipLogs:
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。""" """留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None: def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
first = _personnel(client, headers) first = _personnel(client, headers)
second = _personnel(client, headers) second = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=first["id"]) customer = _create_customer(client, headers, owner_personnel_id=first["id"])
@ -343,11 +343,11 @@ class TestOwnershipLogs:
class TestAudit: class TestAudit:
"""写操作(含归属变更)留痕,摘要要能定位到客户。""" """写操作(含归属变更)留痕,摘要要能定位到客户。"""
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None: def test_新增客户留审计(self, client: TestClient, writer: dict) -> None:
data = _create_customer(client, normal["headers"]) data = _create_customer(client, writer["headers"])
logs = [ logs = [
log 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 if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
] ]
assert logs, "新增客户未留操作日志" assert logs, "新增客户未留操作日志"
@ -355,8 +355,8 @@ class TestAudit:
assert latest.success == 1 assert latest.success == 1
assert data["company_name"] in latest.summary assert data["company_name"] in latest.summary
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None: def test_归属变更审计摘要含客户名(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
old = _personnel(client, headers) old = _personnel(client, headers)
new = _personnel(client, headers) new = _personnel(client, headers)
customer = _create_customer(client, headers, owner_personnel_id=old["id"]) customer = _create_customer(client, headers, owner_personnel_id=old["id"])
@ -369,7 +369,7 @@ class TestAudit:
logs = [ logs = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_CUSTOMER and log.success == 1 if log.module == MODULE_CUSTOMER and log.success == 1
] ]
assert logs, "归属变更未留操作日志" assert logs, "归属变更未留操作日志"
@ -387,9 +387,9 @@ class TestRating:
""" """
def test_保存评级_回显总分与时间_且编辑端点不含评分( def test_保存评级_回显总分与时间_且编辑端点不含评分(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _create_customer(client, headers) customer = _create_customer(client, headers)
assert customer["rating_score"] is None, "新客户应无评分" assert customer["rating_score"] is None, "新客户应无评分"
@ -408,8 +408,8 @@ class TestRating:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["data"]["rating_score"] == 60 assert resp.json()["data"]["rating_score"] == 60
def test_评分越界_400(self, client: TestClient, normal: dict) -> None: def test_评分越界_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
customer = _create_customer(client, headers) customer = _create_customer(client, headers)
resp = client.put( resp = client.put(
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
@ -417,9 +417,9 @@ class TestRating:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
def test_按评分排序_未评分垫底_升序降序都不压过低分( def test_按评分排序_未评分垫底_升序降序都不压过低分(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
high = _create_customer(client, headers) high = _create_customer(client, headers)
low = _create_customer(client, headers) low = _create_customer(client, headers)
unrated = _create_customer(client, headers) unrated = _create_customer(client, headers)

View File

@ -17,7 +17,7 @@ from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
_DASHBOARD = "/api/v1/dashboard" _DASHBOARD = "/api/v1/dashboard"
_PERSONNEL = "/api/v1/base/personnel" _PERSONNEL = "/api/v1/base/personnel"

View File

@ -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"]

View File

@ -90,51 +90,51 @@ class TestAuth:
class TestCrudFlow: class TestCrudFlow:
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。""" """正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None: def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
assert data["status"] == 1, "新增人员应默认在职" assert data["status"] == 1, "新增人员应默认在职"
assert data["is_probation"] == 0 assert data["is_probation"] == 0
# 金额红线:str 进 str 出,不得经 float # 金额红线:str 进 str 出,不得经 float
assert data["salary_standard"] == "3500.50" 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 assert detail.status_code == 200, detail.text
body = detail.json()["data"] body = detail.json()["data"]
assert body["salary_standard"] == "3500.50" assert body["salary_standard"] == "3500.50"
assert body["regions"] == [] assert body["regions"] == []
assert body["plans"] == [] assert body["plans"] == []
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None: def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
manager = _create(client, headers) manager = _create(client, headers)
member = _create(client, headers, manager_id=manager["id"]) member = _create(client, headers, manager_id=manager["id"])
assert member["manager_id"] == manager["id"] assert member["manager_id"] == manager["id"]
assert member["manager_name"] == manager["personnel_name"] assert member["manager_name"] == manager["personnel_name"]
assert member["role_name"] == manager["role_name"] assert member["role_name"] == manager["role_name"]
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None: def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
listed = client.get( 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 assert listed.status_code == 200, listed.text
rows = listed.json()["data"]["list"] rows = listed.json()["data"]["list"]
assert [r["id"] for r in rows] == [data["id"]] assert [r["id"] for r in rows] == [data["id"]]
def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None: def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
updated = client.put( updated = client.put(
f"{_PERSONNEL}/{data['id']}", f"{_PERSONNEL}/{data['id']}",
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1), 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 assert updated.status_code == 200, updated.text
body = updated.json()["data"] body = updated.json()["data"]
assert body["phone"] == "13900001111" assert body["phone"] == "13900001111"
assert body["is_probation"] == 1 assert body["is_probation"] == 1
def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None: def test_在职离职双向切换(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers) off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
assert off.status_code == 200, off.text assert off.status_code == 200, off.text
@ -143,11 +143,11 @@ class TestCrudFlow:
assert on.status_code == 200, on.text assert on.status_code == 200, on.text
assert on.json()["data"]["status"] == 1 assert on.json()["data"]["status"] == 1
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None: def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
assert resp.status_code == 200, resp.text 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 detail.status_code == 404
assert "人员不存在" in detail.json()["message"] assert "人员不存在" in detail.json()["message"]
@ -155,27 +155,27 @@ class TestCrudFlow:
class TestRefRules: class TestRefRules:
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。""" """岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None: def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
resp = client.put( resp = client.put(
f"{_PERSONNEL}/{data['id']}", f"{_PERSONNEL}/{data['id']}",
json=_payload(data["personnel_name"], manager_id=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 resp.status_code == 400, resp.text
assert "直属经理不能是自己" in resp.json()["message"] 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( resp = client.post(
_PERSONNEL, _PERSONNEL,
json=_payload(_uname(), manager_id=999999999), json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"] assert "直属经理不存在或已离职" in resp.json()["message"]
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None: def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
manager = _create(client, headers) manager = _create(client, headers)
quit_resp = client.put( quit_resp = client.put(
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
@ -189,16 +189,16 @@ class TestRefRules:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "直属经理不存在或已离职" in resp.json()["message"] 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( resp = client.post(
_PERSONNEL, _PERSONNEL,
json=_payload(_uname(), business_role_id=999999999), json=_payload(_uname(), business_role_id=999999999),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"] 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]}" code = f"t_br_{uuid.uuid4().hex[:8]}"
factory = get_session_factory() factory = get_session_factory()
@ -220,7 +220,7 @@ class TestRefRules:
resp = client.post( resp = client.post(
_PERSONNEL, _PERSONNEL,
json=_payload(_uname(), business_role_id=role_id), json=_payload(_uname(), business_role_id=role_id),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "岗位不存在或已停用" in resp.json()["message"] assert "岗位不存在或已停用" in resp.json()["message"]
@ -229,8 +229,8 @@ class TestRefRules:
class TestDeleteProtection: class TestDeleteProtection:
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。""" """删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None: def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
manager = _create(client, headers) manager = _create(client, headers)
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
@ -244,9 +244,9 @@ class TestDeleteProtection:
assert freed.status_code == 200, freed.text assert freed.status_code == 200, freed.text
def test_被客户归属引用禁删_离职不放宽_解除后可删( def test_被客户归属引用禁删_离职不放宽_解除后可删(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
owner = _create(client, headers) owner = _create(client, headers)
created = client.post( created = client.post(
_CUSTOMERS, _CUSTOMERS,
@ -277,9 +277,9 @@ class TestDeleteProtection:
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
assert freed.status_code == 200, freed.text 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 暴露。""" """业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
headers = normal["headers"] headers = writer["headers"]
owner = _create(client, headers) owner = _create(client, headers)
assistant = _create(client, headers) assistant = _create(client, headers)
resp_customer = client.post( resp_customer = client.post(
@ -328,9 +328,9 @@ class TestSubTableReplace:
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。""" """辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
def test_辐射区域设置后详情回显一致_再次替换生效( def test_辐射区域设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
resp = client.put( resp = client.put(
f"{_PERSONNEL}/{data['id']}/regions", f"{_PERSONNEL}/{data['id']}/regions",
@ -357,8 +357,8 @@ class TestSubTableReplace:
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"] assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None: def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
client.put( client.put(
f"{_PERSONNEL}/{data['id']}/regions", f"{_PERSONNEL}/{data['id']}/regions",
@ -373,9 +373,9 @@ class TestSubTableReplace:
assert detail["regions"] == [] assert detail["regions"] == []
def test_拓展计划设置后详情回显一致_再次替换生效( def test_拓展计划设置后详情回显一致_再次替换生效(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
resp = client.put( resp = client.put(
f"{_PERSONNEL}/{data['id']}/plans", f"{_PERSONNEL}/{data['id']}/plans",
@ -405,8 +405,8 @@ class TestSubTableReplace:
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"] assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None: def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
client.put( client.put(
f"{_PERSONNEL}/{data['id']}/plans", f"{_PERSONNEL}/{data['id']}/plans",
@ -424,11 +424,11 @@ class TestSubTableReplace:
class TestAudit: class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
logs = [ logs = [
log 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 if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
] ]
assert logs, "新增人员未留操作日志" assert logs, "新增人员未留操作日志"
@ -436,16 +436,16 @@ class TestAudit:
assert latest.success == 1 assert latest.success == 1
assert data["personnel_name"] in latest.summary 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( resp = client.post(
_PERSONNEL, _PERSONNEL,
json=_payload(_uname(), manager_id=999999999), json=_payload(_uname(), manager_id=999999999),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_PERSONNEL and log.success == 0 if log.module == MODULE_PERSONNEL and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -122,8 +122,8 @@ class TestAuth:
class TestCrudFlow: class TestCrudFlow:
"""正向链路:建 → 查 → 改(含换类别)→ 删。""" """正向链路:建 → 查 → 改(含换类别)→ 删。"""
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None: def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
assert data["status"] == 1, "新产品应默认启用" assert data["status"] == 1, "新产品应默认启用"
assert data["spec_model"] == "DN100" assert data["spec_model"] == "DN100"
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float) # 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
@ -132,34 +132,34 @@ class TestCrudFlow:
assert isinstance(data["stock_quantity"], str) assert isinstance(data["stock_quantity"], str)
assert Decimal(data["stock_quantity"]) == Decimal("100") assert Decimal(data["stock_quantity"]) == Decimal("100")
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None: def test_详情与列表均能查到新增产品(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
assert detail.status_code == 200, detail.text assert detail.status_code == 200, detail.text
assert detail.json()["data"]["id"] == data["id"] assert detail.json()["data"]["id"] == data["id"]
listed = client.get( 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 listed.status_code == 200, listed.text
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]] 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() 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( second = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(name, spec_model="DN200"), json=_payload(name, spec_model="DN200"),
headers=normal["headers"], headers=writer["headers"],
) )
assert second.status_code == 200, second.text assert second.status_code == 200, second.text
assert second.json()["data"]["id"] != first["id"] assert second.json()["data"]["id"] != first["id"]
def test_删除产品成功(self, client: TestClient, normal: dict) -> None: def test_删除产品成功(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
assert resp.status_code == 200, resp.text 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 detail.status_code == 404
assert "产品不存在" in detail.json()["message"] assert "产品不存在" in detail.json()["message"]
@ -168,29 +168,29 @@ class TestDeleteProtection:
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。""" """删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
def test_被业务明细引用_400且产品仍在( def test_被业务明细引用_400且产品仍在(
self, client: TestClient, normal: dict, make_business_for_product self, client: TestClient, writer: dict, make_business_for_product
) -> None: ) -> None:
product = _create(client, normal["headers"]) product = _create(client, writer["headers"])
business = make_business_for_product(normal["headers"], product["id"]) business = make_business_for_product(writer["headers"], product["id"])
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 assert resp.status_code == 400, resp.text
assert "引用" in resp.json()["message"] 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 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" "data"
] ]
assert detail["items"][0]["product_id"] == product["id"] assert detail["items"][0]["product_id"] == product["id"]
def test_被发货明细引用_400( def test_被发货明细引用_400(
self, client: TestClient, normal: dict, make_business_for_product self, client: TestClient, writer: dict, make_business_for_product
) -> None: ) -> None:
product = _create(client, normal["headers"]) product = _create(client, writer["headers"])
make_business_for_product(normal["headers"], product["id"]) make_business_for_product(writer["headers"], product["id"])
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.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" "data"
] ]
business_id = businesses["list"][0]["id"] 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 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 assert resp.status_code == 400, resp.text
class TestValidationRules: class TestValidationRules:
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。""" """类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None: def test_重名同规格_400(self, client: TestClient, writer: dict) -> None:
name = _pname() name = _pname()
_create(client, normal["headers"], product_name=name) _create(client, writer["headers"], product_name=name)
dup = client.post( dup = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(name, spec_model="DN100"), json=_payload(name, spec_model="DN100"),
headers=normal["headers"], headers=writer["headers"],
) )
assert dup.status_code == 400, dup.text assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"] assert "已存在同名称同规格的产品" in dup.json()["message"]
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None: def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, writer: dict) -> None:
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。""" """组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
name = _pname() 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"] == "" assert first["spec_model"] == ""
dup = client.post( dup = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(name, spec_model=None), json=_payload(name, spec_model=None),
headers=normal["headers"], headers=writer["headers"],
) )
assert dup.status_code == 400, dup.text assert dup.status_code == 400, dup.text
assert "已存在同名称同规格的产品" in dup.json()["message"] 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( resp = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(_pname(), product_category="not_a_category"), json=_payload(_pname(), product_category="not_a_category"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "产品类别不合法" in resp.json()["message"] 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( resp = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(_pname(), sale_price="-1"), json=_payload(_pname(), sale_price="-1"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text 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( resp = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(_pname(), sale_price="abc"), json=_payload(_pname(), sale_price="abc"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None: def test_编辑改类别为合法值成功(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
updated = client.put( updated = client.put(
f"{_PRODUCTS}/{data['id']}", f"{_PRODUCTS}/{data['id']}",
json=_payload( json=_payload(
@ -277,7 +277,7 @@ class TestValidationRules:
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
sale_price="15.8", sale_price="15.8",
), ),
headers=normal["headers"], headers=writer["headers"],
) )
assert updated.status_code == 200, updated.text assert updated.status_code == 200, updated.text
body = updated.json()["data"] body = updated.json()["data"]
@ -288,8 +288,8 @@ class TestValidationRules:
class TestStatusSwitch: class TestStatusSwitch:
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。""" """启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None: def test_停用再启用切换(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
data = _create(client, headers) data = _create(client, headers)
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers) off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
assert off.status_code == 200, off.text assert off.status_code == 200, off.text
@ -304,11 +304,11 @@ class TestStatusSwitch:
class TestAudit: class TestAudit:
"""写操作(含失败)留痕 —— 审计是验收项。""" """写操作(含失败)留痕 —— 审计是验收项。"""
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增产品成功留审计(self, client: TestClient, writer: dict) -> None:
data = _create(client, normal["headers"]) data = _create(client, writer["headers"])
logs = [ logs = [
log 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 if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
] ]
assert logs, "新增产品未留操作日志" assert logs, "新增产品未留操作日志"
@ -316,18 +316,18 @@ class TestAudit:
assert latest.success == 1 assert latest.success == 1
assert data["product_name"] in latest.summary 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() name = _pname()
_create(client, normal["headers"], product_name=name) _create(client, writer["headers"], product_name=name)
dup = client.post( dup = client.post(
_PRODUCTS, _PRODUCTS,
json=_payload(name, spec_model="DN100"), json=_payload(name, spec_model="DN100"),
headers=normal["headers"], headers=writer["headers"],
) )
assert dup.status_code == 400, dup.text assert dup.status_code == 400, dup.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_PRODUCT and log.success == 0 if log.module == MODULE_PRODUCT and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -122,8 +122,8 @@ class TestOverlapGuardLock:
class TestList: class TestList:
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。""" """列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None: def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, writer: dict) -> None:
resp = client.get(_GRADES, headers=normal["headers"]) resp = client.get(_GRADES, headers=writer["headers"])
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
rows = resp.json()["data"] rows = resp.json()["data"]
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [ assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
@ -140,11 +140,11 @@ class TestCreate:
任一缺失都会算错等级。""" 任一缺失都会算错等级。"""
def test_删除种子后新增相邻区间_成功且判定表更新( def test_删除种子后新增相邻区间_成功且判定表更新(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74), """种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。""" 新增 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) client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
resp = client.post( resp = client.post(
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers _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"] rows = client.get(_GRADES, headers=headers).json()["data"]
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
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(), "80", "10"), headers=normal["headers"]) resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=writer["headers"])
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], 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( 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 resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], 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(), "-5", "10"), headers=normal["headers"]) resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=writer["headers"])
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "分值区间不合法" in resp.json()["message"], 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 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
「与等级 D 重叠」而走不到名称校验。""" 「与等级 D 重叠」而走不到名称校验。"""
headers = normal["headers"] headers = writer["headers"]
_drop_seed(client, headers, "D") _drop_seed(client, headers, "D")
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers) resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "该等级名称已存在" in resp.json()["message"], 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,前端据此定位行。""" """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 resp.status_code == 400, resp.text
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
# 失败不落库:判定表仍是 4 条种子 # 失败不落库:判定表仍是 4 条种子
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"] assert _names(client, writer["headers"]) == ["A", "B", "C", "D"]
class TestEdit: 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 均分离。""" """把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
headers = normal["headers"] headers = writer["headers"]
b_id = _seed_id(client, headers, "B") b_id = _seed_id(client, headers, "B")
resp = client.put( resp = client.put(
f"{_GRADES}/{b_id}", f"{_GRADES}/{b_id}",
@ -211,10 +211,10 @@ class TestEdit:
# 失败不落库:种子保持原样 # 失败不落库:种子保持原样
assert _names(client, headers) == ["A", "B", "C", "D"] 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) **边界相邻不算重叠** """删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
(74 与 75 相邻),这是种子铺法的正例。""" (74 与 75 相邻),这是种子铺法的正例。"""
headers = normal["headers"] headers = writer["headers"]
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
d_id = _seed_id(client, headers, "D") d_id = _seed_id(client, headers, "D")
resp = client.put( 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 [(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] assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None: def test_编辑回显改名与分数(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname() grade_name = _tname()
created = client.post( created = client.post(
@ -261,8 +261,8 @@ class TestDelete:
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 """删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
立即反映,二次删除 404。""" 立即反映,二次删除 404。"""
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: def test_删除等级后判定表更新_二次删除404(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname() grade_name = _tname()
created = client.post( created = client.post(
@ -283,8 +283,8 @@ class TestDelete:
class TestAudit: class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增等级成功留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
grade_name = _tname() grade_name = _tname()
resp = client.post( resp = client.post(
@ -295,7 +295,7 @@ class TestAudit:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
logs = [ logs = [
log 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 if log.module == MODULE_RATING and log.action == ACTION_CREATE
] ]
assert logs, "新增评级等级未留操作日志" assert logs, "新增评级等级未留操作日志"
@ -303,12 +303,12 @@ class TestAudit:
assert latest.success == 1 assert latest.success == 1
assert grade_name in latest.summary assert grade_name in latest.summary
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None: def test_重叠失败留审计(self, client: TestClient, writer: dict) -> None:
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 resp.status_code == 400, resp.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_RATING and log.success == 0 if log.module == MODULE_RATING and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -122,8 +122,8 @@ class TestAuth:
class TestCreate: class TestCreate:
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。""" """创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None: def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
@ -140,10 +140,10 @@ class TestCreate:
assert data["settlement_status"] == 0 assert data["settlement_status"] == 0
assert Decimal(data["amount"]) == Decimal("1200") 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 天, """发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
需规「26 天」示例已作废)。""" 需规「26 天」示例已作废)。"""
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
shipment = _create_shipment(client, headers, business["id"], "2026-10-05") shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
data = _create( data = _create(
@ -158,8 +158,8 @@ class TestCreate:
assert data["shipment_no"] == shipment["shipment_no"] assert data["shipment_no"] == shipment["shipment_no"]
assert data["payment_days"] == 29, data assert data["payment_days"] == 29, data
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None: def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
@ -170,35 +170,35 @@ class TestCreate:
rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
assert Decimal(rollback["total_received_amount"]) == Decimal("0") assert Decimal(rollback["total_received_amount"]) == Decimal("0")
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"]) resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=writer["headers"])
assert resp.status_code == 404, resp.text assert resp.status_code == 404, resp.text
assert "业务不存在" in resp.json()["message"], 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"]
business = _create_business(client, headers) business = _create_business(client, headers)
_terminate(client, headers, business["id"]) _terminate(client, headers, business["id"])
resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers) resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], 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( 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 resp.status_code == 400, resp.text
assert "不能为负数" in resp.json()["message"], 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( resp = client.post(
_RECEIPTS, _RECEIPTS,
json=_rpayload(business["id"], amount="12.345"), json=_rpayload(business["id"], amount="12.345"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "最多两位小数" in resp.json()["message"], resp.text assert "最多两位小数" in resp.json()["message"], resp.text
@ -208,8 +208,8 @@ class TestCrossBusiness:
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应 """发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
差异探测发货单存在性。""" 差异探测发货单存在性。"""
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) business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today())) shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers) business2 = _create_business(client, headers)
@ -222,9 +222,9 @@ class TestCrossBusiness:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], 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) business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today())) shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers) business2 = _create_business(client, headers)
@ -242,8 +242,8 @@ class TestCrossBusiness:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None: def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
shipment = _create_shipment(client, headers, business["id"], "2026-10-05") shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
@ -279,8 +279,8 @@ class TestCrossBusiness:
class TestSettled: class TestSettled:
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。""" """已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None: def test_已核算禁改_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
_settle(data["id"]) _settle(data["id"])
@ -297,8 +297,8 @@ class TestSettled:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "已核算回款禁止修改" in resp.json()["message"], 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"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
_settle(data["id"]) _settle(data["id"])
@ -312,9 +312,9 @@ class TestFilter:
"""核算状态筛选(M3 核算消费的入口口径)。""" """核算状态筛选(M3 核算消费的入口口径)。"""
def test_settlement_status筛选_0只回未核算_1只回已核算( def test_settlement_status筛选_0只回未核算_1只回已核算(
self, client: TestClient, normal: dict self, client: TestClient, writer: dict
) -> None: ) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
pending = _create(client, headers, business["id"], amount="500.00") pending = _create(client, headers, business["id"], amount="500.00")
settled = _create(client, headers, business["id"], amount="700.00") settled = _create(client, headers, business["id"], amount="700.00")
@ -343,13 +343,13 @@ class TestFilter:
class TestAudit: class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增回款成功留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
logs = [ logs = [
log 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 if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
] ]
assert logs, "新增回款未留操作日志" assert logs, "新增回款未留操作日志"
@ -358,8 +358,8 @@ class TestAudit:
assert "新增回款" in latest.summary assert "新增回款" in latest.summary
assert data["receipt_batch_no"] in latest.summary assert data["receipt_batch_no"] in latest.summary
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None: def test_跨业务指定失败留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business1 = _create_business(client, headers) business1 = _create_business(client, headers)
shipment = _create_shipment(client, headers, business1["id"], str(date.today())) shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
business2 = _create_business(client, headers) business2 = _create_business(client, headers)
@ -371,7 +371,7 @@ class TestAudit:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_RECEIPT and log.success == 0 if log.module == MODULE_RECEIPT and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -15,7 +15,7 @@ from decimal import Decimal
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
_REPORTS = "/api/v1/reports" _REPORTS = "/api/v1/reports"
_PERSONNEL = "/api/v1/base/personnel" _PERSONNEL = "/api/v1/base/personnel"

View File

@ -19,7 +19,7 @@ from fastapi.testclient import TestClient
from tests.integration.factories import business_payload, customer 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" _BUSINESSES = "/api/v1/business/businesses"
_PERSONNEL = "/api/v1/base/personnel" _PERSONNEL = "/api/v1/base/personnel"

View File

@ -21,7 +21,7 @@ from app.core.db import get_session_factory
from app.models import MonthlySettlement, PerformanceLedger from app.models import MonthlySettlement, PerformanceLedger
from tests.integration.factories import business_payload, customer 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" _SETTLEMENTS = "/api/v1/settlements"
_CALC = "/api/v1/calc" _CALC = "/api/v1/calc"

View File

@ -97,8 +97,8 @@ class TestCreate:
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总 """创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
是 M3 起付判定的直接消费源,错写就是算错钱。""" 是 M3 起付判定的直接消费源,错写就是算错钱。"""
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None: def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create( data = _create(
client, client,
@ -130,53 +130,53 @@ class TestCreate:
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400") assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50") assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"]) resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=writer["headers"])
assert resp.status_code == 404, resp.text assert resp.status_code == 404, resp.text
assert "业务不存在" in resp.json()["message"], 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"]
business = _create_business(client, headers) business = _create_business(client, headers)
_terminate(client, headers, business["id"]) _terminate(client, headers, business["id"])
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], 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( 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 assert resp.status_code == 400, resp.text
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
business = _create_business(client, normal["headers"]) business = _create_business(client, writer["headers"])
resp = client.post( resp = client.post(
_SHIPMENTS, _SHIPMENTS,
json=_spayload(business["id"], items=[_item(quantity="0")]), json=_spayload(business["id"], items=[_item(quantity="0")]),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "数量" in resp.json()["message"], 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( resp = client.post(
_SHIPMENTS, _SHIPMENTS,
json=_spayload(business["id"], freight_amount="-8.00"), json=_spayload(business["id"], freight_amount="-8.00"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "不能为负数" in resp.json()["message"], 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( resp = client.post(
_SHIPMENTS, _SHIPMENTS,
json=_spayload(business["id"], freight_amount="8.333"), json=_spayload(business["id"], freight_amount="8.333"),
headers=normal["headers"], headers=writer["headers"],
) )
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "最多两位小数" in resp.json()["message"], resp.text assert "最多两位小数" in resp.json()["message"], resp.text
@ -185,8 +185,8 @@ class TestCreate:
class TestEdit: class TestEdit:
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。""" """编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None: def test_编辑发货单_两处汇总同步(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create( data = _create(
client, client,
@ -223,8 +223,8 @@ class TestEdit:
class TestDelete: class TestDelete:
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。""" """删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None: def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create( data = _create(
client, client,
@ -264,8 +264,8 @@ class TestDelete:
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0") assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0") assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None: def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create( data = _create(
client, client,
@ -289,8 +289,8 @@ class TestTerminal:
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算 """终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
的源数据,终态后不得变动。""" 的源数据,终态后不得变动。"""
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None: def test_终态业务发货单禁改_400(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
_terminate(client, headers, business["id"]) _terminate(client, headers, business["id"])
@ -306,8 +306,8 @@ class TestTerminal:
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
assert "禁止编辑发货单" in resp.json()["message"], 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"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
_terminate(client, headers, business["id"]) _terminate(client, headers, business["id"])
@ -320,13 +320,13 @@ class TestTerminal:
class TestAudit: class TestAudit:
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None: def test_新增发货单成功留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
data = _create(client, headers, business["id"]) data = _create(client, headers, business["id"])
logs = [ logs = [
log 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 if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
] ]
assert logs, "新增发货单未留操作日志" assert logs, "新增发货单未留操作日志"
@ -335,15 +335,15 @@ class TestAudit:
assert "新增发货单" in latest.summary assert "新增发货单" in latest.summary
assert data["shipment_no"] in latest.summary assert data["shipment_no"] in latest.summary
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None: def test_终态禁新发货失败留审计(self, client: TestClient, writer: dict) -> None:
headers = normal["headers"] headers = writer["headers"]
business = _create_business(client, headers) business = _create_business(client, headers)
_terminate(client, headers, business["id"]) _terminate(client, headers, business["id"])
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
assert resp.status_code == 400, resp.text assert resp.status_code == 400, resp.text
failed = [ failed = [
log log
for log in _logs_of(normal["username"]) for log in _logs_of(writer["username"])
if log.module == MODULE_SHIPMENT and log.success == 0 if log.module == MODULE_SHIPMENT and log.success == 0
] ]
assert failed, "失败操作未留痕" assert failed, "失败操作未留痕"

View File

@ -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'

View File

@ -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)
}

View File

@ -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")

View File

@ -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

View File

@ -12,6 +12,9 @@ from sqlalchemy.dialects import mysql
from app.models import Base from app.models import Base
EXPECTED_TABLES = { 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", "users",
"roles", "roles",
"user_roles", "user_roles",

View File

@ -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")

View File

@ -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"]

View File

@ -79,7 +79,7 @@ def test_T加查询文件覆盖六类实体() -> None:
def test_T加高风险映射保持待确认() -> None: def test_T加高风险映射保持待确认() -> None:
assert get_mapping("businesses").status is MappingStatus.PENDING assert get_mapping("businesses").status is MappingStatus.PENDING
assert "成本口径" in get_mapping("businesses").unresolved assert "指导价取值时点" in get_mapping("businesses").unresolved
def test_未知T加实体被拒绝() -> None: def test_未知T加实体被拒绝() -> None:

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -80,7 +80,8 @@ def test_多订单和成本日期仍需业务确认不自动选择():
result = _audit().run() result = _audit().run()
assert result["order_coverage"]["multiple_orders"] == 2 assert result["order_coverage"]["multiple_orders"] == 2
keys = {item["key"] for item in result["pending_confirmations"]} 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查询有固定白名单且不接受自定义表名(): def test_SQL查询有固定白名单且不接受自定义表名():

View File

@ -108,3 +108,19 @@ def test_未确认时间增量不能创建误导性批次(target):
with pytest.raises(ValueError, match="增量水位语义尚未确认"): with pytest.raises(ValueError, match="增量水位语义尚未确认"):
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27)) service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 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"

View File

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

Some files were not shown because too many files have changed in this diff Show More