feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
parent
1b074576fe
commit
108bf87225
6
.gitignore
vendored
6
.gitignore
vendored
@ -76,3 +76,9 @@ __MACOSX/
|
||||
|
||||
# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库
|
||||
.tmpdocx/
|
||||
# 本轮原开发环境损坏后的独立恢复运行环境。
|
||||
backend/.venv-tplus/
|
||||
backend/.venv-workbench/
|
||||
|
||||
# 本地浏览器验收自动生成的快照
|
||||
/.playwright-mcp/
|
||||
|
||||
14
README.md
14
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
||||
|
||||
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
||||
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。生产自动化默认关闭,尚未安装启用;真实金额与整月工资尚未验收,首组样本缺人工指导成本。审核能力保留但默认关闭,多原单更正仍单独跟踪。最新交付见 [开发记录](docs/同步运维自动化与样本核对开发记录_2026-10-30.md) 和 [Windows 操作说明](scripts/windows/README.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@ -70,7 +70,7 @@ pnpm dev
|
||||
# 后端测试与代码检查
|
||||
cd backend
|
||||
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
||||
uv run pytest -q # 全部(集成部分需容器在跑)
|
||||
python ../scripts/run_isolated_tests.py # 创建独立测试库运行集成,禁止指向业务库
|
||||
uv run ruff check . # 代码检查
|
||||
uv run ruff format . # 代码格式化
|
||||
|
||||
@ -134,3 +134,13 @@ python e2e/system_management.py # 系统管理 13 项
|
||||
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
|
||||
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
|
||||
| `docs/` | 需求文档与工资算法方案 |
|
||||
|
||||
## 正式工资闭环
|
||||
|
||||
- 在现有账号中明确绑定工资制作人:`PAYROLL_MAKER_USER_ID`。未配置时业务只读,不按管理员或岗位自动放行。真实账号绑定需由维护者核实,不在源码或示例中写入实际用户编号。
|
||||
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
|
||||
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
|
||||
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
|
||||
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。测试运行器保留隔离库供复查,不删除原开发数据。
|
||||
|
||||
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
|
||||
|
||||
@ -46,3 +46,17 @@ T_PLUS_CONNECT_TIMEOUT=10
|
||||
T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
T_PLUS_QUERY_DIR=
|
||||
|
||||
# 指定实际工资制作人的现有用户 ID;留空时业务只读,不自动授予管理员
|
||||
# PAYROLL_MAKER_USER_ID=
|
||||
|
||||
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
|
||||
PAYROLL_APPROVAL_ENABLED=false
|
||||
|
||||
# ===== T+ 周期采集(默认关闭,仅采集原始数据,不自动生成工资)=====
|
||||
T_PLUS_AUTOMATION_ENABLED=false
|
||||
# 0=周一,6=周日;时段均为北京时间
|
||||
T_PLUS_SCHEDULE_WEEKDAY=0
|
||||
T_PLUS_SCHEDULE_TIME=02:00
|
||||
# 托管启动器自动设置;发现标记后停止排队和领取新任务
|
||||
T_PLUS_STOP_FILE=
|
||||
|
||||
27
backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py
Normal file
27
backend/alembic/versions/07b8c9d0e1f2_tplus_index_names.py
Normal 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}")
|
||||
31
backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py
Normal file
31
backend/alembic/versions/18c9d0e1f2a3_formal_payroll.py
Normal 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')
|
||||
@ -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")
|
||||
18
backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py
Normal file
18
backend/alembic/versions/20e1f2a3b4c5_tplus_automation.py
Normal 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")
|
||||
@ -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'''")
|
||||
31
backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py
Normal file
31
backend/alembic/versions/f6a708b9c0d1_tplus_workbench.py
Normal 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)
|
||||
@ -119,6 +119,26 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_payroll_writer(current_user: LoginUser) -> User:
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
|
||||
return ensure_payroll_writer(current_user)
|
||||
|
||||
|
||||
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
|
||||
|
||||
|
||||
def require_workbench_reader(current_user: LoginUser) -> User:
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
if ROLE_ADMIN not in user_repository.get_role_codes(current_user) and not can_write_payroll(current_user):
|
||||
raise ForbiddenError("需要管理员或指定工资制作人权限")
|
||||
return current_user
|
||||
|
||||
|
||||
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.calc import router as calc_router
|
||||
from app.api.v1.dashboard import router as dashboard_router
|
||||
from app.api.v1.payroll import router as payroll_router
|
||||
from app.api.v1.reports import router as reports_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
@ -69,6 +70,7 @@ v1_router.include_router(business_router)
|
||||
|
||||
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(calc_router)
|
||||
v1_router.include_router(payroll_router)
|
||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(settlements_router)
|
||||
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||
|
||||
@ -5,11 +5,13 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
|
||||
from app.core.config import get_settings
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||
from app.services import auth_service, captcha_service
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
@ -71,6 +73,8 @@ def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[
|
||||
username=current_user.username,
|
||||
real_name=current_user.real_name,
|
||||
roles=get_role_codes(current_user),
|
||||
can_write_payroll=can_write_payroll(current_user),
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
last_login_time=current_user.last_login_time,
|
||||
)
|
||||
)
|
||||
|
||||
@ -13,7 +13,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.customer import (
|
||||
@ -46,7 +46,7 @@ def list_customers(
|
||||
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
|
||||
def create_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: CustomerCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
@ -70,7 +70,7 @@ def get_customer(
|
||||
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
|
||||
def update_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -91,7 +91,7 @@ def update_customer(
|
||||
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
|
||||
def delete_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@ -110,7 +110,7 @@ def delete_customer(
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -135,7 +135,7 @@ def rate_customer(
|
||||
)
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@ -11,7 +11,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.personnel import (
|
||||
@ -44,7 +44,7 @@ def list_personnel(
|
||||
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||
def create_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[PersonnelOut]:
|
||||
@ -68,7 +68,7 @@ def get_personnel(
|
||||
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||
def update_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -85,7 +85,7 @@ def update_personnel(
|
||||
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||
def delete_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@ -101,7 +101,7 @@ def delete_personnel(
|
||||
)
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PersonnelStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -116,7 +116,7 @@ def set_status(
|
||||
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||
def replace_regions(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: RegionReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -131,7 +131,7 @@ def replace_regions(
|
||||
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||
def replace_plans(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PlanReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@ -14,7 +14,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.product import (
|
||||
@ -44,7 +44,7 @@ def list_products(
|
||||
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||
def create_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ProductCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ProductOut]:
|
||||
@ -68,7 +68,7 @@ def get_product(
|
||||
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||
def update_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -85,7 +85,7 @@ def update_product(
|
||||
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||
def delete_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@ -100,7 +100,7 @@ def delete_product(
|
||||
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@ -9,7 +9,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.business import (
|
||||
@ -45,7 +45,7 @@ def list_businesses(
|
||||
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||
def create_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: BusinessCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessOut]:
|
||||
@ -69,7 +69,7 @@ def get_business(
|
||||
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||
def update_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -86,7 +86,7 @@ def update_business(
|
||||
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||
def delete_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@ -98,7 +98,7 @@ def delete_business(
|
||||
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||
def change_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -115,7 +115,7 @@ def change_status(
|
||||
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@ -13,7 +13,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||
@ -36,7 +36,7 @@ def list_grades(
|
||||
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||
def create_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: RatingGradeCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RatingGradeOut]:
|
||||
@ -50,7 +50,7 @@ def create_grade(
|
||||
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||
def update_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
payload: RatingGradeUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -65,7 +65,7 @@ def update_grade(
|
||||
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||
def delete_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@ -9,7 +9,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.receipt import (
|
||||
@ -39,7 +39,7 @@ def list_receipts(
|
||||
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||
def create_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ReceiptCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ReceiptOut]:
|
||||
@ -63,7 +63,7 @@ def get_receipt(
|
||||
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||
def update_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: ReceiptUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -80,7 +80,7 @@ def update_receipt(
|
||||
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||
def delete_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.shipment import (
|
||||
@ -39,7 +39,7 @@ def list_shipments(
|
||||
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||
def create_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ShipmentCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ShipmentOut]:
|
||||
@ -63,7 +63,7 @@ def get_shipment(
|
||||
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||
def update_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
payload: ShipmentUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@ -80,7 +80,7 @@ def update_shipment(
|
||||
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||
def delete_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@ -10,18 +10,14 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||
from app.services import calc_service
|
||||
|
||||
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
|
||||
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
|
||||
|
||||
|
||||
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||
@ -50,7 +46,7 @@ def get_ledger(
|
||||
def calc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||
@ -81,7 +77,7 @@ def recalc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: RecalcIn,
|
||||
session: DbSession,
|
||||
current_user: _RecalcUser,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||
@ -97,7 +93,7 @@ def recalc_receipt(
|
||||
def batch_calc(
|
||||
payload: BatchIn,
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[list[dict]]:
|
||||
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||
|
||||
108
backend/app/api/v1/payroll.py
Normal file
108
backend/app/api/v1/payroll.py
Normal 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))
|
||||
@ -8,10 +8,8 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.settlement import (
|
||||
AdjustmentCreateIn,
|
||||
SettlementDetailOut,
|
||||
@ -21,16 +19,15 @@ from app.schemas.settlement import (
|
||||
)
|
||||
from app.services import settlement_service
|
||||
|
||||
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
|
||||
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||
def generate(
|
||||
payload: SettlementGenerateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||
@ -66,7 +63,7 @@ def get_settlement(
|
||||
def submit(
|
||||
settlement_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||
@ -83,7 +80,7 @@ def submit(
|
||||
def create_adjustment(
|
||||
payload: AdjustmentCreateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[dict]:
|
||||
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||
|
||||
@ -4,7 +4,8 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchReader
|
||||
from app.api.v1.system.tplus_workbench import router as workbench_router
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
@ -19,23 +20,24 @@ from app.schemas.tplus import (
|
||||
from app.services.tplus import query_service, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
router.include_router(workbench_router)
|
||||
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
|
||||
def overview(session: DbSession, _: AdminUser):
|
||||
def overview(session: DbSession, _: WorkbenchReader):
|
||||
"""查询配置状态、任务统计和最近核验。"""
|
||||
return ok(query_service.overview(session))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
return ok(query_service.list_batches(session, query))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
def detail(session: DbSession, _: WorkbenchReader, batch_no: BatchNumber):
|
||||
"""查询批次详情与受控的核验报告。"""
|
||||
return ok(query_service.get_batch(session, batch_no))
|
||||
|
||||
@ -43,7 +45,7 @@ def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
|
||||
def errors(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: WorkbenchReader,
|
||||
batch_no: BatchNumber,
|
||||
query: Annotated[TPlusErrorQuery, Query()],
|
||||
):
|
||||
@ -52,7 +54,7 @@ def errors(
|
||||
|
||||
|
||||
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
def create(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
"""将读取或核验任务持久化排队。"""
|
||||
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
@ -61,7 +63,7 @@ def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload:
|
||||
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def retry(
|
||||
session: DbSession,
|
||||
admin: AdminUser,
|
||||
admin: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
batch_no: BatchNumber,
|
||||
payload: TPlusRetryIn,
|
||||
|
||||
223
backend/app/api/v1/system/tplus_workbench.py
Normal file
223
backend/app/api/v1/system/tplus_workbench.py
Normal 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"'},
|
||||
)
|
||||
@ -10,9 +10,13 @@
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@ -115,24 +119,79 @@ def _tplus_worker(args: argparse.Namespace) -> int:
|
||||
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
||||
return 1
|
||||
factory = get_session_factory()
|
||||
settings = get_settings()
|
||||
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
|
||||
stopped = threading.Event()
|
||||
owner = str(uuid4())
|
||||
previous_handlers = {}
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
||||
try:
|
||||
while True:
|
||||
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
|
||||
with factory() as session:
|
||||
batch_no = run_once(session)
|
||||
batch_no = run_once(session, worker_owner=owner)
|
||||
batch = get_batch(session, batch_no) if batch_no else None
|
||||
if batch_no and batch is None:
|
||||
from app.models import TPlusWorkRun
|
||||
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
|
||||
status = batch.status if batch else None
|
||||
if batch_no:
|
||||
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
||||
if not args.watch:
|
||||
return 0 if status in (None, "SUCCEEDED") else 1
|
||||
if batch_no is None or status != "SUCCEEDED":
|
||||
time.sleep(args.poll_seconds)
|
||||
stopped.wait(args.poll_seconds)
|
||||
print("T+读取服务已停止领取新任务", flush=True)
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("T+ 读取服务已停止")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
for sig, handler in previous_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
try:
|
||||
from app.services.tplus.automation import heartbeat
|
||||
|
||||
heartbeat(factory, settings, owner, "STOPPED")
|
||||
except Exception as exc:
|
||||
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
|
||||
|
||||
|
||||
def _maintenance(args):
|
||||
from app.services.tplus.automation import tick
|
||||
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
if args.stop_file:
|
||||
get_settings().t_plus_stop_file = args.stop_file
|
||||
with get_session_factory()() as session:
|
||||
result = tick(session)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _materials(args):
|
||||
from app.services.tplus.materials import collect_materials, render_markdown
|
||||
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
output = Path(args.output_dir).resolve()
|
||||
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
|
||||
if any(p.exists() for p in targets):
|
||||
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
|
||||
with get_tplus_session_factory()() as source:
|
||||
report = collect_materials(source, args.customer_id, args.month)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
targets[1].write_text(render_markdown(report), encoding="utf-8")
|
||||
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@ -182,6 +241,17 @@ def main() -> int:
|
||||
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
||||
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
||||
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
||||
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
|
||||
|
||||
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
|
||||
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
|
||||
p_maintenance.add_argument("--connection-file", default=None)
|
||||
p_maintenance.add_argument("--stop-file", default=None)
|
||||
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
|
||||
p_materials.add_argument("--customer-id", type=int, required=True)
|
||||
p_materials.add_argument("--month", required=True)
|
||||
p_materials.add_argument("--connection-file", default=None)
|
||||
p_materials.add_argument("--output-dir", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -190,7 +260,14 @@ def main() -> int:
|
||||
return _create_admin(args)
|
||||
if args.command == "tplus-worker":
|
||||
return _tplus_worker(args)
|
||||
if args.command == "tplus-maintain":
|
||||
return _maintenance(args)
|
||||
if args.command == "tplus-materials":
|
||||
return _materials(args)
|
||||
return _stage_tplus(args)
|
||||
except Exception as exc:
|
||||
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
|
||||
@ -36,6 +36,11 @@ class Settings(BaseSettings):
|
||||
# ===== 应用 =====
|
||||
app_env: str = "dev"
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
||||
payroll_maker_user_id: int | None = Field(
|
||||
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
|
||||
)
|
||||
payroll_allow_legacy_calculation: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
|
||||
# ===== 鉴权 =====
|
||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||
@ -74,6 +79,10 @@ class Settings(BaseSettings):
|
||||
t_plus_connect_timeout: int = 10
|
||||
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||
t_plus_query_dir: str = ""
|
||||
t_plus_automation_enabled: bool = False
|
||||
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
|
||||
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
t_plus_stop_file: str = ""
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
@ -120,9 +129,7 @@ class Settings(BaseSettings):
|
||||
query = {
|
||||
"driver": self.t_plus_odbc_driver,
|
||||
"Encrypt": "yes" if self.t_plus_encrypt else "no",
|
||||
"TrustServerCertificate": "yes"
|
||||
if self.t_plus_trust_server_certificate
|
||||
else "no",
|
||||
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
|
||||
"Connection Timeout": str(self.t_plus_connect_timeout),
|
||||
}
|
||||
return URL.create(
|
||||
|
||||
@ -17,6 +17,7 @@ from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
|
||||
from app.models.performance_ledger import PerformanceLedger
|
||||
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.models.product import Product
|
||||
@ -26,12 +27,20 @@ from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.tplus_automation import TPlusAutomationState
|
||||
from app.models.tplus_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
TPlusSyncRecord,
|
||||
TPlusSyncWatermark,
|
||||
)
|
||||
from app.models.tplus_workbench import (
|
||||
TPlusFact,
|
||||
TPlusSourceHead,
|
||||
TPlusSourceLink,
|
||||
TPlusWorkLine,
|
||||
TPlusWorkRun,
|
||||
)
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
@ -52,6 +61,9 @@ __all__ = [
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PayrollAllocation",
|
||||
"PayrollInput",
|
||||
"PayrollMonth",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
@ -66,10 +78,16 @@ __all__ = [
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusAutomationState",
|
||||
"TPlusFact",
|
||||
"TPlusSourceHead",
|
||||
"TPlusSourceLink",
|
||||
"TPlusSyncBatch",
|
||||
"TPlusSyncError",
|
||||
"TPlusSyncRecord",
|
||||
"TPlusSyncWatermark",
|
||||
"TPlusWorkLine",
|
||||
"TPlusWorkRun",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
66
backend/app/models/payroll.py
Normal file
66
backend/app/models/payroll.py
Normal 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="单据、成本、归属、参数和逐段计算依据")
|
||||
@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"receipt_id",
|
||||
"calc_version",
|
||||
"personnel_id",
|
||||
"allocation_key",
|
||||
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||
),
|
||||
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
|
||||
@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"calc_type",
|
||||
),
|
||||
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
|
||||
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
|
||||
Index("idx_performance_ledgers_business_id", "business_id"),
|
||||
)
|
||||
|
||||
@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
server_default="1",
|
||||
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||
)
|
||||
allocation_key: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串",
|
||||
)
|
||||
is_active: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
|
||||
|
||||
@property
|
||||
def approval_required(self) -> bool:
|
||||
return bool((self.snapshot_json or {}).get("approval_required", True))
|
||||
|
||||
|
||||
class SettlementItem(Base):
|
||||
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
|
||||
@ -154,6 +158,9 @@ class AdjustmentOrder(Base, TimestampMixin):
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额")
|
||||
source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID")
|
||||
attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照")
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
26
backend/app/models/tplus_automation.py
Normal file
26
backend/app/models/tplus_automation.py
Normal 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")
|
||||
@ -63,6 +63,8 @@ class TPlusSyncBatch(Base, TimestampMixin):
|
||||
requested_by: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
)
|
||||
trigger_type: Mapped[str] = mapped_column(String(16), server_default="'MANUAL'", comment="触发来源:MANUAL/SCHEDULED/AUTO_RETRY")
|
||||
automatic_attempt: Mapped[int] = mapped_column(Integer, server_default="0", comment="自动重试序号(0为原始任务)")
|
||||
audit_result_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="关联核验结果,只含系统字典和汇总"
|
||||
)
|
||||
|
||||
134
backend/app/models/tplus_workbench.py
Normal file
134
backend/app/models/tplus_workbench.py
Normal 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)
|
||||
@ -56,6 +56,8 @@ class UserBrief(BaseModel):
|
||||
username: str
|
||||
real_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
can_write_payroll: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
|
||||
@ -250,6 +250,8 @@ class BusinessCreateIn(BaseModel):
|
||||
class BusinessUpdateIn(BaseModel):
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
customer_id: int = Field(ge=1, description="客户ID")
|
||||
|
||||
59
backend/app/schemas/payroll.py
Normal file
59
backend/app/schemas/payroll.py
Normal 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)
|
||||
@ -62,6 +62,7 @@ class SettlementOut(BaseModel):
|
||||
total_payable: Decimal | None = None
|
||||
person_count: int
|
||||
approval_order_no: str | None = None
|
||||
approval_required: bool = False
|
||||
submitter_name: str
|
||||
locked_at: datetime | datetime | None = None
|
||||
locked_by: str | None = None
|
||||
@ -89,6 +90,7 @@ class AdjustmentCreateIn(BaseModel):
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
source_ledger_id: int | None = Field(default=None, ge=1, description="分配差额所对应的已生成工资台账")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
|
||||
@ -77,6 +77,8 @@ class TPlusBatchOut(BaseModel):
|
||||
finished_at: datetime | None
|
||||
can_retry: bool
|
||||
error_summary: str | None = None
|
||||
trigger_type: str = "MANUAL"
|
||||
automatic_attempt: int = 0
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
@ -97,6 +99,25 @@ class TPlusErrorOut(BaseModel):
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class TPlusAutomationAlert(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
batch_no: str | None = None
|
||||
|
||||
|
||||
class TPlusAutomationOverview(BaseModel):
|
||||
enabled: bool = False
|
||||
weekday: int = 0
|
||||
time: str = "02:00"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
worker_phase: str = "UNKNOWN"
|
||||
heartbeat_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
next_retry_at: datetime | None = None
|
||||
stop_requested: bool = False
|
||||
alerts: list[TPlusAutomationAlert] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TPlusOverviewOut(BaseModel):
|
||||
"""配置就绪不等同于源库连通或兼容验收通过。"""
|
||||
|
||||
@ -107,3 +128,4 @@ class TPlusOverviewOut(BaseModel):
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
automation: TPlusAutomationOverview = Field(default_factory=TPlusAutomationOverview)
|
||||
|
||||
253
backend/app/schemas/tplus_workbench.py
Normal file
253
backend/app/schemas/tplus_workbench.py
Normal 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)
|
||||
@ -93,6 +93,13 @@ def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
|
||||
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
|
||||
的可见性一致);财务/领导只看到本节点可操作的条目。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
return ()
|
||||
codes = set(role_codes)
|
||||
if ROLE_ADMIN in codes:
|
||||
return _PENDING_STATUSES
|
||||
@ -129,8 +136,14 @@ def submit_order(
|
||||
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
||||
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation and (
|
||||
not get_settings().payroll_approval_enabled or not is_enabled(session, order_type)
|
||||
):
|
||||
raise BadRequestError("审核功能未开启,不创建审批单或自动通过痕迹")
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
order = ApprovalOrder(
|
||||
order_no=next_no(
|
||||
session,
|
||||
@ -250,6 +263,13 @@ def act(
|
||||
"""
|
||||
codes = set(user_repository.get_role_codes(operator))
|
||||
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
raise BadRequestError("当前工资流程无需系统审批,历史记录仅供查看")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
|
||||
@ -11,11 +11,13 @@ from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
|
||||
from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -143,7 +145,9 @@ def login(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes,
|
||||
can_write_payroll=can_write_payroll(user),
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -299,6 +299,10 @@ def _build_allocation_rows(
|
||||
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
|
||||
自分份额则双重计入)
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if items and not old_ids and not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("新核算按每人发货对应绩效,请在正式核算资料中维护发货归属,不再录入任意比例")
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
@ -476,6 +480,10 @@ def update_business(
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
|
||||
"""
|
||||
from app.services import payroll_months
|
||||
|
||||
allocation_month = payroll_months.current_month()
|
||||
payroll_months.lock_month(session, allocation_month, operator)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
business = row[0] if row else None
|
||||
with audit_service.audit(
|
||||
@ -527,6 +535,12 @@ def update_business(
|
||||
payload.secondary_allocations,
|
||||
old_ids={a.personnel_id for a in old_allocations},
|
||||
)
|
||||
if _plan_changed(old_allocations, new_rows):
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("历史比例方案仅供查看,新分配请维护逐笔发货归属")
|
||||
payroll_months.ensure_allocation_editable(session, allocation_month, operator)
|
||||
if not payload.secondary_allocations:
|
||||
business_repository.replace_allocations(session, business_id, [])
|
||||
elif _plan_changed(old_allocations, new_rows):
|
||||
@ -539,7 +553,9 @@ def update_business(
|
||||
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
|
||||
# 刷出「撤回+新建」的审批留痕噪音)
|
||||
# - 其余 → 提新单(内含撤旧建新)
|
||||
if payload.secondary_allocations:
|
||||
from app.core.config import get_settings
|
||||
|
||||
if payload.secondary_allocations and get_settings().payroll_allow_legacy_calculation:
|
||||
snapshot = approval_service.allocation_content(session, business) or None
|
||||
if not approval_service.pending_matches_snapshot(
|
||||
session,
|
||||
@ -563,7 +579,7 @@ def update_business(
|
||||
if _is_duplicate_entry(exc):
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
raise
|
||||
else:
|
||||
elif get_settings().payroll_allow_legacy_calculation:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
|
||||
@ -95,6 +95,18 @@ def calc_receipt(
|
||||
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
from app.services import payroll_calculation
|
||||
|
||||
receipt = session.get(Receipt, receipt_id)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if payroll_calculation.latest_input(session, receipt.business_id):
|
||||
result = payroll_calculation.calculate(session, receipt.business_id,
|
||||
receipt.receipt_date.strftime("%Y-%m"), operator=operator, meta=meta, reason=recalc_reason)
|
||||
return CalcReceiptOut(ledger=_to_out(session.get(PerformanceLedger, result["ledger_ids"][0])), repeated=False)
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("请在业务详情的正式核算入口准备资料;旧单批次核算已停用")
|
||||
is_recalc = recalc_reason is not None
|
||||
action = "重算" if is_recalc else "核算"
|
||||
with audit_service.audit(
|
||||
|
||||
@ -98,6 +98,11 @@ def collect_team_buckets(
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
snapshot = getattr(row, "snapshot_json", None) or {}
|
||||
if snapshot.get("engine") == "FIFO":
|
||||
if not snapshot.get("manager_own_excluded") and snapshot.get("manager_id") is not None:
|
||||
add_team_performance(managers, int(snapshot["manager_id"]), row)
|
||||
continue
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
add_team_performance(managers, row.personnel_id, row)
|
||||
@ -118,7 +123,8 @@ def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decima
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if include_manager_personal or r.customer_type != "manager_personal"
|
||||
if not (getattr(r, "snapshot_json", None) or {}).get("manager_own_excluded")
|
||||
and (include_manager_personal or r.customer_type != "manager_personal")
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
16
backend/app/services/payroll_access.py
Normal file
16
backend/app/services/payroll_access.py
Normal 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
|
||||
420
backend/app/services/payroll_calculation.py
Normal file
420
backend/app/services/payroll_calculation.py
Normal 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,
|
||||
)
|
||||
177
backend/app/services/payroll_inputs.py
Normal file
177
backend/app/services/payroll_inputs.py
Normal 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
|
||||
47
backend/app/services/payroll_months.py
Normal file
47
backend/app/services/payroll_months.py
Normal 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
|
||||
@ -154,6 +154,16 @@ def department_summary(session, query) -> dict:
|
||||
manager_of, manager_role, person_info = _team_relations(
|
||||
session, member_map, manager_ids, adjustments
|
||||
)
|
||||
attributed_managers = {mid for a in adjustments.values() for mid in a.get("by_manager", {}) if mid is not None}
|
||||
manager_ids = sorted(set(manager_ids) | attributed_managers)
|
||||
frozen_rows = rows if any((getattr(r, "snapshot_json", None) or {}).get("engine") == "FIFO" for r in rows) or attributed_managers else None
|
||||
for mid in attributed_managers:
|
||||
manager_role[mid] = "sales_manager"
|
||||
if frozen_rows is not None:
|
||||
for row in frozen_rows:
|
||||
snapshot = row.snapshot_json or {}
|
||||
if snapshot.get("engine") == "FIFO" and snapshot.get("manager_id") is not None:
|
||||
manager_role[int(snapshot["manager_id"])] = "sales_manager"
|
||||
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
|
||||
_merge_adjustment_only_members(member_map, adjustments, person_info)
|
||||
|
||||
@ -167,6 +177,7 @@ def department_summary(session, query) -> dict:
|
||||
"include_mp": include_mp,
|
||||
"share": share,
|
||||
"company_base": company_base,
|
||||
"frozen_rows": frozen_rows,
|
||||
}
|
||||
teams_all = _build_teams(session, manager_ids, context)
|
||||
if query.manager_id is None:
|
||||
@ -186,7 +197,10 @@ def department_summary(session, query) -> dict:
|
||||
for team in teams_all
|
||||
if query.manager_id is None or team["manager_id"] == query.manager_id
|
||||
]
|
||||
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
|
||||
unassigned_all = (
|
||||
_frozen_member_rows(None, context) if frozen_rows is not None
|
||||
else _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
|
||||
)
|
||||
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
||||
unassigned = [] if query.manager_id is not None else unassigned_all
|
||||
company_share_amount = _company_share(company_base, share.company_pct)
|
||||
@ -219,6 +233,47 @@ def department_summary(session, query) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _frozen_member_rows(manager_id, context):
|
||||
"""按台账固化的业务分管拆成员,同一业务员可分别出现在多个团队。"""
|
||||
targets = {}
|
||||
source_rows = context["frozen_rows"]
|
||||
for row in source_rows:
|
||||
snap = row.snapshot_json or {}
|
||||
if snap.get("engine") == "FIFO":
|
||||
target = snap.get("manager_id") if not snap.get("manager_own_excluded") else None
|
||||
else:
|
||||
target = _regular_team_of(row.personnel_id, context["manager_of"], context["manager_role"])
|
||||
if row.customer_type == "manager_personal":
|
||||
target = row.personnel_id if context["include_mp"] else None
|
||||
targets[row.id] = target
|
||||
selected = [r for r in source_rows if targets[r.id] == manager_id]
|
||||
members = _member_map(selected)
|
||||
local_adjustments = {}
|
||||
for pid, adjustment in context["adjustments"].items():
|
||||
candidates = {targets[r.id] for r in source_rows if r.personnel_id == pid}
|
||||
present = sorted(t for t in candidates if t is not None)
|
||||
default_target = present[0] if present else (
|
||||
None if candidates else _regular_team_of(pid, context["manager_of"], context["manager_role"])
|
||||
)
|
||||
totals = {"extra": Decimal(0), "deduct": Decimal(0)}
|
||||
selected_adjustment = adjustment.get("by_manager", {}).get(manager_id, {})
|
||||
for field in totals:
|
||||
totals[field] += selected_adjustment.get(field, Decimal(0))
|
||||
if default_target == manager_id:
|
||||
totals[field] += adjustment.get("unattributed", adjustment)[field]
|
||||
local_adjustments[pid] = totals
|
||||
if any(totals.values()) and pid not in members:
|
||||
members[pid] = context["member_map"][pid]
|
||||
output = []
|
||||
for pid, member in members.items():
|
||||
actual = [r for r in selected if r.personnel_id == pid]
|
||||
output.append(_member_row(pid, member, local_adjustments, "regular",
|
||||
performance=sum((r.performance_amount or Decimal(0) for r in actual), Decimal(0)),
|
||||
payable=sum((r.payable_amount or Decimal(0) for r in actual), Decimal(0)),
|
||||
ledger_ids=[r.id for r in actual], include_adjust=True))
|
||||
return output
|
||||
|
||||
|
||||
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
|
||||
"""按经理枚举生成团队行(含零成员团队)。"""
|
||||
teams = []
|
||||
@ -243,6 +298,8 @@ def _build_team(session, manager_id: int, context: dict) -> dict | None:
|
||||
context["adjustments"],
|
||||
context["include_mp"],
|
||||
)
|
||||
if context.get("frozen_rows") is not None:
|
||||
member_rows = _frozen_member_rows(manager_id, context)
|
||||
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
|
||||
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
|
||||
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
|
||||
@ -397,12 +454,17 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
|
||||
"extra": Decimal("0"),
|
||||
"deduct": Decimal("0"),
|
||||
"personnel_name": order.personnel_name,
|
||||
"by_manager": {},
|
||||
"unattributed": {"extra": Decimal(0), "deduct": Decimal(0)},
|
||||
},
|
||||
)
|
||||
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
||||
acc["extra"] += order.amount
|
||||
else:
|
||||
acc["deduct"] += order.amount
|
||||
attribution = getattr(order, "attribution_json", None)
|
||||
bucket = acc["by_manager"].setdefault(attribution.get("manager_id"), {"extra": Decimal(0), "deduct": Decimal(0)}) if attribution else acc["unattributed"]
|
||||
bucket["extra" if order.direction == ADJUSTMENT_DIRECTION_EXTRA else "deduct"] += order.amount
|
||||
return by_person
|
||||
|
||||
|
||||
|
||||
@ -82,6 +82,9 @@ def generate(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
from app.services import payroll_months
|
||||
|
||||
control = payroll_months.lock_month(session, month, operator)
|
||||
existing = settlement_repository.get_by_month(session, month)
|
||||
_guard_regeneration(existing, month)
|
||||
_release_previous_round(session, existing, month)
|
||||
@ -103,6 +106,29 @@ def generate(
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||
# 记成失败审计,与库中事实相反)
|
||||
if control.generated_at is None:
|
||||
from datetime import datetime
|
||||
|
||||
control.generated_at = datetime.now()
|
||||
from app.core.config import get_settings
|
||||
|
||||
approval_enabled = get_settings().payroll_allow_legacy_calculation or (
|
||||
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 2)
|
||||
)
|
||||
settlement.snapshot_json = {
|
||||
**settlement.snapshot_json,
|
||||
"approval_required": approval_enabled,
|
||||
}
|
||||
if not approval_enabled:
|
||||
# 2026-10-30 用户澄清:生成工资即完成,不建审批单或自动通过痕迹。
|
||||
settlement.status = SETTLEMENT_STATUS_APPROVED
|
||||
settlement.locked_at = control.generated_at
|
||||
settlement.locked_by = control.maker_name
|
||||
settlement.approval_order_no = None
|
||||
settlement.snapshot_json = {
|
||||
"note": "工资生成即完成并锁定,仅供查看,无需系统审核",
|
||||
"approval_required": False,
|
||||
}
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
@ -239,6 +265,13 @@ def submit(
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettlementDetailOut:
|
||||
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
raise BadRequestError("工资生成后直接完成,仅供查看,无需系统审批")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@ -456,7 +489,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
if manager.business_role_id
|
||||
else None
|
||||
)
|
||||
if role is None or role.role_code != "sales_manager":
|
||||
frozen_manager = any(
|
||||
(r.snapshot_json or {}).get("engine") == "FIFO"
|
||||
and (r.snapshot_json or {}).get("manager_id") == manager_id
|
||||
for r in ledger_rows
|
||||
)
|
||||
if not frozen_manager and (role is None or role.role_code != "sales_manager"):
|
||||
continue # 非营销经理的上级不参与聚合
|
||||
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||
return aggregates
|
||||
@ -509,6 +547,47 @@ def create_adjustment(
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
amount = _adjustment_amount(payload)
|
||||
from app.core.config import get_settings
|
||||
from app.models import PerformanceLedger
|
||||
from app.services import payroll_months
|
||||
|
||||
source_id = getattr(payload, "source_ledger_id", None)
|
||||
source = session.get(PerformanceLedger, source_id) if source_id else None
|
||||
if source_id and (source is None or source.settlement_id is None):
|
||||
raise BadRequestError("分配调整必须引用已生成工资的原台账")
|
||||
if source_id:
|
||||
from app.models import PayrollInput
|
||||
|
||||
snapshot = source.snapshot_json or {}
|
||||
if source.calc_type != 1 or source.is_active != 1 or snapshot.get("engine") != "FIFO":
|
||||
raise BadRequestError("分配调整必须引用有效的正式FIFO单笔台账")
|
||||
original = session.get(PayrollInput, snapshot.get("input_id"))
|
||||
if original is None:
|
||||
raise BadRequestError("原台账的归属依据缺失")
|
||||
allowed = {source.personnel_id}
|
||||
latest = session.scalar(
|
||||
select(PayrollInput)
|
||||
.where(PayrollInput.business_id == source.business_id)
|
||||
.order_by(PayrollInput.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
values = (latest or original).data_json
|
||||
business_key = snapshot.get("event", {}).get("business_key")
|
||||
for handover in values.get("facts", {}).get("handovers", []):
|
||||
if handover.get("business_key") == business_key:
|
||||
for field in ("from_personnel_id", "to_personnel_id"):
|
||||
target = (
|
||||
values.get("maps", {}).get("personnel", {}).get(str(handover.get(field)))
|
||||
)
|
||||
if target is not None:
|
||||
allowed.add(target)
|
||||
if payload.personnel_id not in allowed:
|
||||
raise BadRequestError("调整人员不属于该业务已登记的归属或交接范围")
|
||||
adjustment_month = (
|
||||
payroll_months.current_month()
|
||||
if source_id or not get_settings().payroll_allow_legacy_calculation
|
||||
else payload.settlement_month
|
||||
)
|
||||
|
||||
person = session.get(Personnel, payload.personnel_id)
|
||||
if person is None:
|
||||
@ -532,7 +611,16 @@ def create_adjustment(
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_ADJUSTMENT,
|
||||
),
|
||||
settlement_month=payload.settlement_month,
|
||||
settlement_month=adjustment_month,
|
||||
adjustment_type="ALLOCATION" if source_id else "GENERAL",
|
||||
source_ledger_id=source_id,
|
||||
attribution_json={
|
||||
"business_id": source.business_id,
|
||||
"manager_id": (source.snapshot_json or {}).get("manager_id"),
|
||||
"original_personnel_id": source.personnel_id,
|
||||
}
|
||||
if source
|
||||
else None,
|
||||
personnel_id=payload.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=payload.direction,
|
||||
@ -544,7 +632,14 @@ def create_adjustment(
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
if get_settings().payroll_allow_legacy_calculation or (
|
||||
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3)
|
||||
):
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
else:
|
||||
from app.models.settlement import ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||
session.commit()
|
||||
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# T+ 只读同步
|
||||
# T+ 读取、基础导入与核算预演
|
||||
|
||||
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
|
||||
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
|
||||
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
|
||||
- `source_heads/snapshots` 记录全量读取的当前源版本,包含哈希去重的 A→B→A;中断任务不发布部分指针。
|
||||
- `master_import` 按人员、产品、客户预览及导入;源、事实、目标变化时拒绝旧预览,保留人工核算字段。
|
||||
- `workbench` 保存人工事实版本、幂等任务和逐行结果,与原 worker 共用锁;`fifo/calculation_preview` 固定快照并计算隔离预演,支持原快照重跑。
|
||||
|
||||
## 主要入口
|
||||
|
||||
@ -20,4 +23,20 @@
|
||||
|
||||
## 验收限制
|
||||
|
||||
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
|
||||
本轮允许已确认的人员、产品、客户进入基础表;不写正式业务、发货、回款、绩效台账、月结、审批或发放记录,不推进时间水位。源库始终只读。日期、含税和人工指导成本已明确;生成凭证资格已按ARAP01来源与有效总账凭证关联核实,未审不等于未生成;平账范围、取价时点、历史余额、特殊更正仍按记录分别待补。
|
||||
|
||||
Docker 是当前开发主线,2008 R2 `10.50.1600` 验收后置;2022 副本不能替代同版本实测。实际导入结果、接口、缺驱动处理及启动方法见 `docs/T+基础导入与核算预演开发记录_2026-10-29.md`,字段证据见同日字段与规则差异表。
|
||||
|
||||
## 正式工资闭环扩展(2026-10-30,优先于上方上一阶段边界)
|
||||
|
||||
已新增 BUSINESS_PREVIEW/BUSINESS_APPLY,允许核实后导入正式业务、发货和回款,正式核算通过独立工资入口执行。源 SQL Server 仍始终只读。新版按同一发货客户名头统一平账、按发货月份指导成本封存;不再按合同拆组或要求重复确认成本月份。
|
||||
|
||||
制作人身份由现有账号ID显式配置;审核能力保留但默认关闭,关闭时工资生成直接完成且不写审批步骤。多原单更正仍待讨论。字段、接口、迁移和最终验收边界见 `docs/正式工资闭环开发记录_2026-10-30.md`,客户回执原文见 `docs/客户补充回执核对_2026-10-30.md`。
|
||||
|
||||
## 自动采集与样本核对(2026-10-30)
|
||||
|
||||
`automation.tick` 通过 `tplus-maintain --once` 排队最近到期周的九类原始采集,默认每周一北京时间 02:00 且关闭。明确断连/超时最多两次自动重试,间隔 5/15 分钟;部分失败、配置/资料错误及正式导入不自动重试。人工重试结果接入同一周期,独立心跳不受长查询阻塞。概况的 `automation` 提供状态及异常,不暴露连接凭据。
|
||||
|
||||
`tplus-materials --customer-id --month --connection-file --output-dir` 从恢复库只读形成样本核对包;空费用和旧初始化只能作为证据,不能推定零运费/切换余额。用户没有首轮样本人工指导成本,真实金额验收待补。
|
||||
|
||||
V20/V21 迁移 head 为 `21f2a3b4c5d6`,现有迁移保留,开发初始化同步。Windows 托管、默认关闭及停止边界见根目录 `scripts/windows/README.md`;本轮完整验证和未验收事项见 `docs/同步运维自动化与样本核对开发记录_2026-10-30.md`。未迁移原业务库或启用生产。
|
||||
|
||||
273
backend/app/services/tplus/automation.py
Normal file
273
backend/app/services/tplus/automation.py
Normal 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
|
||||
407
backend/app/services/tplus/business_import.py
Normal file
407
backend/app/services/tplus/business_import.py
Normal 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"
|
||||
541
backend/app/services/tplus/calculation_preview.py
Normal file
541
backend/app/services/tplus/calculation_preview.py
Normal 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"
|
||||
)
|
||||
35
backend/app/services/tplus/errors.py
Normal file
35
backend/app/services/tplus/errors.py
Normal 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
|
||||
355
backend/app/services/tplus/fifo.py
Normal file
355
backend/app/services/tplus/fifo.py
Normal 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,
|
||||
)
|
||||
),
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
"""T+ 字段映射元数据。
|
||||
|
||||
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
|
||||
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
|
||||
高风险口径在客户确认前保持 ``PENDING``;源读取只写 staging,基础导入另经事实核对和预览。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@ -27,7 +27,7 @@ class TPlusMapping:
|
||||
target_table: str
|
||||
status: MappingStatus
|
||||
unresolved: tuple[str, ...] = ()
|
||||
version: str = "0.2"
|
||||
version: str = "0.5"
|
||||
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
|
||||
"personnel",
|
||||
MappingStatus.CANDIDATE,
|
||||
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
|
||||
("名单缺失编号", "经理分管业务", "入职日期", "试用期"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"products",
|
||||
@ -46,7 +46,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
|
||||
"products",
|
||||
MappingStatus.CANDIDATE,
|
||||
("库存数量语义", "参考价来源", "系统产品分类"),
|
||||
("人工核算分类", "指导价取值时点"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"customers",
|
||||
@ -54,7 +54,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
|
||||
"customers",
|
||||
MappingStatus.CANDIDATE,
|
||||
("客户唯一键", "改名/合并规则", "系统客户分类"),
|
||||
("源客户属性为空", "客户负责人资料"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"businesses",
|
||||
@ -62,7 +62,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
|
||||
"businesses",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "业务负责人覆盖规则", "含税口径"),
|
||||
("指导价取值时点", "业务交接依据", "平账范围"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipments",
|
||||
@ -79,7 +79,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
),
|
||||
"shipments",
|
||||
MappingStatus.PENDING,
|
||||
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
|
||||
("退货/红冲规则", "运费实际金额", "发货归属"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipts",
|
||||
@ -96,7 +96,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
|
||||
("凭证异常例单复核", "特殊款项识别", "平账范围"),
|
||||
),
|
||||
)
|
||||
|
||||
@ -107,7 +107,7 @@ DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "idSaleOrderDTO", "LatestCost"),
|
||||
"business_items",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "含税口径"),
|
||||
("不导入T+成本,仅保留追溯快照",),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipment_items",
|
||||
|
||||
288
backend/app/services/tplus/master_import.py
Normal file
288
backend/app/services/tplus/master_import.py
Normal 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"
|
||||
84
backend/app/services/tplus/materials.py
Normal file
84
backend/app/services/tplus/materials.py
Normal 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)
|
||||
@ -11,6 +11,8 @@ from app.schemas.tplus import (
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import overview as automation_overview
|
||||
from app.services.tplus.errors import MESSAGES
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.relationship_audit import public_report
|
||||
|
||||
@ -22,6 +24,7 @@ ERROR_MESSAGES = {
|
||||
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
|
||||
}
|
||||
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
|
||||
ERROR_MESSAGES.update(MESSAGES)
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
@ -51,6 +54,8 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
error_summary="任务有异常,请查看异常明细"
|
||||
if batch.status in ("FAILED", "PARTIAL")
|
||||
else None,
|
||||
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
|
||||
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
|
||||
)
|
||||
|
||||
|
||||
@ -119,4 +124,5 @@ def overview(session) -> TPlusOverviewOut:
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
automation=automation_overview(session, settings=task_service.get_settings()),
|
||||
)
|
||||
|
||||
@ -29,10 +29,22 @@ _COLUMNS = {
|
||||
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
|
||||
}
|
||||
PENDING_CONFIRMATIONS = (
|
||||
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
|
||||
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
|
||||
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
|
||||
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
|
||||
(
|
||||
"cost_basis",
|
||||
"月度指导成本",
|
||||
"采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确",
|
||||
),
|
||||
(
|
||||
"voucher_state",
|
||||
"凭证例外核对",
|
||||
"已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替",
|
||||
),
|
||||
(
|
||||
"opening_balance",
|
||||
"历史承接",
|
||||
"发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供",
|
||||
),
|
||||
("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"),
|
||||
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
|
||||
)
|
||||
|
||||
|
||||
85
backend/app/services/tplus/report_labels.py
Normal file
85
backend/app/services/tplus/report_labels.py
Normal 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
|
||||
53
backend/app/services/tplus/snapshots.py
Normal file
53
backend/app/services/tplus/snapshots.py
Normal 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)))
|
||||
59
backend/app/services/tplus/source_heads.py
Normal file
59
backend/app/services/tplus/source_heads.py
Normal 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
|
||||
@ -11,8 +11,10 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import get_settings
|
||||
from app.core.tplus_lock import sync_lock
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error
|
||||
from app.services.tplus.mapping import TPlusMapping, get_mapping
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
from app.services.tplus.source_heads import observe_rows
|
||||
from app.services.tplus.staging import stage_rows
|
||||
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
|
||||
|
||||
@ -74,13 +76,18 @@ class TPlusSyncService:
|
||||
if batch.status != "RUNNING" or not batch.entity_types_json:
|
||||
raise ValueError("任务尚未领取或读取范围不完整")
|
||||
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
|
||||
interrupted = False
|
||||
for mapping in mappings:
|
||||
try:
|
||||
self._read_entity(batch, mapping, batch_size)
|
||||
except Exception as exc:
|
||||
self.target_session.rollback()
|
||||
self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__)
|
||||
code = classify_source_error(exc)
|
||||
self._record_error(batch, mapping, {}, code, type(exc).__name__)
|
||||
self.target_session.commit()
|
||||
if code in TRANSIENT_CODES:
|
||||
interrupted = True
|
||||
break
|
||||
batch.status = "SUCCEEDED"
|
||||
if batch.failed_record_count:
|
||||
failed_rows = (
|
||||
@ -94,7 +101,8 @@ class TPlusSyncService:
|
||||
)
|
||||
or 0
|
||||
)
|
||||
batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED"
|
||||
# 中途断连仍是未完成的全量采集;保留已暂存页,重跑由哈希防重。
|
||||
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
|
||||
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
||||
batch.finished_at = datetime.now()
|
||||
batch.watermark_to = None
|
||||
@ -106,12 +114,20 @@ class TPlusSyncService:
|
||||
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
|
||||
for chunk in batched(source, size):
|
||||
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
|
||||
observe_rows(self.target_session, batch, mapping, list(chunk))
|
||||
batch.source_record_count += len(chunk)
|
||||
batch.staged_record_count += inserted
|
||||
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
||||
for row, reason in errors:
|
||||
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
|
||||
self.target_session.commit()
|
||||
report = dict(batch.audit_result_json or {})
|
||||
report["source_head_version"] = 1
|
||||
report["observed_entities"] = list(
|
||||
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
|
||||
)
|
||||
batch.audit_result_json = report
|
||||
self.target_session.commit()
|
||||
|
||||
def _record_error(
|
||||
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
|
||||
|
||||
@ -16,6 +16,7 @@ from app.repositories import tplus_repository as repository
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.tplus.errors import MESSAGES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -164,6 +165,7 @@ def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
|
||||
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
|
||||
}
|
||||
message = messages.get(code, "任务未完成,请核对运行配置")
|
||||
message = MESSAGES.get(code, message)
|
||||
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
|
||||
batch.failed_record_count = (batch.failed_record_count or 0) + 1
|
||||
session.add(
|
||||
|
||||
295
backend/app/services/tplus/workbench.py
Normal file
295
backend/app/services/tplus/workbench.py
Normal 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):
|
||||
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
|
||||
@ -7,14 +7,25 @@ from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import WorkerHeartbeat
|
||||
from app.services.tplus.errors import classify_source_error
|
||||
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
def run_once(session, source_factory=None):
|
||||
def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||
try:
|
||||
with sync_lock(session):
|
||||
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.models import TPlusWorkRun
|
||||
from app.services.tplus.workbench import execute_next
|
||||
|
||||
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
|
||||
number = execute_next(session)
|
||||
if number:
|
||||
return number
|
||||
task_service.recover_running(session)
|
||||
batch = repository.next_queued(session)
|
||||
if batch is None:
|
||||
@ -30,10 +41,12 @@ def run_once(session, source_factory=None):
|
||||
|
||||
|
||||
def _execute(session, batch, source_factory):
|
||||
configured = False
|
||||
try:
|
||||
settings = resolve_tplus_settings(task_service.get_settings())
|
||||
if batch.source_database and batch.source_database != settings.t_plus_database:
|
||||
raise RuntimeError("任务排队后的源账套配置已改变")
|
||||
configured = True
|
||||
factory = source_factory or get_tplus_session_factory()
|
||||
with factory() as source:
|
||||
if batch.sync_mode == "AUDIT":
|
||||
@ -55,7 +68,7 @@ def _execute(session, batch, source_factory):
|
||||
batch,
|
||||
"INTERRUPTED"
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit))
|
||||
else "EXECUTION_ERROR",
|
||||
else classify_source_error(exc, "EXECUTION_ERROR" if configured else "SOURCE_CONFIG_ERROR"),
|
||||
)
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
|
||||
@ -316,3 +316,13 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
|
||||
## 2026-10-30 正式工资闭环与新回执验证
|
||||
|
||||
新增 `test_formal_payroll.py`(单元与MySQL集成)、隔离库保护用例;旧基础数据用例显式使用writer身份,历史公式/比例审批兼容用例显式启用legacy_calculation,不用测试放宽生产授权。
|
||||
|
||||
最终完整单元749项通过。隔离完整集成基线396通过、1个旧夹具权限失败,已修复并在75项受影响模块回归中通过;不将此记录冒充最终代码一次性全量集成通过。V18/V19迁移往返及结构9项通过。覆盖默认无审批痕迹、可选审核开启、客户名头合并FIFO、发货月成本封存、逐段金额与防重、未生成工资月份级联重算、固化分管和当月调整。详细证据见 `docs/正式工资闭环开发记录_2026-10-30.md`。
|
||||
|
||||
**禁止直接对原业务库执行集成测试。** 运行 `python scripts/run_isolated_tests.py` 创建受限测试库;夹具在执行前及清理前核验显式标记、名称及真实引擎目标。运行器保留隔离库便于复查,不打印临时凭据。
|
||||
|
||||
最新完整集成补验:全部29个测试文件分两套独立数据库覆盖,215+184=399项通过,失败0、跳过0。上述旧夹具失败已经修复并关闭;最终结果以本条和开发记录末节为准。完整单元749通过,V18/V19往返及结构9通过。
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@ -81,7 +82,12 @@ def _purge_test_data() -> None:
|
||||
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
|
||||
会把 tn... 这类无关账号一并卷入。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
from tests.isolation import ensure_test_database
|
||||
|
||||
ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
factory = get_session_factory()
|
||||
ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
with factory() as session:
|
||||
users = list(
|
||||
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
|
||||
@ -104,6 +110,11 @@ def _purge_test_data() -> None:
|
||||
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
|
||||
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
|
||||
# 的单据与人员清理;仅测试写入,全量清)----
|
||||
from app.models import PayrollAllocation, PayrollInput, PayrollMonth, TPlusAutomationState
|
||||
session.execute(delete(TPlusAutomationState))
|
||||
session.execute(delete(PayrollAllocation))
|
||||
session.execute(delete(PayrollInput))
|
||||
session.execute(delete(PayrollMonth))
|
||||
session.execute(delete(PerformanceLedger))
|
||||
session.execute(delete(SettlementItem))
|
||||
session.execute(delete(MonthlySettlement))
|
||||
@ -216,6 +227,11 @@ def _purge_test_data() -> None:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test():
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
||||
from app.core.config import get_settings
|
||||
from tests.isolation import ensure_test_database
|
||||
|
||||
ensure_test_database(get_settings().mysql_database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
ensure_test_database(get_session_factory().kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE"))
|
||||
yield
|
||||
_purge_test_data()
|
||||
|
||||
@ -311,9 +327,11 @@ def make_role():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin(make_user, auth_headers) -> dict:
|
||||
def admin(make_user, auth_headers, monkeypatch) -> dict:
|
||||
"""管理员账号(含请求头)。"""
|
||||
user = make_user([ROLE_ADMIN])
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user["id"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
@ -357,3 +375,18 @@ def tplus_tasks(monkeypatch):
|
||||
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
||||
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_calculation(monkeypatch):
|
||||
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def writer(normal, admin, monkeypatch):
|
||||
"""指定一个普通角色账号为工资制作人,证明身份授权与管理员角色无关。"""
|
||||
from app.core.config import get_settings
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", normal["id"])
|
||||
return normal
|
||||
|
||||
@ -22,7 +22,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_APPROVALS = "/api/v1/system/approvals"
|
||||
|
||||
@ -33,7 +33,7 @@ from tests.integration.test_approval_flow import (
|
||||
_set_switch,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -91,11 +91,11 @@ def _log_count(username: str, *, success: bool) -> int:
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _baseline(normal: dict) -> tuple[int, int]:
|
||||
def _baseline(writer: dict) -> tuple[int, int]:
|
||||
"""POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。"""
|
||||
return (
|
||||
_log_count(normal["username"], success=True),
|
||||
_log_count(normal["username"], success=False),
|
||||
_log_count(writer["username"], success=True),
|
||||
_log_count(writer["username"], success=False),
|
||||
)
|
||||
|
||||
|
||||
@ -103,12 +103,12 @@ class TestAuditRetry:
|
||||
"""重试成功与持续失败两条路径的审计记录数。"""
|
||||
|
||||
def test_首次撞号重试成功_审计恰一条真成功(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
customer_id = _customer(client, writer["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
@ -123,25 +123,25 @@ class TestAuditRetry:
|
||||
return real_generate(session)
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", flaky_generate)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
base_ok, base_fail = _baseline(writer)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||
# 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功
|
||||
# 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0
|
||||
assert _log_count(normal["username"], success=True) == base_ok + 1
|
||||
assert _log_count(normal["username"], success=False) == base_fail
|
||||
assert _log_count(writer["username"], success=True) == base_ok + 1
|
||||
assert _log_count(writer["username"], success=False) == base_fail
|
||||
|
||||
def test_持续冲突_最终失败且审计恰一条失败(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
customer_id = _customer(client, normal["headers"])
|
||||
customer_id = _customer(client, writer["headers"])
|
||||
first = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["business_no"]
|
||||
@ -150,10 +150,10 @@ class TestAuditRetry:
|
||||
return existing_no
|
||||
|
||||
monkeypatch.setattr(business_service, "_generate_no", always_conflict)
|
||||
base_ok, base_fail = _baseline(normal)
|
||||
base_ok, base_fail = _baseline(writer)
|
||||
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=writer["headers"]
|
||||
)
|
||||
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||
@ -161,8 +161,8 @@ class TestAuditRetry:
|
||||
assert "完整性冲突" in second.json()["message"]
|
||||
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
assert _log_count(normal["username"], success=True) == base_ok
|
||||
assert _log_count(writer["username"], success=False) == base_fail + 1
|
||||
assert _log_count(writer["username"], success=True) == base_ok
|
||||
|
||||
|
||||
def _receipt_log_count(username: str, *, success: bool) -> int:
|
||||
@ -199,12 +199,12 @@ class TestReceiptAuditRetry:
|
||||
"""
|
||||
|
||||
def test_首次撞号重试成功_审计恰一条真成功(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
business = create_business(client, normal["headers"])
|
||||
business = create_business(client, writer["headers"])
|
||||
first = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||
@ -219,24 +219,24 @@ class TestReceiptAuditRetry:
|
||||
return real_generate(session)
|
||||
|
||||
monkeypatch.setattr(receipt_service, "_generate_no", flaky_generate)
|
||||
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||
base_ok = _receipt_log_count(writer["username"], success=True)
|
||||
base_fail = _receipt_log_count(writer["username"], success=False)
|
||||
|
||||
second = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert calls["n"] == 2, "重试应恰好再取一次号"
|
||||
assert _receipt_log_count(normal["username"], success=True) == base_ok + 1
|
||||
assert _receipt_log_count(normal["username"], success=False) == base_fail
|
||||
assert _receipt_log_count(writer["username"], success=True) == base_ok + 1
|
||||
assert _receipt_log_count(writer["username"], success=False) == base_fail
|
||||
|
||||
def test_持续冲突_最终失败且审计恰一条失败(
|
||||
self, quiet_client: TestClient, normal: dict, monkeypatch
|
||||
self, quiet_client: TestClient, writer: dict, monkeypatch
|
||||
) -> None:
|
||||
client = quiet_client
|
||||
business = create_business(client, normal["headers"])
|
||||
business = create_business(client, writer["headers"])
|
||||
first = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
existing_no = first.json()["data"]["receipt_batch_no"]
|
||||
@ -245,12 +245,12 @@ class TestReceiptAuditRetry:
|
||||
return existing_no
|
||||
|
||||
monkeypatch.setattr(receipt_service, "_generate_no", always_conflict)
|
||||
base_ok = _receipt_log_count(normal["username"], success=True)
|
||||
base_fail = _receipt_log_count(normal["username"], success=False)
|
||||
base_ok = _receipt_log_count(writer["username"], success=True)
|
||||
base_fail = _receipt_log_count(writer["username"], success=False)
|
||||
|
||||
second = client.post(
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=normal["headers"]
|
||||
_RECEIPTS, json=_receipt_payload(business["id"]), headers=writer["headers"]
|
||||
)
|
||||
assert second.status_code == 400, second.text
|
||||
assert _receipt_log_count(normal["username"], success=False) == base_fail + 1
|
||||
assert _receipt_log_count(normal["username"], success=True) == base_ok
|
||||
assert _receipt_log_count(writer["username"], success=False) == base_fail + 1
|
||||
assert _receipt_log_count(writer["username"], success=True) == base_ok
|
||||
|
||||
@ -43,7 +43,7 @@ from app.models import (
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
@ -199,8 +199,8 @@ class TestCreate:
|
||||
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
|
||||
传值,列表检索与 M3 取数都会错乱。"""
|
||||
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
first = _create(client, headers, customer["id"])
|
||||
second = _create(client, headers, customer["id"])
|
||||
@ -218,18 +218,18 @@ class TestCreate:
|
||||
assert first["status"] == BUSINESS_STATUS_ACTIVE
|
||||
assert first["customer_name"] == customer["company_name"]
|
||||
|
||||
def test_空明细_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_空明细_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, writer: dict) -> None:
|
||||
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
|
||||
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
|
||||
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -250,43 +250,43 @@ class TestCreate:
|
||||
).scalar_one()
|
||||
assert db_amount == Decimal("5.17")
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(quantity="0")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "数量" in resp.json()["message"], resp.text
|
||||
|
||||
def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_非法产品类别_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""类别是折算阈值的行级锚点:脏类别取不到阈值参数。"""
|
||||
customer = _customer(client, normal["headers"])
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(product_category="water")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "产品类别不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "须为有效数字" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售额由服务层固化_入参sale_amount不生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
|
||||
数量×单价计算,否则前端传值直接改业务销售额。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
|
||||
data = _create(client, headers, customer["id"], items=[item])
|
||||
@ -295,18 +295,18 @@ class TestCreate:
|
||||
assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02")
|
||||
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
|
||||
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], is_special=1),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "特殊业务必须填写说明" in resp.json()["message"], resp.text
|
||||
|
||||
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
|
||||
assert data["is_special"] == 1
|
||||
@ -317,8 +317,8 @@ class TestEdit:
|
||||
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
|
||||
数据库 SUM)不一致就说明重算有旁路。"""
|
||||
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -365,10 +365,10 @@ class TestEdit:
|
||||
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
|
||||
|
||||
def test_编辑端点塞owner不生效_归属唯一走ownership(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
someone = _personnel(client, headers)
|
||||
@ -387,10 +387,10 @@ class TestEdit:
|
||||
]
|
||||
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, writer: dict) -> None:
|
||||
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
|
||||
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
_terminate(client, headers, data["id"])
|
||||
@ -412,10 +412,10 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@ -435,8 +435,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@ -452,8 +452,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@ -479,8 +479,8 @@ class TestEdit:
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@ -514,14 +514,14 @@ class TestStatus:
|
||||
assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1)
|
||||
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
|
||||
assert rows[1]["change_reason"] == "客户取消订单"
|
||||
assert rows[1]["operator_name"] == normal["username"]
|
||||
assert rows[1]["operator_name"] == writer["username"]
|
||||
|
||||
|
||||
class TestOwnership:
|
||||
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
|
||||
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
old_owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
|
||||
@ -555,7 +555,7 @@ class TestOwnership:
|
||||
assert row["old_owner_name"] == old_owner["personnel_name"]
|
||||
assert row["new_owner_name"] == new_owner["personnel_name"]
|
||||
assert row["change_reason"] == "人员调动"
|
||||
assert row["operator_name"] == normal["username"]
|
||||
assert row["operator_name"] == writer["username"]
|
||||
|
||||
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
|
||||
factory = get_session_factory()
|
||||
@ -567,8 +567,8 @@ class TestOwnership:
|
||||
f"owner_type 应为业务归属(2):{log.owner_type}"
|
||||
)
|
||||
|
||||
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属未变化_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@ -580,8 +580,8 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"], resp.text
|
||||
|
||||
def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建时负责人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
quit_owner = _personnel(client, headers)
|
||||
off = client.put(
|
||||
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
|
||||
@ -596,8 +596,8 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
|
||||
|
||||
def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属端点负责人员不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
resp = client.put(
|
||||
@ -613,8 +613,8 @@ class TestCustomerProtect:
|
||||
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
|
||||
语义断裂 —— 服务层检查是唯一防线。"""
|
||||
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
other = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
@ -658,8 +658,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@ -693,8 +693,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
new_owner = _personnel(client, headers)
|
||||
@ -745,10 +745,10 @@ class TestGrossProfit:
|
||||
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
|
||||
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
|
||||
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, writer: dict) -> None:
|
||||
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
|
||||
折算 10000 ×(997/1000 − 30/100) = 6970。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -778,10 +778,10 @@ class TestGrossProfit:
|
||||
assert cats[0]["profit_rate"] == "997/1000"
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970")
|
||||
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, writer: dict) -> None:
|
||||
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
|
||||
否则录入期(补价前)打开毛利润视图就是 500。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -805,9 +805,9 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
|
||||
|
||||
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -854,11 +854,11 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
|
||||
|
||||
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
|
||||
self, client: TestClient, normal: dict, threshold_batch
|
||||
self, client: TestClient, writer: dict, threshold_batch
|
||||
) -> None:
|
||||
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
|
||||
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -893,13 +893,13 @@ class TestGrossProfit:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增业务成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
_create(client, headers, customer["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增业务未留操作日志"
|
||||
@ -907,17 +907,17 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert "创建业务" in latest.summary
|
||||
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], owner_personnel_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@ -26,7 +26,7 @@ from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocatio
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_CALC = "/api/v1/calc"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
@ -338,8 +338,8 @@ class TestGuards:
|
||||
assert float(ledger["converted_profit"]) == -2000.0
|
||||
assert float(ledger["performance_amount"]) < 0
|
||||
|
||||
def test_准入_normal403_marketing_ops可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
|
||||
def test_准入_未指定账号拒绝_指定制作人可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch
|
||||
) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
@ -354,6 +354,9 @@ class TestGuards:
|
||||
|
||||
ops = make_user(["marketing_ops"])
|
||||
ops["headers"] = auth_headers(ops)
|
||||
from app.core.config import get_settings
|
||||
assert _calc(client, ops["headers"], receipt["id"]).status_code == 403
|
||||
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", ops["id"])
|
||||
resp = _calc(client, ops["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@ -426,7 +429,8 @@ class TestRecalc:
|
||||
)
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
|
||||
fin = finance
|
||||
assert client.post(f"{_CALC}/receipts/{receipt['id']}/recalc", json={"reason": "非制作人拒绝"}, headers=finance["headers"]).status_code == 403
|
||||
fin = admin
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "成本录入修正"},
|
||||
@ -726,7 +730,7 @@ class TestSecondaryAllocation:
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "用例:分摊重算"},
|
||||
headers=finance["headers"],
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@ -122,38 +122,38 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。"""
|
||||
|
||||
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None:
|
||||
owner = _personnel(client, normal["headers"])
|
||||
data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"])
|
||||
def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, writer: dict) -> None:
|
||||
owner = _personnel(client, writer["headers"])
|
||||
data = _create_customer(client, writer["headers"], owner_personnel_id=owner["id"])
|
||||
assert data["owner_personnel_id"] == owner["id"]
|
||||
assert data["owner_personnel_name"] == owner["personnel_name"]
|
||||
assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL
|
||||
|
||||
def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None:
|
||||
def test_同公司名不同负责人创建成功(self, client: TestClient, writer: dict) -> None:
|
||||
company = _company()
|
||||
first = _create_customer(client, normal["headers"], company_name=company)
|
||||
first = _create_customer(client, writer["headers"], company_name=company)
|
||||
second = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(company, owner_name="李老板"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["data"]["id"] != first["id"]
|
||||
|
||||
def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名同负责人_400(self, client: TestClient, writer: dict) -> None:
|
||||
company = _company()
|
||||
_create_customer(client, normal["headers"], company_name=company)
|
||||
_create_customer(client, writer["headers"], company_name=company)
|
||||
dup = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(company, owner_name="张老板"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "该公司名称与负责人的客户已存在" in dup.json()["message"]
|
||||
|
||||
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, normal: dict) -> None:
|
||||
def test_删除被业务引用的客户_400引导处理单据(self, client: TestClient, writer: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
@ -175,20 +175,20 @@ class TestCrudFlow:
|
||||
assert blocked.status_code == 400, blocked.text
|
||||
assert "已存在关联业务" in blocked.json()["message"]
|
||||
|
||||
def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_客户分类非法_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json=_cpayload(_company(), customer_type="not_a_type"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "客户分类不合法" in resp.json()["message"]
|
||||
|
||||
def test_编辑其他字段不触归属_塞归属字段也被忽略(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
stranger = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
@ -220,8 +220,8 @@ class TestCrudFlow:
|
||||
class TestOwnership:
|
||||
"""归属变更:主表更新与留痕同事务;校验失败不留脏数据。"""
|
||||
|
||||
def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更_主表更新且留痕新增(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
old = _personnel(client, headers)
|
||||
new = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||
@ -243,10 +243,10 @@ class TestOwnership:
|
||||
assert log.old_owner_personnel_id == int(old["id"])
|
||||
assert log.new_owner_personnel_id == int(new["id"])
|
||||
assert log.change_reason == "区域调整"
|
||||
assert log.operator_name == normal["username"]
|
||||
assert log.operator_name == writer["username"]
|
||||
|
||||
def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_变更后归属人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
target = _personnel(client, headers)
|
||||
client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers)
|
||||
customer = _create_customer(client, headers)
|
||||
@ -259,8 +259,8 @@ class TestOwnership:
|
||||
assert "归属人员不存在或已离职" in resp.json()["message"]
|
||||
assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕"
|
||||
|
||||
def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新旧归属相同_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
resp = client.put(
|
||||
@ -271,19 +271,19 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"]
|
||||
|
||||
def test_双方均为空_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_双方均为空_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""无归属客户再清一次归属也属「未变化」——不产生空转留痕。"""
|
||||
customer = _create_customer(client, normal["headers"])
|
||||
customer = _create_customer(client, writer["headers"])
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/ownership",
|
||||
json={"new_owner_personnel_id": None},
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"]
|
||||
|
||||
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
owner = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=owner["id"])
|
||||
resp = client.put(
|
||||
@ -306,8 +306,8 @@ class TestOwnership:
|
||||
class TestOwnershipLogs:
|
||||
"""留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。"""
|
||||
|
||||
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
first = _personnel(client, headers)
|
||||
second = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=first["id"])
|
||||
@ -343,11 +343,11 @@ class TestOwnershipLogs:
|
||||
class TestAudit:
|
||||
"""写操作(含归属变更)留痕,摘要要能定位到客户。"""
|
||||
|
||||
def test_新增客户留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create_customer(client, normal["headers"])
|
||||
def test_新增客户留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create_customer(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增客户未留操作日志"
|
||||
@ -355,8 +355,8 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["company_name"] in latest.summary
|
||||
|
||||
def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更审计摘要含客户名(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
old = _personnel(client, headers)
|
||||
new = _personnel(client, headers)
|
||||
customer = _create_customer(client, headers, owner_personnel_id=old["id"])
|
||||
@ -369,7 +369,7 @@ class TestAudit:
|
||||
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_CUSTOMER and log.success == 1
|
||||
]
|
||||
assert logs, "归属变更未留操作日志"
|
||||
@ -387,9 +387,9 @@ class TestRating:
|
||||
"""
|
||||
|
||||
def test_保存评级_回显总分与时间_且编辑端点不含评分(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
assert customer["rating_score"] is None, "新客户应无评分"
|
||||
|
||||
@ -408,8 +408,8 @@ class TestRating:
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["rating_score"] == 60
|
||||
|
||||
def test_评分越界_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_评分越界_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _create_customer(client, headers)
|
||||
resp = client.put(
|
||||
f"{_CUSTOMERS}/{customer['id']}/rating", json={"score": 101}, headers=headers
|
||||
@ -417,9 +417,9 @@ class TestRating:
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_按评分排序_未评分垫底_升序降序都不压过低分(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
high = _create_customer(client, headers)
|
||||
low = _create_customer(client, headers)
|
||||
unrated = _create_customer(client, headers)
|
||||
|
||||
@ -17,7 +17,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_DASHBOARD = "/api/v1/dashboard"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
315
backend/tests/integration/test_formal_payroll.py
Normal file
315
backend/tests/integration/test_formal_payroll.py
Normal 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"]
|
||||
@ -90,51 +90,51 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。"""
|
||||
|
||||
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
assert data["status"] == 1, "新增人员应默认在职"
|
||||
assert data["is_probation"] == 0
|
||||
# 金额红线:str 进 str 出,不得经 float
|
||||
assert data["salary_standard"] == "3500.50"
|
||||
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 200, detail.text
|
||||
body = detail.json()["data"]
|
||||
assert body["salary_standard"] == "3500.50"
|
||||
assert body["regions"] == []
|
||||
assert body["plans"] == []
|
||||
|
||||
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
member = _create(client, headers, manager_id=manager["id"])
|
||||
assert member["manager_id"] == manager["id"]
|
||||
assert member["manager_name"] == manager["personnel_name"]
|
||||
assert member["role_name"] == manager["role_name"]
|
||||
|
||||
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
listed = client.get(
|
||||
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"]
|
||||
_PERSONNEL, params={"keyword": data["personnel_name"]}, headers=writer["headers"]
|
||||
)
|
||||
assert listed.status_code == 200, listed.text
|
||||
rows = listed.json()["data"]["list"]
|
||||
assert [r["id"] for r in rows] == [data["id"]]
|
||||
|
||||
def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑人员全量替换生效(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
updated = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}",
|
||||
json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()["data"]
|
||||
assert body["phone"] == "13900001111"
|
||||
assert body["is_probation"] == 1
|
||||
|
||||
def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_在职离职双向切换(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||
assert off.status_code == 200, off.text
|
||||
@ -143,11 +143,11 @@ class TestCrudFlow:
|
||||
assert on.status_code == 200, on.text
|
||||
assert on.json()["data"]["status"] == 1
|
||||
|
||||
def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
def test_删除无引用人员成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 404
|
||||
assert "人员不存在" in detail.json()["message"]
|
||||
|
||||
@ -155,27 +155,27 @@ class TestCrudFlow:
|
||||
class TestRefRules:
|
||||
"""岗位/经理引用校验 —— 核算锚点不悬空、不环引用。"""
|
||||
|
||||
def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑时直属经理是自己_400(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}",
|
||||
json=_payload(data["personnel_name"], manager_id=data["id"]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不能是自己" in resp.json()["message"]
|
||||
|
||||
def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_直属经理不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), manager_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||
|
||||
def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
quit_resp = client.put(
|
||||
f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers
|
||||
@ -189,16 +189,16 @@ class TestRefRules:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "直属经理不存在或已离职" in resp.json()["message"]
|
||||
|
||||
def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_岗位不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), business_role_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||
|
||||
def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_岗位已停用_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。"""
|
||||
code = f"t_br_{uuid.uuid4().hex[:8]}"
|
||||
factory = get_session_factory()
|
||||
@ -220,7 +220,7 @@ class TestRefRules:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), business_role_id=role_id),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "岗位不存在或已停用" in resp.json()["message"]
|
||||
@ -229,8 +229,8 @@ class TestRefRules:
|
||||
class TestDeleteProtection:
|
||||
"""删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。"""
|
||||
|
||||
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
manager = _create(client, headers)
|
||||
member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用
|
||||
blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers)
|
||||
@ -244,9 +244,9 @@ class TestDeleteProtection:
|
||||
assert freed.status_code == 200, freed.text
|
||||
|
||||
def test_被客户归属引用禁删_离职不放宽_解除后可删(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _create(client, headers)
|
||||
created = client.post(
|
||||
_CUSTOMERS,
|
||||
@ -277,9 +277,9 @@ class TestDeleteProtection:
|
||||
freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers)
|
||||
assert freed.status_code == 200, freed.text
|
||||
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, normal: dict) -> None:
|
||||
def test_被业务归属引用禁删_负责或协助任一即拦(self, client: TestClient, writer: dict) -> None:
|
||||
"""业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
owner = _create(client, headers)
|
||||
assistant = _create(client, headers)
|
||||
resp_customer = client.post(
|
||||
@ -328,9 +328,9 @@ class TestSubTableReplace:
|
||||
"""辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。"""
|
||||
|
||||
def test_辐射区域设置后详情回显一致_再次替换生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/regions",
|
||||
@ -357,8 +357,8 @@ class TestSubTableReplace:
|
||||
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||
assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"]
|
||||
|
||||
def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_辐射区域替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/regions",
|
||||
@ -373,9 +373,9 @@ class TestSubTableReplace:
|
||||
assert detail["regions"] == []
|
||||
|
||||
def test_拓展计划设置后详情回显一致_再次替换生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/plans",
|
||||
@ -405,8 +405,8 @@ class TestSubTableReplace:
|
||||
detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"]
|
||||
assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"]
|
||||
|
||||
def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_拓展计划替换为空列表即清空(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
client.put(
|
||||
f"{_PERSONNEL}/{data['id']}/plans",
|
||||
@ -424,11 +424,11 @@ class TestSubTableReplace:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增人员成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增人员未留操作日志"
|
||||
@ -436,16 +436,16 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["personnel_name"] in latest.summary
|
||||
|
||||
def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None:
|
||||
def test_校验失败也留审计(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json=_payload(_uname(), manager_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PERSONNEL and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@ -122,8 +122,8 @@ class TestAuth:
|
||||
class TestCrudFlow:
|
||||
"""正向链路:建 → 查 → 改(含换类别)→ 删。"""
|
||||
|
||||
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
assert data["status"] == 1, "新产品应默认启用"
|
||||
assert data["spec_model"] == "DN100"
|
||||
# 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float)
|
||||
@ -132,34 +132,34 @@ class TestCrudFlow:
|
||||
assert isinstance(data["stock_quantity"], str)
|
||||
assert Decimal(data["stock_quantity"]) == Decimal("100")
|
||||
|
||||
def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
def test_详情与列表均能查到新增产品(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["data"]["id"] == data["id"]
|
||||
|
||||
listed = client.get(
|
||||
_PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"]
|
||||
_PRODUCTS, params={"keyword": data["product_name"]}, headers=writer["headers"]
|
||||
)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]]
|
||||
|
||||
def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None:
|
||||
def test_同名称不同规格创建成功(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
first = _create(client, normal["headers"], product_name=name, spec_model="DN100")
|
||||
first = _create(client, writer["headers"], product_name=name, spec_model="DN100")
|
||||
second = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN200"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["data"]["id"] != first["id"]
|
||||
|
||||
def test_删除产品成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
def test_删除产品成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"])
|
||||
detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=writer["headers"])
|
||||
assert detail.status_code == 404
|
||||
assert "产品不存在" in detail.json()["message"]
|
||||
|
||||
@ -168,29 +168,29 @@ class TestDeleteProtection:
|
||||
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
|
||||
|
||||
def test_被业务明细引用_400且产品仍在(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
self, client: TestClient, writer: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
business = make_business_for_product(normal["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
product = _create(client, writer["headers"])
|
||||
business = make_business_for_product(writer["headers"], product["id"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "引用" in resp.json()["message"]
|
||||
# 产品仍在(引导改走停用而非物理删除)
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert still.status_code == 200
|
||||
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
|
||||
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=writer["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
assert detail["items"][0]["product_id"] == product["id"]
|
||||
|
||||
def test_被发货明细引用_400(
|
||||
self, client: TestClient, normal: dict, make_business_for_product
|
||||
self, client: TestClient, writer: dict, make_business_for_product
|
||||
) -> None:
|
||||
product = _create(client, normal["headers"])
|
||||
make_business_for_product(normal["headers"], product["id"])
|
||||
product = _create(client, writer["headers"])
|
||||
make_business_for_product(writer["headers"], product["id"])
|
||||
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
|
||||
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
|
||||
businesses = client.get("/api/v1/business/businesses", headers=writer["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
business_id = businesses["list"][0]["id"]
|
||||
@ -208,68 +208,68 @@ class TestDeleteProtection:
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert shipment.status_code == 200, shipment.text
|
||||
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
|
||||
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
class TestValidationRules:
|
||||
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""
|
||||
|
||||
def test_重名同规格_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名同规格_400(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
_create(client, normal["headers"], product_name=name)
|
||||
_create(client, writer["headers"], product_name=name)
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN100"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||
|
||||
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None:
|
||||
def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, writer: dict) -> None:
|
||||
"""组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。"""
|
||||
name = _pname()
|
||||
first = _create(client, normal["headers"], product_name=name, spec_model=None)
|
||||
first = _create(client, writer["headers"], product_name=name, spec_model=None)
|
||||
assert first["spec_model"] == ""
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model=None),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
assert "已存在同名称同规格的产品" in dup.json()["message"]
|
||||
|
||||
def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_产品类别非法_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), product_category="not_a_category"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "产品类别不合法" in resp.json()["message"]
|
||||
|
||||
def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_销售单价为负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), sale_price="-1"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(_pname(), sale_price="abc"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_编辑改类别为合法值成功(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
updated = client.put(
|
||||
f"{_PRODUCTS}/{data['id']}",
|
||||
json=_payload(
|
||||
@ -277,7 +277,7 @@ class TestValidationRules:
|
||||
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||
sale_price="15.8",
|
||||
),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()["data"]
|
||||
@ -288,8 +288,8 @@ class TestValidationRules:
|
||||
class TestStatusSwitch:
|
||||
"""启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。"""
|
||||
|
||||
def test_停用再启用切换(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_停用再启用切换(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
data = _create(client, headers)
|
||||
off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers)
|
||||
assert off.status_code == 200, off.text
|
||||
@ -304,11 +304,11 @@ class TestStatusSwitch:
|
||||
class TestAudit:
|
||||
"""写操作(含失败)留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
data = _create(client, normal["headers"])
|
||||
def test_新增产品成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
data = _create(client, writer["headers"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增产品未留操作日志"
|
||||
@ -316,18 +316,18 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert data["product_name"] in latest.summary
|
||||
|
||||
def test_重名失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
def test_重名失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
name = _pname()
|
||||
_create(client, normal["headers"], product_name=name)
|
||||
_create(client, writer["headers"], product_name=name)
|
||||
dup = client.post(
|
||||
_PRODUCTS,
|
||||
json=_payload(name, spec_model="DN100"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert dup.status_code == 400, dup.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_PRODUCT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@ -122,8 +122,8 @@ class TestOverlapGuardLock:
|
||||
class TestList:
|
||||
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||||
|
||||
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.get(_GRADES, headers=normal["headers"])
|
||||
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.get(_GRADES, headers=writer["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()["data"]
|
||||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
|
||||
@ -140,11 +140,11 @@ class TestCreate:
|
||||
任一缺失都会算错等级。"""
|
||||
|
||||
def test_删除种子后新增相邻区间_成功且判定表更新(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
|
||||
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||
resp = client.post(
|
||||
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
|
||||
@ -158,48 +158,48 @@ class TestCreate:
|
||||
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
|
||||
|
||||
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
|
||||
def test_区间倒挂_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_分值超上限_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(
|
||||
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
|
||||
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
|
||||
def test_分值负下限_400(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_等级名称已存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
|
||||
「与等级 D 重叠」而走不到名称校验。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D")
|
||||
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "该等级名称已存在" in resp.json()["message"], resp.text
|
||||
|
||||
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None:
|
||||
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
|
||||
# 失败不落库:判定表仍是 4 条种子
|
||||
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
|
||||
assert _names(client, writer["headers"]) == ["A", "B", "C", "D"]
|
||||
|
||||
|
||||
class TestEdit:
|
||||
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
|
||||
新增路共用重叠约束,两处都要拦。"""
|
||||
|
||||
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, writer: dict) -> None:
|
||||
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
b_id = _seed_id(client, headers, "B")
|
||||
resp = client.put(
|
||||
f"{_GRADES}/{b_id}",
|
||||
@ -211,10 +211,10 @@ class TestEdit:
|
||||
# 失败不落库:种子保持原样
|
||||
assert _names(client, headers) == ["A", "B", "C", "D"]
|
||||
|
||||
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, writer: dict) -> None:
|
||||
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
|
||||
(74 与 75 相邻),这是种子铺法的正例。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||
d_id = _seed_id(client, headers, "D")
|
||||
resp = client.put(
|
||||
@ -228,8 +228,8 @@ class TestEdit:
|
||||
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
|
||||
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
|
||||
|
||||
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑回显改名与分数(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
created = client.post(
|
||||
@ -261,8 +261,8 @@ class TestDelete:
|
||||
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
||||
立即反映,二次删除 404。"""
|
||||
|
||||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
created = client.post(
|
||||
@ -283,8 +283,8 @@ class TestDelete:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增等级成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||
grade_name = _tname()
|
||||
resp = client.post(
|
||||
@ -295,7 +295,7 @@ class TestAudit:
|
||||
assert resp.status_code == 200, resp.text
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RATING and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增评级等级未留操作日志"
|
||||
@ -303,12 +303,12 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert grade_name in latest.summary
|
||||
|
||||
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||
def test_重叠失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=writer["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RATING and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@ -122,8 +122,8 @@ class TestAuth:
|
||||
class TestCreate:
|
||||
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
|
||||
|
||||
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
|
||||
@ -140,10 +140,10 @@ class TestCreate:
|
||||
assert data["settlement_status"] == 0
|
||||
assert Decimal(data["amount"]) == Decimal("1200")
|
||||
|
||||
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None:
|
||||
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, writer: dict) -> None:
|
||||
"""发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
|
||||
需规「26 天」示例已作废)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||
data = _create(
|
||||
@ -158,8 +158,8 @@ class TestCreate:
|
||||
assert data["shipment_no"] == shipment["shipment_no"]
|
||||
assert data["payment_days"] == 29, data
|
||||
|
||||
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
|
||||
@ -170,35 +170,35 @@ class TestCreate:
|
||||
rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||
assert Decimal(rollback["total_received_amount"]) == Decimal("0")
|
||||
|
||||
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"])
|
||||
def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=writer["headers"])
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "业务不存在" in resp.json()["message"], resp.text
|
||||
|
||||
def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态业务禁新增回款_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
_terminate(client, headers, business["id"])
|
||||
resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text
|
||||
|
||||
def test_金额负数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_金额负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"]
|
||||
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "不能为负数" in resp.json()["message"], resp.text
|
||||
|
||||
def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_金额三位小数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json=_rpayload(business["id"], amount="12.345"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||
@ -208,8 +208,8 @@ class TestCrossBusiness:
|
||||
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
|
||||
差异探测发货单存在性。"""
|
||||
|
||||
def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_跨业务指定发货批次_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@ -222,9 +222,9 @@ class TestCrossBusiness:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
|
||||
|
||||
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_编辑时指定跨业务发货批次_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""编辑路径(全量替换)同样强校验,无「未变即免检」旁路。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@ -242,8 +242,8 @@ class TestCrossBusiness:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text
|
||||
|
||||
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||
data = _create(client, headers, business["id"])
|
||||
@ -279,8 +279,8 @@ class TestCrossBusiness:
|
||||
class TestSettled:
|
||||
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
|
||||
|
||||
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_已核算禁改_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_settle(data["id"])
|
||||
@ -297,8 +297,8 @@ class TestSettled:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "已核算回款禁止修改" in resp.json()["message"], resp.text
|
||||
|
||||
def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_已核算禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_settle(data["id"])
|
||||
@ -312,9 +312,9 @@ class TestFilter:
|
||||
"""核算状态筛选(M3 核算消费的入口口径)。"""
|
||||
|
||||
def test_settlement_status筛选_0只回未核算_1只回已核算(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
pending = _create(client, headers, business["id"], amount="500.00")
|
||||
settled = _create(client, headers, business["id"], amount="700.00")
|
||||
@ -343,13 +343,13 @@ class TestFilter:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增回款成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增回款未留操作日志"
|
||||
@ -358,8 +358,8 @@ class TestAudit:
|
||||
assert "新增回款" in latest.summary
|
||||
assert data["receipt_batch_no"] in latest.summary
|
||||
|
||||
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_跨业务指定失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business1 = _create_business(client, headers)
|
||||
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||
business2 = _create_business(client, headers)
|
||||
@ -371,7 +371,7 @@ class TestAudit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_RECEIPT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
@ -15,7 +15,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_REPORTS = "/api/v1/reports"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
@ -19,7 +19,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
@ -21,7 +21,7 @@ from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement, PerformanceLedger
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_CALC = "/api/v1/calc"
|
||||
|
||||
@ -97,8 +97,8 @@ class TestCreate:
|
||||
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
|
||||
是 M3 起付判定的直接消费源,错写就是算错钱。"""
|
||||
|
||||
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -130,53 +130,53 @@ class TestCreate:
|
||||
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
|
||||
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
|
||||
|
||||
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"])
|
||||
def test_业务不存在_404(self, client: TestClient, writer: dict) -> None:
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=writer["headers"])
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "业务不存在" in resp.json()["message"], resp.text
|
||||
|
||||
def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态业务禁新发货_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
_terminate(client, headers, business["id"])
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text
|
||||
|
||||
def test_明细为空_400(self, client: TestClient, normal: dict) -> None:
|
||||
business = _create_business(client, normal["headers"])
|
||||
def test_明细为空_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"]
|
||||
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
business = _create_business(client, normal["headers"])
|
||||
def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], items=[_item(quantity="0")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "数量" in resp.json()["message"], resp.text
|
||||
|
||||
def test_运费负数_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_运费负数_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""运费是金额源数据:负值会让业务运费合计与报表直接错乱。"""
|
||||
business = _create_business(client, normal["headers"])
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], freight_amount="-8.00"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "不能为负数" in resp.json()["message"], resp.text
|
||||
|
||||
def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||
business = _create_business(client, normal["headers"])
|
||||
def test_运费三位小数_400(self, client: TestClient, writer: dict) -> None:
|
||||
business = _create_business(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json=_spayload(business["id"], freight_amount="8.333"),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||
@ -185,8 +185,8 @@ class TestCreate:
|
||||
class TestEdit:
|
||||
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
|
||||
|
||||
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑发货单_两处汇总同步(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -223,8 +223,8 @@ class TestEdit:
|
||||
class TestDelete:
|
||||
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
|
||||
|
||||
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -264,8 +264,8 @@ class TestDelete:
|
||||
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
|
||||
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
|
||||
|
||||
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@ -289,8 +289,8 @@ class TestTerminal:
|
||||
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
|
||||
的源数据,终态后不得变动。"""
|
||||
|
||||
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态业务发货单禁改_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_terminate(client, headers, business["id"])
|
||||
@ -306,8 +306,8 @@ class TestTerminal:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "禁止编辑发货单" in resp.json()["message"], resp.text
|
||||
|
||||
def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态业务发货单禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
_terminate(client, headers, business["id"])
|
||||
@ -320,13 +320,13 @@ class TestTerminal:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增发货单成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
data = _create(client, headers, business["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增发货单未留操作日志"
|
||||
@ -335,15 +335,15 @@ class TestAudit:
|
||||
assert "新增发货单" in latest.summary
|
||||
assert data["shipment_no"] in latest.summary
|
||||
|
||||
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终态禁新发货失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
business = _create_business(client, headers)
|
||||
_terminate(client, headers, business["id"])
|
||||
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_SHIPMENT and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
158
backend/tests/integration/test_tplus_automation.py
Normal file
158
backend/tests/integration/test_tplus_automation.py
Normal 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'
|
||||
334
backend/tests/integration/test_tplus_workbench.py
Normal file
334
backend/tests/integration/test_tplus_workbench.py
Normal 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)
|
||||
}
|
||||
12
backend/tests/isolation.py
Normal file
12
backend/tests/isolation.py
Normal 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")
|
||||
465
backend/tests/unit/test_formal_payroll.py
Normal file
465
backend/tests/unit/test_formal_payroll.py
Normal 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
|
||||
@ -12,6 +12,9 @@ from sqlalchemy.dialects import mysql
|
||||
from app.models import Base
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"tplus_automation_states",
|
||||
"payroll_months", "payroll_inputs", "payroll_allocations",
|
||||
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
|
||||
"users",
|
||||
"roles",
|
||||
"user_roles",
|
||||
|
||||
23
backend/tests/unit/test_payroll_isolation.py
Normal file
23
backend/tests/unit/test_payroll_isolation.py
Normal 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")
|
||||
160
backend/tests/unit/test_tplus_automation.py
Normal file
160
backend/tests/unit/test_tplus_automation.py
Normal 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"]
|
||||
@ -79,7 +79,7 @@ def test_T加查询文件覆盖六类实体() -> None:
|
||||
|
||||
def test_T加高风险映射保持待确认() -> None:
|
||||
assert get_mapping("businesses").status is MappingStatus.PENDING
|
||||
assert "成本口径" in get_mapping("businesses").unresolved
|
||||
assert "指导价取值时点" in get_mapping("businesses").unresolved
|
||||
|
||||
|
||||
def test_未知T加实体被拒绝() -> None:
|
||||
|
||||
15
backend/tests/unit/test_tplus_error_classes.py
Normal file
15
backend/tests/unit/test_tplus_error_classes.py
Normal 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"
|
||||
252
backend/tests/unit/test_tplus_fifo.py
Normal file
252
backend/tests/unit/test_tplus_fifo.py
Normal 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)
|
||||
40
backend/tests/unit/test_tplus_materials.py
Normal file
40
backend/tests/unit/test_tplus_materials.py
Normal 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
|
||||
@ -80,7 +80,8 @@ def test_多订单和成本日期仍需业务确认不自动选择():
|
||||
result = _audit().run()
|
||||
assert result["order_coverage"]["multiple_orders"] == 2
|
||||
keys = {item["key"] for item in result["pending_confirmations"]}
|
||||
assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys
|
||||
assert {"cost_basis", "voucher_state", "opening_balance", "allocation"} <= keys
|
||||
assert not {"receipt_date", "ship_date"} & keys
|
||||
|
||||
|
||||
def test_SQL查询有固定白名单且不接受自定义表名():
|
||||
|
||||
@ -108,3 +108,19 @@ def test_未确认时间增量不能创建误导性批次(target):
|
||||
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
|
||||
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
|
||||
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
|
||||
|
||||
|
||||
def test_已读数据后明确超时仍标记可重试失败(target):
|
||||
service = _service(target, [])
|
||||
|
||||
def source(entity, **kwargs):
|
||||
if entity == "products":
|
||||
return iter([{"id": 1}])
|
||||
raise TimeoutError("敏感驱动信息不得回显")
|
||||
|
||||
service.reader.iter_entity.side_effect = source
|
||||
batch = service.run(entity_types=["products", "customers", "personnel"])
|
||||
assert batch.status == "FAILED"
|
||||
assert batch.staged_record_count == 1
|
||||
assert service.reader.iter_entity.call_count == 2
|
||||
assert target.scalar(select(TPlusSyncError.error_code)) == "SOURCE_TIMEOUT"
|
||||
|
||||
822
backend/tests/unit/test_tplus_workbench.py
Normal file
822
backend/tests/unit/test_tplus_workbench.py
Normal 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
Loading…
Reference in New Issue
Block a user