Compare commits
3 Commits
02fb06e0af
...
22605f4106
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22605f4106 | ||
|
|
a2f9a58de5 | ||
|
|
1461a8d113 |
@ -137,10 +137,17 @@ python e2e/system_management.py # 系统管理 13 项
|
||||
|
||||
## 正式工资闭环
|
||||
|
||||
- 在现有账号中明确绑定工资制作人:`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`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
|
||||
|
||||
## 权限管理
|
||||
|
||||
- “用户管理 → 分配角色”:选择角色并预览继承权限,支持多个角色叠加。
|
||||
- “角色管理 → 配置权限”:按模块配置模板;停用角色即时撤销其授权。
|
||||
- 管理员拥有全部功能权限;月份锁定、审核节点及源读取开关继续独立检查。
|
||||
- 授权变更无需重新登录,后端逐请求校验;详情、只读提示、菜单与按钮使用统一规则。操作与接口说明见 [权限管理使用与开发记录](docs/权限管理使用与开发记录_2026-11-02.md)。
|
||||
|
||||
@ -17,6 +17,15 @@ REDIS_DB=0
|
||||
# ===== 应用 =====
|
||||
APP_ENV=dev
|
||||
APP_SECRET_KEY=
|
||||
# 运行监控:每 15 秒采集,分钟聚合保留 7 天;部署限单个应用进程
|
||||
MONITOR_ENABLED=true
|
||||
MONITOR_CPU_THRESHOLD=85
|
||||
MONITOR_MEMORY_THRESHOLD=85
|
||||
MONITOR_DISK_THRESHOLD=90
|
||||
MONITOR_RESOURCE_DURATION_SECONDS=300
|
||||
MONITOR_SLOW_REQUEST_MS=2000
|
||||
MONITOR_ERROR_RATE_THRESHOLD=5
|
||||
MONITOR_ERROR_MIN_REQUESTS=20
|
||||
# JWT 有效期(分钟),规范要求 ≤120
|
||||
JWT_EXPIRE_MINUTES=120
|
||||
# 验证码有效期(秒),规范要求 300
|
||||
@ -47,8 +56,7 @@ T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
T_PLUS_QUERY_DIR=
|
||||
|
||||
# 指定实际工资制作人的现有用户 ID;留空时业务只读,不自动授予管理员
|
||||
# PAYROLL_MAKER_USER_ID=
|
||||
# 工资操作通过角色权限控制,无需配置全局制作人账号。
|
||||
|
||||
# 审核能力保留但默认关闭;开启后仍按各类审批参数及财务/领导节点执行
|
||||
PAYROLL_APPROVAL_ENABLED=false
|
||||
@ -60,3 +68,5 @@ T_PLUS_SCHEDULE_WEEKDAY=0
|
||||
T_PLUS_SCHEDULE_TIME=02:00
|
||||
# 托管启动器自动设置;发现标记后停止排队和领取新任务
|
||||
T_PLUS_STOP_FILE=
|
||||
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
|
||||
UPLOAD_DIR=
|
||||
|
||||
@ -297,3 +297,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
||||
|
||||
**改了代码不生效** —— 启动时没加 `--reload`。
|
||||
|
||||
## 系统监控(2026-11-02)
|
||||
|
||||
新增管理员只读接口 `/api/v1/system/monitor/{overview,trends,interfaces,events,diagnostic}`,事件详情为 `/events/{id}`。后台独立线程每 15 秒采集,分钟聚合保留 7 天;配置见 `.env.example` 的 `MONITOR_*`,新增依赖 `psutil`。部署限单个后端应用进程,按项目流程应用 V23 迁移后启动。异常通过 `X-Request-ID` 与脱敏服务端日志关联,`/api/health` 原契约保持不变。
|
||||
|
||||
详细采集口径、故障边界、排查步骤和接口见 [系统监控运维说明](../docs/系统监控运维说明.md)。
|
||||
|
||||
## 角色授权(2026-11-02)
|
||||
|
||||
系统权限统一为“角色配置权限 → 用户分配角色 → 用户继承权限”。V26(`26e7f8a9b0c1`)停用旧个人覆盖和全局制作人入口;工资核算、生成、调整分别检查角色权限,月份实际经办人、生成锁和历史留痕继续保留。接口与迁移边界见 [角色授权使用说明](../docs/角色授权使用说明.md)。
|
||||
|
||||
47
backend/alembic/versions/22a3b4c5d6e7_authorization.py
Normal file
47
backend/alembic/versions/22a3b4c5d6e7_authorization.py
Normal file
File diff suppressed because one or more lines are too long
39
backend/alembic/versions/23b4c5d6e7f8_system_monitor.py
Normal file
39
backend/alembic/versions/23b4c5d6e7f8_system_monitor.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""新增运行监控快照、接口聚合与诊断事件。"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "23b4c5d6e7f8"
|
||||
down_revision: str | None = "22a3b4c5d6e7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
DDL = [
|
||||
"CREATE TABLE monitor_snapshots (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '采集分钟,北京时间', \n\tsample_count INTEGER NOT NULL COMMENT '实际采样次数', \n\tresources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', \n\trequests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', \n\tCONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci",
|
||||
"CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute)",
|
||||
"CREATE TABLE monitor_request_minutes (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '请求分钟,北京时间', \n\tmethod VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', \n\troute VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', \n\tmetrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', \n\tCONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='接口分钟级运行统计' COLLATE utf8mb4_general_ci",
|
||||
"CREATE INDEX idx_monitor_request_minutes_minute ON monitor_request_minutes (minute)",
|
||||
"CREATE TABLE monitor_events (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tkind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', \n\tcode VARCHAR(64) NOT NULL COMMENT '稳定错误码', \n\tcomponent VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', \n\tlevel VARCHAR(16) NOT NULL COMMENT '异常级别', \n\tmessage VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', \n\tfingerprint VARCHAR(64) COMMENT '同类健康异常合并键', \n\trequest_id VARCHAR(32) COMMENT '请求关联编号', \n\tmethod VARCHAR(8) COMMENT 'HTTP方法', \n\troute VARCHAR(256) COMMENT '路由模板', \n\tstatus_code INTEGER COMMENT 'HTTP状态码', \n\tduration_ms FLOAT COMMENT '请求耗时,毫秒', \n\tbatch_no VARCHAR(32) COMMENT '相关同步批次编号', \n\tfirst_seen DATETIME NOT NULL COMMENT '首次发生时间', \n\tlast_seen DATETIME NOT NULL COMMENT '最近发生时间', \n\toccurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', \n\tresolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', \n\tCONSTRAINT pk_monitor_events PRIMARY KEY (id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='脱敏诊断事件与异常恢复记录' COLLATE utf8mb4_general_ci",
|
||||
"CREATE INDEX idx_monitor_events_fingerprint ON monitor_events (fingerprint, resolved_at)",
|
||||
"CREATE INDEX idx_monitor_events_last_seen ON monitor_events (last_seen)",
|
||||
"CREATE INDEX idx_monitor_events_request_id ON monitor_events (request_id)",
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
for statement in DDL:
|
||||
op.execute(text(statement))
|
||||
op.execute(text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1)"))
|
||||
op.execute(text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:monitor:view' WHERE r.role_code='admin'"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 应用回滚可保留新增监控表;有历史数据时拒绝隐式删除。
|
||||
bind = op.get_bind()
|
||||
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
|
||||
if bind.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar():
|
||||
raise RuntimeError("存在监控历史,拒绝自动删除;请回退应用并保留新增表")
|
||||
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
|
||||
op.drop_table(table)
|
||||
bind.execute(text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code='system:monitor:view'"))
|
||||
bind.execute(text("DELETE FROM permissions WHERE perm_code='system:monitor:view'"))
|
||||
152
backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py
Normal file
152
backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""V24 需求补齐:试用期台账、产品来源及分产品系数参数。"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "24c5d6e7f8a9"
|
||||
down_revision = "23b4c5d6e7f8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROBATION_DDL = "CREATE TABLE probation_deferrals (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原计提年度', \n month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', \n accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', \n eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), \n CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), \n CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year);\n\nCREATE TABLE probation_payments (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', \n adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原累计年度', \n month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', \n amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', \n snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_payments PRIMARY KEY (id), \n CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), \n CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), \n CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, \n CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year);"
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
op.add_column(
|
||||
"personnel",
|
||||
sa.Column(
|
||||
"probation_start_date", sa.Date(), nullable=True, comment="试用期开始日(含当日)"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"personnel",
|
||||
sa.Column(
|
||||
"regularization_date",
|
||||
sa.Date(),
|
||||
nullable=True,
|
||||
comment="转正生效日(当日不再属于试用期)",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"personnel",
|
||||
sa.Column(
|
||||
"resignation_date", sa.Date(), nullable=True, comment="离职生效日(当日起不计基本工资)"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"personnel",
|
||||
sa.Column(
|
||||
"salary_history_json",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="工资标准、岗位及任职资料生效版本",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"source_trade_json",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="最近有效发货数量、含税成交价及源单据快照",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"quantity_price_manual",
|
||||
sa.SmallInteger(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="数量单价人工保护:1人工维护 0随源更新",
|
||||
),
|
||||
)
|
||||
for name, title in (
|
||||
("normal_payable_sum", "当月正常实付绩效"),
|
||||
("deferred_sum", "当月试用期缓发变化"),
|
||||
("probation_payment_sum", "本月年度补发或扣回"),
|
||||
):
|
||||
op.add_column(
|
||||
"settlement_items",
|
||||
sa.Column(name, sa.Numeric(14, 2), server_default="0", nullable=False, comment=title),
|
||||
)
|
||||
for statement in PROBATION_DDL.split(";"):
|
||||
if statement.strip():
|
||||
op.execute(sa.text(statement))
|
||||
# 仅建立新参数定义,不写任何历史生效值;未覆盖项继续回溯原通用参数。
|
||||
fields = (
|
||||
("first", "个人户首日比例", "DECIMAL", "‰"),
|
||||
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
|
||||
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
|
||||
("floor", "个人户超期比例", "DECIMAL", "‰"),
|
||||
("boundary", "个人户线性阶段截止天数", "INT", "天"),
|
||||
)
|
||||
definitions = sa.table(
|
||||
"param_definitions",
|
||||
*[
|
||||
sa.column(name, sa.String())
|
||||
for name in (
|
||||
"param_code",
|
||||
"param_name",
|
||||
"param_group",
|
||||
"value_type",
|
||||
"unit",
|
||||
"dimension_type",
|
||||
"value_min",
|
||||
"value_max",
|
||||
"description",
|
||||
)
|
||||
],
|
||||
sa.column("sort_order", sa.Integer()),
|
||||
sa.column("status", sa.Integer()),
|
||||
)
|
||||
op.bulk_insert(
|
||||
definitions,
|
||||
[
|
||||
dict(
|
||||
param_code=f"coefficient.personal.{field}",
|
||||
param_name=title,
|
||||
param_group="产品系数表",
|
||||
value_type=kind,
|
||||
unit=unit,
|
||||
dimension_type="PRODUCT_CATEGORY",
|
||||
value_min="1" if kind == "INT" else "0",
|
||||
value_max="3660" if kind == "INT" else None,
|
||||
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。",
|
||||
sort_order=52 + index,
|
||||
status=1,
|
||||
)
|
||||
for index, (field, title, kind, unit) in enumerate(fields)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for table, condition in (("personnel", "salary_history_json IS NOT NULL"), ("products", "source_trade_json IS NOT NULL OR quantity_price_manual<>0")):
|
||||
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")).scalar():
|
||||
raise RuntimeError("存在人员或产品新增资料,请回退应用并保留业务结构")
|
||||
for table in ("probation_payments", "probation_deferrals"):
|
||||
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar():
|
||||
raise RuntimeError("存在试用期结算记录,请回退应用并保留业务数据")
|
||||
if bind.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code LIKE 'coefficient.personal.%'"
|
||||
)
|
||||
).scalar():
|
||||
raise RuntimeError("存在已维护的产品系数,请保留新结构")
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM param_definitions WHERE param_code LIKE 'coefficient.personal.%'")
|
||||
)
|
||||
op.drop_table("probation_payments")
|
||||
op.drop_table("probation_deferrals")
|
||||
for name in ("normal_payable_sum", "deferred_sum", "probation_payment_sum"):
|
||||
op.drop_column("settlement_items", name)
|
||||
op.drop_column("products", "quantity_price_manual")
|
||||
op.drop_column("products", "source_trade_json")
|
||||
op.drop_column("personnel", "regularization_date")
|
||||
op.drop_column("personnel", "salary_history_json")
|
||||
op.drop_column("personnel", "resignation_date")
|
||||
op.drop_column("personnel", "probation_start_date")
|
||||
24
backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py
Normal file
24
backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""V25 将新增系统监控接入统一功能权限,不修改已应用的 V22。"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "25d6e7f8a9b0"
|
||||
down_revision: str | None = "24c5d6e7f8a9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
for code, name, kind in (
|
||||
("system:monitor:view", "系统监控:查看", 1),
|
||||
("system:monitor:export", "系统监控:导出", 2),
|
||||
):
|
||||
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), dict(code=code,name=name,kind=kind))
|
||||
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), dict(code=code))
|
||||
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# V23 也可能已登记目录;回退此兼容版本不删除任何既有授权。
|
||||
return
|
||||
31
backend/alembic/versions/26e7f8a9b0c1_role_authorization.py
Normal file
31
backend/alembic/versions/26e7f8a9b0c1_role_authorization.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""授权归一到角色,停用全局制作人入口,保留旧结构与历史工资依据。"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision: str = "26e7f8a9b0c1"
|
||||
down_revision: str | None = "25d6e7f8a9b0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
STATEMENTS = (
|
||||
"UPDATE authorization_settings SET maker_user_id=NULL, revision=revision+1 WHERE id=1",
|
||||
"UPDATE permissions SET status=0 WHERE perm_code IN ('system:authorization:view','system:authorization:update')",
|
||||
"UPDATE roles SET role_name='工资经办', description='按角色授权维护核算资料、计算工资及生成工资;月份锁定和实际经办人留痕继续生效' WHERE role_code='payroll_maker' AND role_name='工资制作人'",
|
||||
"UPDATE roles SET description='管理业务基础资料,具体功能由角色权限配置决定' WHERE role_code='marketing_ops' AND description LIKE '%审批链%'",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if bind.execute(text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
|
||||
raise RuntimeError("存在个人权限覆盖,请先整理为等效角色后再升级,避免隐式改变用户权限")
|
||||
for statement in STATEMENTS:
|
||||
op.execute(text(statement))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 只恢复旧入口,不猜测重新绑定哪个账号。旧版本需由管理员重新指定。
|
||||
op.execute(text("UPDATE permissions SET status=1 WHERE perm_code IN ('system:authorization:view','system:authorization:update')"))
|
||||
op.execute(text("UPDATE roles SET role_name='工资制作人' WHERE role_code='payroll_maker' AND role_name='工资经办'"))
|
||||
op.execute(text("UPDATE authorization_settings SET revision=revision+1 WHERE id=1"))
|
||||
@ -0,0 +1,21 @@
|
||||
"""增加源级自动采集开关,不修改活动采集表。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "27f8a9b0c1d2"
|
||||
down_revision = "26e7f8a9b0c1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not inspect(op.get_bind()).has_table("tplus_automation_configs"):
|
||||
sql = Path(__file__).resolve().parents[3] / "db/migration/V27__tplus_automation_config.sql"
|
||||
op.get_bind().execute(text(sql.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("tplus_automation_configs")
|
||||
@ -0,0 +1,31 @@
|
||||
"""V28 计提公式模板定义:模板入库,页面可维护;公式骨架仍受控。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "28a9b0c1d2e3"
|
||||
down_revision = "27f8a9b0c1d2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not inspect(op.get_bind()).has_table("calc_template_definitions"):
|
||||
sql = Path(__file__).resolve().parents[3] / "db/migration/V28__calc_template_definitions.sql"
|
||||
raw = sql.read_text(encoding="utf-8")
|
||||
# MySQL 不支持 multi-statement;按分号+换行拆分成逐条执行。
|
||||
# JSON_OBJECT 内不含分号+换行,可安全拆分。
|
||||
for statement in raw.split(";\n"):
|
||||
stripped = "\n".join(
|
||||
line for line in statement.splitlines() if not line.lstrip().startswith("--")
|
||||
).strip()
|
||||
if stripped:
|
||||
op.get_bind().execute(text(stripped))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 模板可能被岗位引用,外键不存在但编码被业务表保存;回退仅删定义表,
|
||||
# 已绑定岗位的编码会回退到代码注册表解析,不猜新数据归属。
|
||||
op.drop_table("calc_template_definitions")
|
||||
@ -98,7 +98,7 @@ def _is_digits(value: object) -> bool:
|
||||
return isinstance(value, str) and value.isdigit()
|
||||
|
||||
|
||||
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
||||
"""要求当前用户具备管理员角色。
|
||||
|
||||
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
|
||||
@ -107,6 +107,9 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
||||
|
||||
:raises ForbiddenError: 已登录但不具备管理员角色(403)
|
||||
"""
|
||||
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
|
||||
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
|
||||
# authorization_checked 短路,每次按数据库角色重新判定。
|
||||
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
|
||||
raise ForbiddenError("需要管理员权限")
|
||||
return current_user
|
||||
@ -120,19 +123,14 @@ 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)
|
||||
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
|
||||
return 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
|
||||
|
||||
|
||||
@ -152,10 +150,45 @@ def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||
"""
|
||||
|
||||
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
codes = set(user_repository.get_role_codes(current_user))
|
||||
if not codes & set(role_codes):
|
||||
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
||||
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
|
||||
held = set(user_repository.get_role_codes(current_user))
|
||||
if not held & set(role_codes):
|
||||
raise ForbiddenError(message)
|
||||
return current_user
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
|
||||
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
|
||||
from app.services import authorization_policy as policy
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
endpoint = request.scope["route"].endpoint
|
||||
module = endpoint.__module__.removeprefix("app.api.v1.")
|
||||
name = endpoint.__name__
|
||||
if (module, name) in policy.PUBLIC_ENDPOINTS:
|
||||
return
|
||||
user = get_current_user(credentials, session)
|
||||
body = {}
|
||||
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
|
||||
try:
|
||||
value = await request.json()
|
||||
body = value if isinstance(value, dict) else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
|
||||
if code is None:
|
||||
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
|
||||
if code:
|
||||
try:
|
||||
require_permission(session, user, code)
|
||||
except ForbiddenError:
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
|
||||
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
|
||||
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
|
||||
)):
|
||||
raise
|
||||
request.state.authorization_checked = True
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api import health
|
||||
from app.api.deps import authorize_request
|
||||
from app.api.v1 import auth
|
||||
from app.api.v1.base import business_roles as base_business_roles
|
||||
from app.api.v1.base import customers as base_customers
|
||||
@ -18,17 +19,22 @@ 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.metadata import router as metadata_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
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import calc_templates as system_calc_templates
|
||||
from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import monitor as system_monitor
|
||||
from app.api.v1.system import params as system_params
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import settings as system_settings
|
||||
from app.api.v1.system import tplus as system_tplus
|
||||
from app.api.v1.system import users as system_users
|
||||
from app.api.v1.system.authorization import router as authorization_router
|
||||
from app.api.v1.system.coefficients import router as coefficient_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -36,15 +42,21 @@ api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
|
||||
# 业务接口:/api/v1/*
|
||||
v1_router = APIRouter(prefix="/v1")
|
||||
v1_router = APIRouter(prefix="/v1", dependencies=[Depends(authorize_request)])
|
||||
v1_router.include_router(authorization_router)
|
||||
v1_router.include_router(metadata_router)
|
||||
v1_router.include_router(auth.router)
|
||||
|
||||
# 系统管理(需求 2.5.1):/api/v1/system/*
|
||||
system_router = APIRouter(prefix="/system")
|
||||
system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_monitor.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_business_roles.router)
|
||||
system_router.include_router(system_calc_templates.router)
|
||||
system_router.include_router(system_params.router)
|
||||
|
||||
system_router.include_router(coefficient_router)
|
||||
system_router.include_router(system_settings.router)
|
||||
system_router.include_router(system_approvals.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
|
||||
@ -8,10 +8,8 @@ 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=["认证"])
|
||||
|
||||
@ -67,13 +65,15 @@ def logout(
|
||||
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
||||
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
|
||||
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
|
||||
from app.services.authorization_service import session_info, user_session
|
||||
|
||||
authorization = session_info(user_session(current_user), current_user)
|
||||
return ok(
|
||||
CurrentUserOut(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
real_name=current_user.real_name,
|
||||
roles=get_role_codes(current_user),
|
||||
can_write_payroll=can_write_payroll(current_user),
|
||||
**authorization,
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
last_login_time=current_user.last_login_time,
|
||||
)
|
||||
|
||||
@ -13,7 +13,7 @@ from app.api.deps import DbSession, get_current_user
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.business_role import BusinessRoleOptionOut
|
||||
from app.services import business_role_service
|
||||
from app.services.authorization_metadata import options
|
||||
|
||||
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
|
||||
|
||||
@ -31,4 +31,4 @@ def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[Busines
|
||||
|
||||
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
|
||||
"""
|
||||
return ok(business_role_service.list_options(session))
|
||||
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
|
||||
|
||||
@ -108,7 +108,7 @@ def set_status(
|
||||
) -> ApiResponse[PersonnelOut]:
|
||||
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
|
||||
result = personnel_service.set_status(
|
||||
session, personnel_id, payload.status, operator=current_user, meta=meta
|
||||
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
|
||||
)
|
||||
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
|
||||
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
|
||||
|
||||
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
|
||||
(V5 迁移),待业务管理任务完成后补充。
|
||||
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
|
||||
仅建立产品档案关联,不改写历史业务金额。
|
||||
|
||||
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
|
||||
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
|
||||
@ -19,6 +19,7 @@ from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.product import (
|
||||
ProductCreateIn,
|
||||
ProductExtractIn,
|
||||
ProductOut,
|
||||
ProductQuery,
|
||||
ProductStatusIn,
|
||||
@ -31,6 +32,34 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
@router.get("/extract/candidates")
|
||||
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
|
||||
from app.services.product_completion import candidates
|
||||
|
||||
return ok(candidates(session, keyword, page_num, page_size))
|
||||
|
||||
|
||||
@router.post("/extract/preview")
|
||||
def extraction_preview(payload: ProductExtractIn, session: DbSession, _: CurrentUser):
|
||||
from app.services.product_completion import extraction_plan
|
||||
|
||||
return ok(extraction_plan(session, payload.item_ids))
|
||||
|
||||
|
||||
@router.post("/extract")
|
||||
def extraction_apply(payload: ProductExtractIn, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
|
||||
from app.services.product_completion import extract
|
||||
|
||||
return ok(extract(session, payload, actor, meta))
|
||||
|
||||
|
||||
@router.post("/{product_id}/restore-source")
|
||||
def restore_source(product_id: int, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
|
||||
from app.services.product_completion import restore_source as restore
|
||||
|
||||
return ok(restore(session, product_id, actor, meta))
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
|
||||
def list_products(
|
||||
session: DbSession,
|
||||
|
||||
22
backend/app/api/v1/metadata.py
Normal file
22
backend/app/api/v1/metadata.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""表单参照选项,不能由此读取薪酬、联系方式或完整业务对象。"""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import DbSession, LoginUser
|
||||
from app.core.response import ok
|
||||
from app.services.authorization_metadata import options, payroll_options
|
||||
|
||||
router = APIRouter(prefix="/meta", tags=["业务参照"])
|
||||
|
||||
|
||||
@router.get("/payroll-input/{business_id}")
|
||||
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser):
|
||||
return ok(payroll_options(session, actor, business_id))
|
||||
|
||||
|
||||
@router.get("/options")
|
||||
def metadata_options(session: DbSession, actor: LoginUser, resource: str,
|
||||
business_id: Annotated[int | None, Query(ge=1)] = None,
|
||||
search: Annotated[str | None, Query(max_length=64)] = None):
|
||||
return ok(options(session, actor, resource, business_id=business_id, search=search))
|
||||
@ -3,6 +3,8 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
|
||||
from app.core.response import PageQuery, ok
|
||||
@ -12,6 +14,47 @@ from app.services import payroll_calculation, payroll_inputs, payroll_months, se
|
||||
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
|
||||
|
||||
|
||||
class ProbationAdjustmentIn(BaseModel):
|
||||
personnel_id: int = Field(ge=1)
|
||||
year: str = Field(pattern=r"^20\d{2}$")
|
||||
fingerprint: str = Field(min_length=64, max_length=64)
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
@router.post("/probation/adjustments")
|
||||
def probation_adjustment(payload: ProbationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||
from app.services.probation_service import adjustment as apply
|
||||
|
||||
return ok(apply(session, payload, actor, meta))
|
||||
|
||||
|
||||
@router.get("/probation")
|
||||
def probation_preview(session: DbSession, _: LoginUser, month: str, personnel_id: int | None = None):
|
||||
from app.services import probation_service
|
||||
|
||||
return ok(probation_service.preview(session, month, personnel_id))
|
||||
|
||||
|
||||
@router.get("/probation/export")
|
||||
def probation_export(session: DbSession, _: LoginUser, month: str):
|
||||
from app.services import probation_service
|
||||
|
||||
return Response(probation_service.export(session, month),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="probation_balances.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/salary-export/{month}")
|
||||
def salary_export(month: str, session: DbSession, _: LoginUser):
|
||||
from app.core.month import validate_month
|
||||
from app.services.salary_completion import export_settlement
|
||||
|
||||
validate_month(month)
|
||||
return Response(export_settlement(session, month),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="monthly_salary.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/months/{month}")
|
||||
def month_state(
|
||||
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
|
||||
|
||||
56
backend/app/api/v1/system/authorization.py
Normal file
56
backend/app/api/v1/system/authorization.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ok
|
||||
from app.schemas.authorization import (
|
||||
AuthorizationPreviewIn,
|
||||
AuthorizationSettingsIn,
|
||||
RolePermissionsIn,
|
||||
UserAuthorizationIn,
|
||||
)
|
||||
from app.services import authorization_management as service
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
|
||||
|
||||
|
||||
@router.get("/permissions/catalog")
|
||||
def catalog(session: DbSession, actor: AdminUser):
|
||||
service.require_admin(session, actor)
|
||||
return ok(list(CATALOG.values()))
|
||||
|
||||
|
||||
@router.get("/roles/{role_id}/permissions")
|
||||
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
|
||||
return ok(service.role_permissions(session, role_id))
|
||||
|
||||
|
||||
@router.put("/roles/{role_id}/permissions")
|
||||
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||
return ok(service.save_role(session, role_id, payload, actor, meta))
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/authorization")
|
||||
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
|
||||
return ok(service.user_authorization(session, user_id))
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/authorization/preview")
|
||||
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
|
||||
return ok(service.preview(session, user_id, payload))
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/authorization")
|
||||
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||
return ok(service.save_user(session, user_id, payload, actor, meta))
|
||||
|
||||
|
||||
@router.get("/authorization-settings")
|
||||
def settings(session: DbSession, actor: AdminUser):
|
||||
return ok(service.settings_out(session))
|
||||
|
||||
|
||||
@router.put("/authorization-settings")
|
||||
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
|
||||
return ok(service.save_settings(session, payload, actor, meta))
|
||||
@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
|
||||
|
||||
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
|
||||
"""
|
||||
return ok(business_role_service.list_template_options())
|
||||
return ok(business_role_service.list_template_options(session))
|
||||
|
||||
|
||||
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
|
||||
|
||||
70
backend/app/api/v1/system/calc_templates.py
Normal file
70
backend/app/api/v1/system/calc_templates.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""计提公式模板管理接口(管理员)。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.schemas.calc_template import (
|
||||
CalcTemplateCreateIn,
|
||||
CalcTemplateOut,
|
||||
CalcTemplateUpdateIn,
|
||||
FormulaOptionOut,
|
||||
)
|
||||
from app.services import calc_template_service
|
||||
|
||||
router = APIRouter(prefix="/calc-templates", tags=["系统管理 · 公式模板"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[list[CalcTemplateOut]], summary="模板列表")
|
||||
def list_templates(session: DbSession, _: AdminUser) -> ApiResponse[list[CalcTemplateOut]]:
|
||||
"""取全部模板(含停用与岗位引用计数)。"""
|
||||
return ok(calc_template_service.list_templates(session).records)
|
||||
|
||||
|
||||
@router.get("/meta/formulas", response_model=ApiResponse[list[FormulaOptionOut]], summary="公式骨架下拉")
|
||||
def list_formula_options(_: AdminUser) -> ApiResponse[list[FormulaOptionOut]]:
|
||||
"""取受控公式骨架集合(新增模板时选择)。"""
|
||||
return ok([FormulaOptionOut.model_validate(row) for row in calc_template_service.list_formula_options()])
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[CalcTemplateOut], summary="新增模板")
|
||||
def create_template(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
payload: CalcTemplateCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcTemplateOut]:
|
||||
"""新增公式模板。编码唯一、骨架受控、参数绑定按骨架角色校验。"""
|
||||
return ok(
|
||||
calc_template_service.create_template(session, payload, operator=current_user, meta=meta),
|
||||
message="模板已创建",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=ApiResponse[CalcTemplateOut], summary="编辑模板")
|
||||
def update_template(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
template_id: Annotated[int, Path(ge=1)],
|
||||
payload: CalcTemplateUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcTemplateOut]:
|
||||
"""编辑模板名称/说明/排序;自定义模板可调整参数绑定。"""
|
||||
return ok(
|
||||
calc_template_service.update_template(session, template_id, payload, operator=current_user, meta=meta),
|
||||
message="已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", response_model=ApiResponse[None], summary="删除模板")
|
||||
def delete_template(
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
template_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
"""删除模板。系统模板禁删;被岗位引用禁删。"""
|
||||
calc_template_service.delete_template(session, template_id, operator=current_user, meta=meta)
|
||||
return ok(message="模板已删除")
|
||||
37
backend/app/api/v1/system/coefficients.py
Normal file
37
backend/app/api/v1/system/coefficients.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""系数表查询、维护与工作簿导入导出,复用计提参数权限。"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.deps import DbSession, LoginUser, RequestMetaDep
|
||||
from app.core.response import ok
|
||||
from app.schemas.coefficient import CoefficientChangeIn
|
||||
from app.services import coefficient_service
|
||||
|
||||
router = APIRouter(prefix="/coefficients", tags=["计提系数表"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def catalog(session: DbSession, _: LoginUser, as_of: date | None = None):
|
||||
return ok(coefficient_service.catalog(session, as_of))
|
||||
|
||||
|
||||
@router.put("")
|
||||
def save(payload: CoefficientChangeIn, session: DbSession, actor: LoginUser, meta: RequestMetaDep):
|
||||
return ok(coefficient_service.save(session, payload, actor, meta))
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export(session: DbSession, _: LoginUser, as_of: date | None = None):
|
||||
return Response(
|
||||
coefficient_service.workbook(session, as_of),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="coefficients.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import-preview")
|
||||
def import_preview(session: DbSession, _: LoginUser, file: UploadFile = File(...)):
|
||||
return ok(coefficient_service.import_preview(session, file.file.read(2 * 1024 * 1024 + 1)))
|
||||
56
backend/app/api/v1/system/monitor.py
Normal file
56
backend/app/api/v1/system/monitor.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""系统监控只读接口;全部复用管理员依赖,不提供运维写操作。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.deps import AdminUser, DbSession
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.monitor import EventOut, EventQuery, MonitorWindow, OverviewOut
|
||||
from app.services import monitor_service
|
||||
|
||||
router = APIRouter(prefix="/monitor", tags=["系统管理 · 系统监控"])
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ApiResponse[OverviewOut], summary="健康总览")
|
||||
def overview(request: Request, session: DbSession, _: AdminUser):
|
||||
return ok(monitor_service.overview(session, request.app.state.monitor))
|
||||
|
||||
|
||||
@router.get("/trends", response_model=ApiResponse[dict], summary="分钟级监控趋势")
|
||||
def trends(
|
||||
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
|
||||
):
|
||||
return ok(monitor_service.trends(session, request.app.state.monitor, query))
|
||||
|
||||
|
||||
@router.get("/interfaces", response_model=ApiResponse[dict], summary="接口性能排行")
|
||||
def interfaces(
|
||||
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
|
||||
):
|
||||
return ok(monitor_service.interfaces(session, request.app.state.monitor, query))
|
||||
|
||||
|
||||
@router.get("/events", response_model=ApiResponse[PageResult[EventOut]], summary="诊断事件")
|
||||
def events(session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]):
|
||||
return ok(monitor_service.events(session, query))
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=ApiResponse[EventOut], summary="诊断事件详情")
|
||||
def event(session: DbSession, _: AdminUser, event_id: Annotated[int, Path(ge=1)]):
|
||||
return ok(monitor_service.event(session, event_id))
|
||||
|
||||
|
||||
@router.get("/diagnostic", summary="下载脱敏诊断摘要")
|
||||
def diagnostic(
|
||||
request: Request, session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]
|
||||
):
|
||||
return JSONResponse(
|
||||
jsonable_encoder(monitor_service.diagnostic(session, request.app.state.monitor, query)),
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="monitor-diagnostic.json"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
@ -8,6 +8,8 @@ from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchRead
|
||||
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 (
|
||||
TPlusAutomationOverview,
|
||||
TPlusAutomationUpdate,
|
||||
TPlusBatchDetailOut,
|
||||
TPlusBatchOut,
|
||||
TPlusBatchQuery,
|
||||
@ -17,7 +19,7 @@ from app.schemas.tplus import (
|
||||
TPlusOverviewOut,
|
||||
TPlusRetryIn,
|
||||
)
|
||||
from app.services.tplus import query_service, task_service
|
||||
from app.services.tplus import automation_config, query_service, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
router.include_router(workbench_router)
|
||||
@ -30,6 +32,13 @@ def overview(session: DbSession, _: WorkbenchReader):
|
||||
return ok(query_service.overview(session))
|
||||
|
||||
|
||||
@router.put("/automation", response_model=ApiResponse[TPlusAutomationOverview])
|
||||
def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
|
||||
payload: TPlusAutomationUpdate):
|
||||
"""保存自动排队开关;既有活动任务继续执行。"""
|
||||
return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta)))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@ -35,13 +36,29 @@ class Settings(BaseSettings):
|
||||
|
||||
# ===== 应用 =====
|
||||
app_env: str = "dev"
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
||||
upload_root: Path | None = Field(default=None, validation_alias="UPLOAD_DIR")
|
||||
|
||||
@field_validator("upload_root", mode="before")
|
||||
@classmethod
|
||||
def _empty_upload_root(cls, value):
|
||||
return None if value in (None, "") else value
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符", repr=False)
|
||||
payroll_maker_user_id: int | None = Field(
|
||||
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
|
||||
)
|
||||
payroll_allow_legacy_calculation: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
|
||||
# ===== 单进程运行监控;历史只清理监控新表 =====
|
||||
monitor_enabled: bool = True
|
||||
monitor_cpu_threshold: float = Field(default=85, ge=1, le=100)
|
||||
monitor_memory_threshold: float = Field(default=85, ge=1, le=100)
|
||||
monitor_disk_threshold: float = Field(default=90, ge=1, le=100)
|
||||
monitor_resource_duration_seconds: int = Field(default=300, ge=15)
|
||||
monitor_slow_request_ms: int = Field(default=2000, ge=1)
|
||||
monitor_error_rate_threshold: float = Field(default=5, ge=0.1, le=100)
|
||||
monitor_error_min_requests: int = Field(default=20, ge=1)
|
||||
|
||||
# ===== 鉴权 =====
|
||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||
jwt_expire_minutes: int = Field(default=120, ge=1, le=120)
|
||||
@ -55,12 +72,12 @@ class Settings(BaseSettings):
|
||||
mysql_port: int = 3306
|
||||
mysql_database: str = "payroll"
|
||||
mysql_user: str = "payroll"
|
||||
mysql_password: str = ""
|
||||
mysql_password: str = Field(default="", repr=False)
|
||||
|
||||
# ===== Redis =====
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_password: str = Field(default="", repr=False)
|
||||
redis_db: int = 0
|
||||
|
||||
# ===== T+ 只读 SQL Server =====
|
||||
@ -115,7 +132,7 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Redis 连接 URL。密码为空时不拼接认证段。"""
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
auth = f":{quote(self.redis_password, safe='')}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
@ -149,7 +166,7 @@ class Settings(BaseSettings):
|
||||
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||
"""
|
||||
return BACKEND_DIR / "uploads"
|
||||
return self.upload_root.resolve() if self.upload_root else BACKEND_DIR / "uploads"
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@ -74,13 +74,16 @@ async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONRespons
|
||||
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
||||
"""
|
||||
logger.exception("未预期的服务端异常")
|
||||
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||
request_id = getattr(request.state, "request_id", "-")
|
||||
logger.exception("未预期的服务端异常", extra={"request_id": request_id})
|
||||
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
|
||||
@ -52,3 +52,10 @@ class NotFoundError(AppException):
|
||||
|
||||
code = CODE_NOT_FOUND
|
||||
message = "资源不存在"
|
||||
|
||||
|
||||
class ConflictError(AppException):
|
||||
"""授权版本已变化,客户端应刷新后重新核对。"""
|
||||
|
||||
code = 409
|
||||
message = "配置已被其他人修改,请刷新后重新核对"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""日志配置。
|
||||
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天轮转(当前未做大小上限,磁盘容量需配合监控)、
|
||||
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
||||
|
||||
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
||||
@ -13,6 +13,7 @@ import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
from app.core.monitoring import REQUEST_ID
|
||||
|
||||
LOG_DIR = BACKEND_DIR / "logs"
|
||||
|
||||
@ -53,6 +54,8 @@ class SensitiveFormatter(logging.Formatter):
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if not hasattr(record, "request_id"):
|
||||
record.request_id = REQUEST_ID.get()
|
||||
return scrub(super().format(record))
|
||||
|
||||
|
||||
@ -71,7 +74,7 @@ def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
|
||||
root.removeHandler(handler)
|
||||
|
||||
formatter = SensitiveFormatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
240
backend/app/core/monitoring.py
Normal file
240
backend/app/core/monitoring.py
Normal file
@ -0,0 +1,240 @@
|
||||
"""有界、线程安全的请求遥测;请求线程只写内存,绝不等待监控数据库。"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
from collections import OrderedDict, deque
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from time import monotonic, perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
SHANGHAI = timezone(timedelta(hours=8))
|
||||
# 固定上界直方图,可相加后求近似 P95;最后一档使用实际最大耗时。
|
||||
BOUNDS = (
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
500,
|
||||
750,
|
||||
1000,
|
||||
1500,
|
||||
2000,
|
||||
3000,
|
||||
5000,
|
||||
10000,
|
||||
30000,
|
||||
60000,
|
||||
120000,
|
||||
300000,
|
||||
)
|
||||
|
||||
|
||||
def now_local() -> datetime:
|
||||
return datetime.now(SHANGHAI).replace(tzinfo=None)
|
||||
|
||||
|
||||
def minute_of(value: datetime) -> datetime:
|
||||
return value.replace(second=0, microsecond=0)
|
||||
|
||||
|
||||
def empty_metric() -> dict:
|
||||
return {
|
||||
"count": 0,
|
||||
"errors_5xx": 0,
|
||||
"errors_4xx": 0,
|
||||
"max_ms": 0.0,
|
||||
"histogram": [0] * (len(BOUNDS) + 1),
|
||||
"last_error_at": None,
|
||||
}
|
||||
|
||||
|
||||
def merge_metrics(items) -> dict:
|
||||
result = empty_metric()
|
||||
for item in items:
|
||||
for key in ("count", "errors_5xx", "errors_4xx"):
|
||||
result[key] += item[key]
|
||||
result["max_ms"] = max(result["max_ms"], item["max_ms"])
|
||||
for index, count in enumerate(item["histogram"]):
|
||||
result["histogram"][index] += count
|
||||
last = item.get("last_error_at")
|
||||
if last and (not result["last_error_at"] or last > result["last_error_at"]):
|
||||
result["last_error_at"] = last
|
||||
result["error_rate"] = (
|
||||
round(result["errors_5xx"] / result["count"] * 100, 2) if result["count"] else 0
|
||||
)
|
||||
result["p95_ms"] = percentile(result)
|
||||
return result
|
||||
|
||||
|
||||
def percentile(metric: dict) -> float | None:
|
||||
if not metric["count"]:
|
||||
return None
|
||||
target, count = math.ceil(metric["count"] * 0.95), 0
|
||||
for index, value in enumerate(metric["histogram"]):
|
||||
count += value
|
||||
if count >= target:
|
||||
return min(BOUNDS[index], metric["max_ms"]) if index < len(BOUNDS) else metric["max_ms"]
|
||||
return None
|
||||
|
||||
|
||||
def fingerprint(code: str, component: str = "") -> str:
|
||||
return hashlib.sha256(f"{code}:{component}".encode()).hexdigest()
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""最多保留十分钟、每分钟 200 个路由、2000 条待写事件。"""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.minutes = OrderedDict()
|
||||
self.events = deque(maxlen=2000)
|
||||
self.dropped_events = 0
|
||||
self.started = monotonic()
|
||||
self.instance_id = uuid4().hex[:16]
|
||||
self.slow_ms = 2000
|
||||
|
||||
def record(self, method, route, status, elapsed, request_id, *, at=None):
|
||||
at = at or now_local()
|
||||
key = minute_of(at)
|
||||
with self.lock:
|
||||
endpoints = self.minutes.setdefault(key, {})
|
||||
endpoint = (method, route)
|
||||
if endpoint not in endpoints and len(endpoints) >= 200:
|
||||
endpoint = ("OTHER", "/__other__")
|
||||
metric = endpoints.setdefault(endpoint, empty_metric())
|
||||
metric["count"] += 1
|
||||
metric["errors_5xx"] += int(status >= 500)
|
||||
metric["errors_4xx"] += int(400 <= status < 500)
|
||||
metric["max_ms"] = max(metric["max_ms"], round(elapsed, 2))
|
||||
bucket = next((i for i, bound in enumerate(BOUNDS) if elapsed <= bound), len(BOUNDS))
|
||||
metric["histogram"][bucket] += 1
|
||||
if status >= 500:
|
||||
metric["last_error_at"] = at.isoformat()
|
||||
while len(self.minutes) > 10:
|
||||
self.minutes.popitem(last=False)
|
||||
if status >= 500 or elapsed >= self.slow_ms:
|
||||
self._event(
|
||||
{
|
||||
"kind": "REQUEST_ERROR" if status >= 500 else "SLOW_REQUEST",
|
||||
"code": "HTTP_5XX" if status >= 500 else "REQUEST_SLOW",
|
||||
"component": "http",
|
||||
"message": "接口处理异常,请按请求编号检索服务端日志。"
|
||||
if status >= 500
|
||||
else "接口响应较慢,请核对同一时段资源和任务。",
|
||||
"request_id": request_id,
|
||||
"method": method,
|
||||
"route": route,
|
||||
"status_code": status,
|
||||
"duration_ms": round(elapsed, 2),
|
||||
"first_seen": at,
|
||||
"last_seen": at,
|
||||
"occurrences": 1,
|
||||
"level": "error" if status >= 500 else "warning",
|
||||
}
|
||||
)
|
||||
|
||||
def _event(self, event):
|
||||
if len(self.events) == self.events.maxlen:
|
||||
self.dropped_events += 1
|
||||
self.events.append(event)
|
||||
|
||||
def runtime_error(self, component):
|
||||
with self.lock:
|
||||
at = now_local()
|
||||
self._event(
|
||||
{
|
||||
"kind": "RUNTIME_ERROR",
|
||||
"code": "BACKGROUND_ERROR",
|
||||
"component": component[:128],
|
||||
"message": "后台运行出现错误,请检索服务端日志。",
|
||||
"first_seen": at,
|
||||
"last_seen": at,
|
||||
"occurrences": 1,
|
||||
"level": "error",
|
||||
}
|
||||
)
|
||||
|
||||
def metrics(self, start=None, end=None):
|
||||
with self.lock:
|
||||
return deepcopy(
|
||||
{
|
||||
key: value
|
||||
for key, value in self.minutes.items()
|
||||
if (start is None or key >= start) and (end is None or key < end)
|
||||
}
|
||||
)
|
||||
|
||||
def take_events(self):
|
||||
with self.lock:
|
||||
values = list(self.events)
|
||||
self.events.clear()
|
||||
return values
|
||||
|
||||
def restore_events(self, values):
|
||||
with self.lock:
|
||||
for value in reversed(values):
|
||||
if len(self.events) < self.events.maxlen:
|
||||
self.events.appendleft(value)
|
||||
else:
|
||||
self.dropped_events += 1
|
||||
|
||||
|
||||
class MonitorMiddleware:
|
||||
"""纯 ASGI 中间件:包含异常、流式响应与取消路径,避免读取请求正文。"""
|
||||
|
||||
def __init__(self, app: ASGIApp, recorder: Recorder):
|
||||
self.app, self.recorder = app, recorder
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
request_id = uuid4().hex
|
||||
scope.setdefault("state", {})["request_id"] = request_id
|
||||
token = REQUEST_ID.set(request_id)
|
||||
started, status = perf_counter(), 500
|
||||
|
||||
async def with_id(message: Message):
|
||||
nonlocal status
|
||||
if message["type"] == "http.response.start":
|
||||
status = message["status"]
|
||||
message["headers"] = [
|
||||
(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"
|
||||
]
|
||||
message["headers"].append((b"x-request-id", request_id.encode()))
|
||||
if scope.get("path", "").startswith("/api/v1/system/monitor"):
|
||||
message["headers"].append((b"cache-control", b"no-store"))
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, with_id)
|
||||
except Exception:
|
||||
status = 500
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
route = getattr(scope.get("route"), "path", "/__unmatched__")
|
||||
excluded = route.startswith(
|
||||
("/api/v1/system/monitor", "/api/health", "/api/docs", "/api/openapi")
|
||||
)
|
||||
if not excluded:
|
||||
method = scope.get("method", "OTHER")
|
||||
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
|
||||
method = "OTHER"
|
||||
self.recorder.record(
|
||||
method, route, status, (perf_counter() - started) * 1000, request_id
|
||||
)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning("请求遥测写入失败", exc_info=True)
|
||||
finally:
|
||||
REQUEST_ID.reset(token)
|
||||
@ -14,14 +14,16 @@ from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.monitoring import MonitorMiddleware, Recorder
|
||||
from app.core.redis_client import close_redis
|
||||
from app.core.tplus_db import dispose_tplus_engine
|
||||
from app.services.monitor_collector import MonitorCollector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期管理。
|
||||
|
||||
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
||||
@ -34,7 +36,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings.mysql_host,
|
||||
settings.mysql_database,
|
||||
)
|
||||
yield
|
||||
app.state.monitor.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
app.state.monitor.stop()
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
close_redis()
|
||||
@ -62,6 +68,9 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_exception_handlers(app)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
recorder = Recorder()
|
||||
app.state.monitor = MonitorCollector(settings, recorder)
|
||||
app.add_middleware(MonitorMiddleware, recorder=recorder)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@ -10,16 +10,20 @@
|
||||
"""
|
||||
|
||||
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||
from app.models.business_role import BusinessRole
|
||||
from app.models.calc_template import CalcTemplateDefinition
|
||||
from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||||
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.probation import ProbationDeferral, ProbationPayment
|
||||
from app.models.product import Product
|
||||
from app.models.rating import CustomerRatingGrade
|
||||
from app.models.receipt import Receipt
|
||||
@ -27,7 +31,7 @@ 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_automation import TPlusAutomationConfig, TPlusAutomationState
|
||||
from app.models.tplus_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
@ -47,14 +51,19 @@ __all__ = [
|
||||
"AdjustmentOrder",
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"AuthorizationSetting",
|
||||
"Base",
|
||||
"Business",
|
||||
"BusinessItem",
|
||||
"BusinessRole",
|
||||
"BusinessStatusLog",
|
||||
"CalcTemplateDefinition",
|
||||
"Customer",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonitorEvent",
|
||||
"MonitorRequestMinute",
|
||||
"MonitorSnapshot",
|
||||
"MonthlySettlement",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
@ -69,6 +78,8 @@ __all__ = [
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
"PersonnelRegion",
|
||||
"ProbationDeferral",
|
||||
"ProbationPayment",
|
||||
"Product",
|
||||
"Receipt",
|
||||
"Role",
|
||||
@ -78,6 +89,7 @@ __all__ = [
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusAutomationConfig",
|
||||
"TPlusAutomationState",
|
||||
"TPlusFact",
|
||||
"TPlusSourceHead",
|
||||
@ -90,5 +102,6 @@ __all__ = [
|
||||
"TPlusWorkRun",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserPermissionOverride",
|
||||
"UserRole",
|
||||
]
|
||||
|
||||
28
backend/app/models/authorization.py
Normal file
28
backend/app/models/authorization.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""授权版本锁及旧版授权结构;V26 后不再使用个人覆盖或全局制作人绑定。"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class UserPermissionOverride(Base, TimestampMixin):
|
||||
__tablename__ = "user_permission_overrides"
|
||||
__table_args__ = mysql_table_args(
|
||||
"用户权限覆盖表(个人禁止优先)",
|
||||
UniqueConstraint("user_id", "permission_id", name="uk_user_permission_overrides_user_permission"),
|
||||
Index("idx_user_permission_overrides_permission_id", "permission_id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
user_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="CASCADE"), comment="系统用户ID")
|
||||
permission_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("permissions.id", ondelete="RESTRICT"), comment="权限ID")
|
||||
effect: Mapped[str] = mapped_column(String(8), comment="个人覆盖:ALLOW允许 DENY禁止")
|
||||
|
||||
|
||||
class AuthorizationSetting(Base, TimestampMixin):
|
||||
__tablename__ = "authorization_settings"
|
||||
__table_args__ = mysql_table_args("授权设置表(单例与并发修改锁)")
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="单例主键ID")
|
||||
maker_user_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="RESTRICT"), comment="现任工资制作人账号ID")
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1", comment="全局授权版本")
|
||||
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="SET NULL"), comment="最近修改人ID")
|
||||
52
backend/app/models/calc_template.py
Normal file
52
backend/app/models/calc_template.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""计提公式模板定义模型。
|
||||
|
||||
把一期代码内注册表升级为数据库配置:模板名称、说明、参数绑定可在页面维护,
|
||||
公式执行骨架仍是受控枚举(formula_type),不允许自由表达式,避免注入与算错。
|
||||
岗位角色 `business_roles.calc_template` 绑定模板编码,编码创建后不可修改。
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class CalcTemplateDefinition(Base, TimestampMixin):
|
||||
"""计提公式模板定义表。内置模板随迁移初始化,页面可新增自定义模板。"""
|
||||
|
||||
__tablename__ = "calc_template_definitions"
|
||||
__table_args__ = mysql_table_args("计提公式模板定义表(页面可维护,骨架受控)")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
template_code: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="模板编码,创建后不可修改;岗位表 business_roles.calc_template 引用",
|
||||
)
|
||||
template_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="模板名称")
|
||||
formula_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="公式骨架类型:linear_decay/fixed_profit/fixed_sales/manager_share/prepay_fixed,创建后不可修改",
|
||||
)
|
||||
params_json: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
comment="骨架参数角色到参数编码的 JSON 映射,如 {start:..., slope:...}",
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(String(255), comment="模板说明")
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||
)
|
||||
is_system: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="是否系统模板:1内置(禁删、骨架与编码锁定) 0自定义",
|
||||
)
|
||||
79
backend/app/models/monitor.py
Normal file
79
backend/app/models/monitor.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""监控数据独立保存,不关联工资或审计表;仅这些新表按七天保留。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
|
||||
|
||||
|
||||
class MonitorSnapshot(Base):
|
||||
__tablename__ = "monitor_snapshots"
|
||||
__table_args__ = mysql_table_args(
|
||||
"分钟级运行监控快照",
|
||||
UniqueConstraint("instance_id", "minute", name="uk_monitor_snapshots_instance_minute"),
|
||||
Index("idx_monitor_snapshots_minute", "minute"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||
minute: Mapped[datetime] = mapped_column(DateTime, comment="采集分钟,北京时间")
|
||||
sample_count: Mapped[int] = mapped_column(Integer, comment="实际采样次数")
|
||||
resources: Mapped[dict] = mapped_column(JSON, comment="资源均值与采集范围,不含配置")
|
||||
requests: Mapped[dict] = mapped_column(JSON, comment="请求汇总与固定耗时直方图")
|
||||
|
||||
|
||||
class MonitorRequestMinute(Base):
|
||||
__tablename__ = "monitor_request_minutes"
|
||||
__table_args__ = mysql_table_args(
|
||||
"接口分钟级运行统计",
|
||||
UniqueConstraint(
|
||||
"instance_id", "minute", "method", "route", name="uk_monitor_request_minutes_endpoint"
|
||||
),
|
||||
Index("idx_monitor_request_minutes_minute", "minute"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||
minute: Mapped[datetime] = mapped_column(DateTime, comment="请求分钟,北京时间")
|
||||
method: Mapped[str] = mapped_column(String(8), comment="HTTP方法,固定集合")
|
||||
route: Mapped[str] = mapped_column(String(256), comment="路由模板,不含原始地址及参数")
|
||||
metrics: Mapped[dict] = mapped_column(JSON, comment="请求数、状态分类与固定直方图")
|
||||
|
||||
|
||||
class MonitorEvent(Base):
|
||||
__tablename__ = "monitor_events"
|
||||
__table_args__ = mysql_table_args(
|
||||
"脱敏诊断事件与异常恢复记录",
|
||||
Index("idx_monitor_events_last_seen", "last_seen"),
|
||||
Index("idx_monitor_events_request_id", "request_id"),
|
||||
Index("idx_monitor_events_fingerprint", "fingerprint", "resolved_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
|
||||
kind: Mapped[str] = mapped_column(String(24), comment="事件类型,固定集合")
|
||||
code: Mapped[str] = mapped_column(String(64), comment="稳定错误码")
|
||||
component: Mapped[str] = mapped_column(String(128), comment="所属组件或日志模块")
|
||||
level: Mapped[str] = mapped_column(String(16), comment="异常级别")
|
||||
message: Mapped[str] = mapped_column(String(256), comment="通用说明,不保存内部异常文本")
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="同类健康异常合并键")
|
||||
request_id: Mapped[str | None] = mapped_column(String(32), comment="请求关联编号")
|
||||
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP方法")
|
||||
route: Mapped[str | None] = mapped_column(String(256), comment="路由模板")
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, comment="HTTP状态码")
|
||||
duration_ms: Mapped[float | None] = mapped_column(Float, comment="请求耗时,毫秒")
|
||||
batch_no: Mapped[str | None] = mapped_column(String(32), comment="相关同步批次编号")
|
||||
first_seen: Mapped[datetime] = mapped_column(DateTime, comment="首次发生时间")
|
||||
last_seen: Mapped[datetime] = mapped_column(DateTime, comment="最近发生时间")
|
||||
occurrences: Mapped[int] = mapped_column(Integer, comment="发生或持续采样次数")
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="恢复时间,仅用于健康异常"
|
||||
)
|
||||
@ -10,7 +10,7 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy import JSON, Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@ -62,9 +62,22 @@ class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||
server_default="0",
|
||||
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
|
||||
)
|
||||
probation_start_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="试用期开始日(含当日)"
|
||||
)
|
||||
regularization_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="转正生效日(当日不再属于试用期)"
|
||||
)
|
||||
resignation_date: Mapped[date | None] = mapped_column(
|
||||
Date, comment="离职生效日(当日起不计基本工资)"
|
||||
)
|
||||
salary_standard: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
|
||||
)
|
||||
salary_history_json: Mapped[list | None] = mapped_column(
|
||||
JSON,
|
||||
comment="工资标准、岗位及任职资料生效版本",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
81
backend/app/models/probation.py
Normal file
81
backend/app/models/probation.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""试用期缓发与结清记录,引用固化台账,不使用人员当前开关重写历史。"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
|
||||
|
||||
|
||||
class ProbationDeferral(Base):
|
||||
__tablename__ = "probation_deferrals"
|
||||
__table_args__ = mysql_table_args(
|
||||
"试用期缓发台账(每个正式台账版本一条)",
|
||||
UniqueConstraint("ledger_id", name="uk_probation_deferrals_ledger_id"),
|
||||
Index("idx_probation_deferrals_personnel_id_year", "personnel_id", "year"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
ledger_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("performance_ledgers.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="原核算台账版本ID",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
|
||||
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原计提年度")
|
||||
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次计提或应付变化月份")
|
||||
accrued: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="本次试用期计提额,可负"
|
||||
)
|
||||
eligible: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="满足起付及权重的本次应付差额,可负"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
|
||||
)
|
||||
|
||||
|
||||
class ProbationPayment(Base):
|
||||
__tablename__ = "probation_payments"
|
||||
__table_args__ = mysql_table_args(
|
||||
"试用期年终及后续结清记录(工资生成事务内固化)",
|
||||
UniqueConstraint(
|
||||
"settlement_id",
|
||||
"personnel_id",
|
||||
"year",
|
||||
name="uk_probation_payments_settlement_person_year",
|
||||
),
|
||||
UniqueConstraint("adjustment_order_id", name="uk_probation_payments_adjustment_order_id"),
|
||||
Index("idx_probation_payments_personnel_id_year", "personnel_id", "year"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
settlement_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
comment="月结ID(当月调整单结清时为空)",
|
||||
)
|
||||
adjustment_order_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("adjustment_orders.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
comment="已生成工资后的结清调整单ID",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
|
||||
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原累计年度")
|
||||
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次实际支付月份")
|
||||
amount: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="本次补发或扣回金额"
|
||||
)
|
||||
snapshot_json: Mapped[dict] = mapped_column(
|
||||
JSON, nullable=False, comment="计提、起付权重、门槛及历史补发依据"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
|
||||
)
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Numeric, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@ -56,3 +56,5 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
source_trade_json: Mapped[dict | None] = mapped_column(JSON, comment="最近有效发货数量、含税成交价及源单据快照")
|
||||
quantity_price_manual: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, server_default="0", comment="数量单价人工保护:1人工维护 0随源更新")
|
||||
|
||||
@ -131,17 +131,27 @@ class SettlementItem(Base):
|
||||
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
|
||||
)
|
||||
net_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
|
||||
Numeric(14, 2),
|
||||
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
|
||||
)
|
||||
is_probation: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||
comment="试用期:1缓发绩效不发(基本工资正常发,超年累计记 note)0否",
|
||||
)
|
||||
probation_note: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
|
||||
)
|
||||
normal_payable_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="当月正常实付绩效"
|
||||
)
|
||||
deferred_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="当月试用期缓发变化"
|
||||
)
|
||||
probation_payment_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, server_default="0", comment="本月年度补发或扣回"
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="个人明细快照(台账行引用列表)"
|
||||
)
|
||||
|
||||
@ -2,10 +2,26 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusAutomationConfig(Base, TimestampMixin):
|
||||
"""页面保存的源级开关,与长任务心跳表分开,切换不争用采集状态行。"""
|
||||
|
||||
__tablename__ = "tplus_automation_configs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+自动采集设置(不含连接凭据)",
|
||||
UniqueConstraint("source_key", name="uk_tplus_automation_configs_source_key"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_key: Mapped[str] = mapped_column(String(64), comment="源连接身份摘要")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, comment="自动采集是否开启:1开启0关闭")
|
||||
version: Mapped[int] = mapped_column(Integer, comment="设置版本号,防止并发覆盖")
|
||||
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
|
||||
|
||||
|
||||
class TPlusAutomationState(Base, TimestampMixin):
|
||||
|
||||
66
backend/app/repositories/calc_template_repository.py
Normal file
66
backend/app/repositories/calc_template_repository.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""计提公式模板数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BusinessRole, CalcTemplateDefinition
|
||||
|
||||
|
||||
def get_by_id(session: Session, template_id: int, *, for_update: bool = False) -> CalcTemplateDefinition | None:
|
||||
"""按主键查询;删除校验走行锁。"""
|
||||
if for_update:
|
||||
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.id == template_id).with_for_update()
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
return session.get(CalcTemplateDefinition, template_id)
|
||||
|
||||
|
||||
def get_by_code(session: Session, template_code: str) -> CalcTemplateDefinition | None:
|
||||
"""按编码查询模板。编码是岗位绑定与参数解析的锚点。"""
|
||||
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.template_code == template_code)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_all(session: Session) -> list[CalcTemplateDefinition]:
|
||||
"""全部模板(含停用,供管理列表展示引用状态)。"""
|
||||
stmt = select(CalcTemplateDefinition).order_by(
|
||||
CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_active(session: Session) -> list[CalcTemplateDefinition]:
|
||||
"""启用模板,供岗位表单下拉。"""
|
||||
stmt = (
|
||||
select(CalcTemplateDefinition)
|
||||
.where(CalcTemplateDefinition.status == 1)
|
||||
.order_by(CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id)
|
||||
)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles_by_codes(session: Session, codes: list[str]) -> dict[str, int]:
|
||||
"""批量统计各模板被岗位引用的数量,用于删除保护与列表展示。"""
|
||||
if not codes:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(BusinessRole.calc_template, func.count(BusinessRole.id))
|
||||
.where(BusinessRole.calc_template.in_(codes))
|
||||
.group_by(BusinessRole.calc_template)
|
||||
).all()
|
||||
return {code: int(count) for code, count in rows}
|
||||
|
||||
|
||||
def create(session: Session, template: CalcTemplateDefinition) -> CalcTemplateDefinition:
|
||||
"""新增模板。只 flush 以取得自增 id。"""
|
||||
session.add(template)
|
||||
session.flush()
|
||||
return template
|
||||
|
||||
|
||||
def delete(session: Session, template: CalcTemplateDefinition) -> None:
|
||||
"""删除模板。调用方须先完成保护规则校验。"""
|
||||
session.delete(template)
|
||||
session.flush()
|
||||
59
backend/app/repositories/monitor_repository.py
Normal file
59
backend/app/repositories/monitor_repository.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""监控查询只使用固定字段与有界窗口;事务由服务层负责。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||||
|
||||
|
||||
def snapshots(session, start, end):
|
||||
return session.scalars(
|
||||
select(MonitorSnapshot)
|
||||
.where(MonitorSnapshot.minute >= start, MonitorSnapshot.minute < end)
|
||||
.order_by(MonitorSnapshot.minute)
|
||||
.limit(10082)
|
||||
).all()
|
||||
|
||||
|
||||
def request_minutes(session, start, end, route=None):
|
||||
statement = select(MonitorRequestMinute).where(
|
||||
MonitorRequestMinute.minute >= start, MonitorRequestMinute.minute < end
|
||||
)
|
||||
if route:
|
||||
statement = statement.where(MonitorRequestMinute.route == route)
|
||||
return session.scalars(
|
||||
statement.order_by(MonitorRequestMinute.minute.desc()).limit(20001)
|
||||
).all()
|
||||
|
||||
|
||||
def events(session, query, start, end):
|
||||
filters = [MonitorEvent.last_seen >= start, MonitorEvent.first_seen < end]
|
||||
if query.kind:
|
||||
filters.append(MonitorEvent.kind == query.kind)
|
||||
if query.request_id:
|
||||
filters.append(MonitorEvent.request_id == query.request_id)
|
||||
if query.route:
|
||||
filters.append(MonitorEvent.route == query.route)
|
||||
if query.active_only:
|
||||
filters.extend([MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None)])
|
||||
total = session.scalar(select(func.count()).select_from(MonitorEvent).where(*filters)) or 0
|
||||
rows = session.scalars(
|
||||
select(MonitorEvent)
|
||||
.where(*filters)
|
||||
.order_by(MonitorEvent.last_seen.desc(), MonitorEvent.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total
|
||||
|
||||
|
||||
def active_events(session):
|
||||
return session.scalars(
|
||||
select(MonitorEvent)
|
||||
.where(MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None))
|
||||
.order_by(MonitorEvent.last_seen.desc())
|
||||
.limit(100)
|
||||
).all()
|
||||
|
||||
|
||||
def event(session, event_id):
|
||||
return session.get(MonitorEvent, event_id)
|
||||
@ -1,6 +1,6 @@
|
||||
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
@ -129,6 +129,24 @@ def status_counts(session: Session) -> dict[str, int]:
|
||||
)
|
||||
|
||||
|
||||
def progress_reports(session: Session, numbers):
|
||||
if not numbers:
|
||||
return {}
|
||||
rows = session.execute(select(TPlusSyncBatch.batch_no,
|
||||
TPlusSyncBatch.audit_result_json["progress"],
|
||||
TPlusSyncBatch.audit_result_json["observed_entities"],
|
||||
).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||
return {number: {"progress": progress, "observed_entities": observed or []}
|
||||
for number, progress, observed in rows}
|
||||
|
||||
|
||||
def current_batch(session: Session):
|
||||
return session.scalar(select(TPlusSyncBatch).order_by(
|
||||
case((TPlusSyncBatch.status == "RUNNING", 0), (TPlusSyncBatch.status == "QUEUED", 1), else_=2),
|
||||
TPlusSyncBatch.id.desc(),
|
||||
).limit(1))
|
||||
|
||||
|
||||
def latest_audit(session: Session):
|
||||
"""最近完成的核验;返回批次供查询层过滤报告。"""
|
||||
return session.scalar(
|
||||
|
||||
@ -37,7 +37,7 @@ def get_role_codes(user: User) -> list[str]:
|
||||
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
|
||||
故此处遍历不会触发 N+1 查询。
|
||||
"""
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None]
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None and link.role.status == 1]
|
||||
|
||||
|
||||
def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
|
||||
@ -58,6 +58,10 @@ class UserBrief(BaseModel):
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
can_write_payroll: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
authorization_revision: int = 0
|
||||
is_payroll_maker: bool = False
|
||||
maker_required_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
|
||||
45
backend/app/schemas/authorization.py
Normal file
45
backend/app/schemas/authorization.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""授权请求版本化,禁止匿名覆盖他人的修改。"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PermissionOverrideIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_code: str = Field(min_length=1, max_length=128)
|
||||
effect: Literal["ALLOW", "DENY"]
|
||||
|
||||
|
||||
class AuthorizationPreviewIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
role_codes: list[str] = Field(default_factory=list, max_length=100)
|
||||
# 保留空字段兼容旧客户端;不再允许个人允许/禁止覆盖角色授权。
|
||||
overrides: list[PermissionOverrideIn] = Field(default_factory=list, max_length=0)
|
||||
|
||||
|
||||
class UserAuthorizationIn(AuthorizationPreviewIn):
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def reason_required(cls, value):
|
||||
if not value.strip():
|
||||
raise ValueError("修改原因不能为空")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class RolePermissionsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_codes: list[str] = Field(max_length=300)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
|
||||
|
||||
class AuthorizationSettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
maker_user_id: int | None = Field(default=None, ge=1)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
@ -107,6 +107,15 @@ class BusinessItemIn(BaseModel):
|
||||
return _check_amount(value, "金额", AMOUNT_MAX)
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _derive_guided_cost(self):
|
||||
from app.services.cost_rules import guided_total
|
||||
|
||||
total = guided_total(self.quantity, self.guided_price, self.cost_amount)
|
||||
self.cost_amount = str(total) if total is not None else None
|
||||
return self
|
||||
|
||||
|
||||
class BusinessItemOut(BaseModel):
|
||||
"""业务产品明细行响应。金额/数量一律 str。"""
|
||||
|
||||
|
||||
82
backend/app/schemas/calc_template.py
Normal file
82
backend/app/schemas/calc_template.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""计提公式模板的请求/响应模型。"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# 模板编码与历史 TPL_ 编码一致:大写字母开头,大写字母/数字/下划线,3–32 位。
|
||||
TEMPLATE_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,31}$")
|
||||
|
||||
|
||||
class TemplateParamOut(BaseModel):
|
||||
"""模板骨架参数角色映射(角色 → 参数编码)。"""
|
||||
|
||||
role: str
|
||||
param_code: str
|
||||
|
||||
|
||||
class CalcTemplateOut(BaseModel):
|
||||
"""模板列表/详情响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
template_code: str
|
||||
template_name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
is_system: int = 0
|
||||
role_count: int = 0
|
||||
|
||||
|
||||
class CalcTemplateOptionOut(BaseModel):
|
||||
"""岗位表单下拉项(仅启用模板,含骨架与参数绑定说明)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
formula_type: str
|
||||
description: str
|
||||
params: list[TemplateParamOut]
|
||||
|
||||
|
||||
class FormulaOptionOut(BaseModel):
|
||||
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
roles: list[str]
|
||||
|
||||
|
||||
class CalcTemplateCreateIn(BaseModel):
|
||||
"""新增模板。编码与骨架创建后不可修改。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_code: str = Field(min_length=3, max_length=32, description="模板编码,创建后不可修改")
|
||||
template_name: str = Field(min_length=1, max_length=64, description="模板名称")
|
||||
formula_type: str = Field(min_length=1, max_length=32, description="公式骨架类型")
|
||||
params: dict[str, str] = Field(default_factory=dict, description="骨架参数角色到参数编码映射")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("template_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not TEMPLATE_CODE_PATTERN.match(value):
|
||||
raise ValueError("模板编码须以大写字母开头,仅可含大写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class CalcTemplateUpdateIn(BaseModel):
|
||||
"""编辑模板。编码与骨架锁定;参数绑定仍按骨架角色校验。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_name: str = Field(min_length=1, max_length=64)
|
||||
params: dict[str, str] = Field(default_factory=dict)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
23
backend/app/schemas/coefficient.py
Normal file
23
backend/app/schemas/coefficient.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""系数表与 Excel 导入共用的边界模型。"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoefficientRowIn(BaseModel):
|
||||
product_category: Literal["municipal_water", "rail_transit"]
|
||||
customer_type: Literal["personal", "enterprise"]
|
||||
stage: Literal["first", "linear", "after", "fixed"]
|
||||
value: str = Field(min_length=1, max_length=64)
|
||||
slope: str | None = Field(default=None, max_length=64)
|
||||
boundary: int | None = Field(default=None, ge=1, le=3660)
|
||||
|
||||
|
||||
class CoefficientChangeIn(BaseModel):
|
||||
effective_date: date
|
||||
base_date: date | None = None
|
||||
remark: str = Field(default="系数表维护", max_length=255)
|
||||
fingerprint: str = Field(min_length=64, max_length=64)
|
||||
rows: list[CoefficientRowIn] = Field(min_length=1, max_length=16)
|
||||
@ -9,8 +9,7 @@
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
同步记录、读取开关和自动采集开关分别返回,不用当前开关覆盖历史采集结果。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@ -80,7 +79,7 @@ class TodoBlock(BaseModel):
|
||||
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
|
||||
unassigned_receipt_total: int = 0
|
||||
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
|
||||
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
|
||||
sync_available: bool = Field(default=False, description="同步记录是否可查询")
|
||||
sync_failure_total: int = 0
|
||||
|
||||
|
||||
@ -154,6 +153,17 @@ class ActivityPageOut(BaseModel):
|
||||
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||
|
||||
|
||||
class SyncBlock(BaseModel):
|
||||
"""源采集历史与当前运行配置,仅公开汇总状态。"""
|
||||
|
||||
available: bool = False
|
||||
enabled: bool = False
|
||||
automation_enabled: bool = False
|
||||
latest_status: str | None = None
|
||||
last_success_at: datetime | None = None
|
||||
historical_failure_total: int = 0
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
@ -161,3 +171,4 @@ class DashboardOut(BaseModel):
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
sync: SyncBlock = Field(default_factory=SyncBlock)
|
||||
|
||||
78
backend/app/schemas/monitor.py
Normal file
78
backend/app/schemas/monitor.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""监控接口只暴露固定白名单,不传输内部异常或连接配置。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
Status = Literal["normal", "warning", "error", "unknown"]
|
||||
Hours = Annotated[
|
||||
Literal[1, 24, 168],
|
||||
BeforeValidator(lambda value: int(value) if value in ("1", "24", "168") else value),
|
||||
]
|
||||
|
||||
|
||||
class MonitorWindow(BaseModel):
|
||||
hours: Hours = 1
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class EventQuery(PageQuery):
|
||||
hours: Hours = 24
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
kind: Literal["HEALTH", "REQUEST_ERROR", "SLOW_REQUEST", "RUNTIME_ERROR"] | None = None
|
||||
request_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
active_only: bool = False
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
instance_id: str
|
||||
kind: str
|
||||
code: str
|
||||
component: str
|
||||
level: str
|
||||
message: str
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
route: str | None = None
|
||||
status_code: int | None = None
|
||||
duration_ms: float | None = None
|
||||
batch_no: str | None = None
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
occurrences: int
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class DependencyOut(BaseModel):
|
||||
name: str
|
||||
status: Status
|
||||
message: str
|
||||
checked_at: datetime | None = None
|
||||
duration_ms: float | None = None
|
||||
|
||||
|
||||
class OverviewOut(BaseModel):
|
||||
status: Status
|
||||
environment: str
|
||||
version: str
|
||||
instance_id: str
|
||||
uptime_seconds: int
|
||||
collected_at: datetime | None
|
||||
last_persisted_at: datetime | None
|
||||
collection_status: Status
|
||||
collection_message: str
|
||||
resources: dict
|
||||
requests: dict
|
||||
dependencies: list[DependencyOut]
|
||||
alerts: list[EventOut]
|
||||
tasks: dict
|
||||
dropped_events: int
|
||||
retention_days: int = 7
|
||||
@ -191,8 +191,6 @@ class ParamPreviewIn(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_dates(self) -> "ParamPreviewIn":
|
||||
if self.receipt_date < self.ship_date:
|
||||
raise ValueError("回款到账日不得早于发货日")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
@ -46,6 +46,9 @@ class PersonnelOut(BaseModel):
|
||||
manager_name: str | None = None
|
||||
phone: str | None = None
|
||||
hire_date: date | None = None
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = 0
|
||||
salary_standard: str | None = None
|
||||
status: int = 1
|
||||
@ -53,6 +56,7 @@ class PersonnelOut(BaseModel):
|
||||
|
||||
|
||||
class PersonnelDetailOut(PersonnelOut):
|
||||
salary_history_json: list[dict] | None = None
|
||||
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
|
||||
|
||||
regions: list[PersonnelRegionOut] = Field(default_factory=list)
|
||||
@ -60,6 +64,7 @@ class PersonnelDetailOut(PersonnelOut):
|
||||
|
||||
|
||||
class PersonnelCreateIn(BaseModel):
|
||||
salary_effective_date: date | None = None
|
||||
"""新增人员。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
@ -69,6 +74,9 @@ class PersonnelCreateIn(BaseModel):
|
||||
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
|
||||
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
|
||||
salary_standard: str | None = Field(
|
||||
default=None, description="月工资标准(元),字符串形式的数字"
|
||||
@ -100,6 +108,13 @@ class PersonnelCreateIn(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_probation_dates(self):
|
||||
if self.probation_start_date and self.regularization_date and self.regularization_date <= self.probation_start_date:
|
||||
raise ValueError("转正生效日必须晚于试用期开始日")
|
||||
return self
|
||||
|
||||
|
||||
class PersonnelUpdateIn(PersonnelCreateIn):
|
||||
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
|
||||
|
||||
@ -108,6 +123,7 @@ class PersonnelStatusIn(BaseModel):
|
||||
"""在职/离职切换。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1在职 0离职")
|
||||
effective_date: date | None = None
|
||||
|
||||
|
||||
class RegionItemIn(BaseModel):
|
||||
|
||||
@ -28,6 +28,8 @@ class ProductOut(BaseModel):
|
||||
remark: str | None = None
|
||||
create_time: datetime
|
||||
update_time: datetime
|
||||
source_trade_json: dict | None = None
|
||||
quantity_price_manual: int = 0
|
||||
|
||||
|
||||
class ProductCreateIn(BaseModel):
|
||||
@ -95,3 +97,15 @@ class ProductQuery(PageQuery):
|
||||
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
|
||||
|
||||
class ProductExtractIn(BaseModel):
|
||||
item_ids: list[int] = Field(min_length=1, max_length=200)
|
||||
fingerprint: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
@field_validator("item_ids")
|
||||
@classmethod
|
||||
def _ids(cls, value):
|
||||
if any(v <= 0 for v in value) or len(value) != len(set(value)):
|
||||
raise ValueError("明细ID必须为不重复的正整数")
|
||||
return value
|
||||
|
||||
@ -284,6 +284,7 @@ class DepartmentSummaryOut(BaseModel):
|
||||
"""部门绩效工资汇总响应(FR-030)。"""
|
||||
|
||||
settlement_month: str
|
||||
salary_rows: list[dict] = Field(default_factory=list, description="已生成工资的基本工资、正常绩效、缓发及年度补发快照")
|
||||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||||
unassigned: list[DepartmentMemberRow] = Field(
|
||||
|
||||
@ -33,6 +33,7 @@ class RoleManageOut(RoleOut):
|
||||
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
|
||||
|
||||
user_count: int = 0
|
||||
permission_count: int = 0
|
||||
|
||||
|
||||
class RoleCreateIn(BaseModel):
|
||||
@ -44,6 +45,8 @@ class RoleCreateIn(BaseModel):
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
permission_codes: list[str] | None = Field(default=None, max_length=300)
|
||||
reason: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
|
||||
@field_validator("role_code")
|
||||
@classmethod
|
||||
|
||||
@ -48,6 +48,10 @@ class SettlementItemOut(BaseModel):
|
||||
net_amount: Decimal | None = None
|
||||
is_probation: int
|
||||
probation_note: str | None = None
|
||||
normal_payable_sum: Decimal = Decimal("0")
|
||||
deferred_sum: Decimal = Decimal("0")
|
||||
probation_payment_sum: Decimal = Decimal("0")
|
||||
snapshot_json: dict | None = None
|
||||
|
||||
|
||||
class SettlementOut(BaseModel):
|
||||
|
||||
@ -60,6 +60,24 @@ class TPlusErrorQuery(PageQuery):
|
||||
entity_type: EntityType | None = None
|
||||
|
||||
|
||||
class TPlusEntityProgress(BaseModel):
|
||||
entity_type: str
|
||||
total_records: int | None = None
|
||||
processed_records: int | None = None
|
||||
status: str = "WAITING"
|
||||
|
||||
|
||||
class TPlusProgressOut(BaseModel):
|
||||
phase: str
|
||||
total_records: int | None = None
|
||||
processed_records: int = 0
|
||||
percentage: float | None = None
|
||||
active_entity: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
elapsed_seconds: int = 0
|
||||
entities: list[TPlusEntityProgress] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TPlusBatchOut(BaseModel):
|
||||
"""批次列表不包含原始数据、源配置或数据库异常。"""
|
||||
|
||||
@ -79,6 +97,7 @@ class TPlusBatchOut(BaseModel):
|
||||
error_summary: str | None = None
|
||||
trigger_type: str = "MANUAL"
|
||||
automatic_attempt: int = 0
|
||||
progress: TPlusProgressOut | None = None
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
@ -105,8 +124,16 @@ class TPlusAutomationAlert(BaseModel):
|
||||
batch_no: str | None = None
|
||||
|
||||
|
||||
class TPlusAutomationUpdate(BaseModel):
|
||||
enabled: bool = Field(strict=True)
|
||||
expected_version: int = Field(ge=0, strict=True)
|
||||
|
||||
|
||||
class TPlusAutomationOverview(BaseModel):
|
||||
enabled: bool = False
|
||||
version: int = 0
|
||||
editable: bool = False
|
||||
configuration_origin: str = "ENVIRONMENT"
|
||||
weekday: int = 0
|
||||
time: str = "02:00"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
@ -125,6 +152,7 @@ class TPlusOverviewOut(BaseModel):
|
||||
configuration_message: str
|
||||
status_counts: dict[str, int]
|
||||
total_staged_records: int = 0
|
||||
current_batch: TPlusBatchOut | None = None
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
|
||||
@ -35,7 +35,7 @@ from app.core.constants import (
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, Business, User
|
||||
from app.models.approval import (
|
||||
@ -53,7 +53,6 @@ from app.models.approval import (
|
||||
APPROVAL_STATUS_REJECTED,
|
||||
APPROVAL_STATUS_WITHDRAWN,
|
||||
)
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.approval import (
|
||||
ApprovalActionIn,
|
||||
ApprovalOrderDetailOut,
|
||||
@ -261,7 +260,6 @@ def act(
|
||||
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
||||
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
||||
"""
|
||||
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
|
||||
|
||||
@ -281,15 +279,29 @@ def act(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=True)
|
||||
# 与工资生成保持相同人员锁顺序,审核驳回释放年度余额不得绕过结清锁。
|
||||
hint = session.get(ApprovalOrder, order_id)
|
||||
if hint is not None and hint.order_type in (2, 3):
|
||||
from app.models import AdjustmentOrder, Personnel, SettlementItem
|
||||
|
||||
person_ids = []
|
||||
if hint.order_type == 2:
|
||||
person_ids = list(session.scalars(select(SettlementItem.personnel_id).where(SettlementItem.settlement_id == hint.ref_id)))
|
||||
else:
|
||||
target_adjustment = session.get(AdjustmentOrder, hint.ref_id)
|
||||
if target_adjustment:
|
||||
person_ids = [target_adjustment.personnel_id]
|
||||
list(session.scalars(select(Personnel).where(Personnel.id.in_(person_ids)).order_by(Personnel.id).with_for_update()))
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=True, populate_existing=True)
|
||||
if order is None:
|
||||
raise NotFoundError("审批单不存在")
|
||||
if order.status not in _PENDING_STATUSES:
|
||||
raise BadRequestError("审批单已结束,不可重复操作")
|
||||
|
||||
required_role = _NODE_ROLE_BY_STATUS[order.status]
|
||||
if required_role not in codes:
|
||||
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, operator, "approvals:" + required_role)
|
||||
|
||||
node_now = APPROVAL_NODES[order.status]
|
||||
rejected = payload.action == APPROVAL_ACTION_REJECT
|
||||
@ -528,6 +540,9 @@ def settlement_content(session: Session, settlement_id: int) -> dict | None:
|
||||
"basic_amount": _amount_text(item.basic_amount),
|
||||
"salary_mode": item.salary_mode,
|
||||
"net_amount": _amount_text(item.net_amount),
|
||||
"normal_payable_sum": str(item.normal_payable_sum),
|
||||
"deferred_sum": str(item.deferred_sum),
|
||||
"probation_payment_sum": str(item.probation_payment_sum),
|
||||
"is_probation": item.is_probation,
|
||||
}
|
||||
for item in items
|
||||
|
||||
@ -17,7 +17,6 @@ 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__)
|
||||
|
||||
@ -141,12 +140,14 @@ def login(
|
||||
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
|
||||
|
||||
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
|
||||
from app.services.authorization_service import session_info
|
||||
|
||||
authorization = session_info(session, user)
|
||||
return LoginOut(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes,
|
||||
can_write_payroll=can_write_payroll(user),
|
||||
id=user.id, username=user.username, real_name=user.real_name, **authorization,
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
171
backend/app/services/authorization_management.py
Normal file
171
backend/app/services/authorization_management.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""管理员维护授权;审计、版本检查和权限写入在同一业务事务中。"""
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
|
||||
from app.models import Permission, Role, RolePermission, User, UserRole
|
||||
from app.repositories import user_repository
|
||||
from app.services import audit_service
|
||||
from app.services import authorization_service as auth
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
|
||||
def require_admin(session, actor):
|
||||
if actor is None or actor.status != 1 or "admin" not in auth.role_codes(session, actor.id, for_update=True):
|
||||
raise ForbiddenError("仅系统管理员可以配置权限")
|
||||
|
||||
|
||||
def lock_policy(session, actor, expected=None):
|
||||
policy = auth.setting(session, lock=True)
|
||||
require_admin(session, actor)
|
||||
if expected is not None and policy.revision != expected:
|
||||
raise ConflictError()
|
||||
return policy
|
||||
|
||||
|
||||
def bump(policy, actor):
|
||||
policy.revision += 1
|
||||
policy.updated_by = actor.id
|
||||
|
||||
|
||||
def _user(session, user_id):
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def _roles(session, codes, *, existing=()):
|
||||
if len(set(codes)) != len(codes):
|
||||
raise BadRequestError("角色不能重复")
|
||||
rows = list(session.scalars(select(Role).where(Role.role_code.in_(codes))))
|
||||
if len(rows) != len(codes) or any(r.status != 1 and r.role_code not in existing for r in rows):
|
||||
raise BadRequestError("角色不存在或已停用")
|
||||
return rows
|
||||
|
||||
|
||||
def _overrides(payload):
|
||||
if payload.overrides:
|
||||
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
|
||||
return {}
|
||||
|
||||
|
||||
def user_authorization(session, user_id):
|
||||
user = _user(session, user_id)
|
||||
result = auth.calculate(session, user)
|
||||
return {**result, "id": user.id, "username": user.username,
|
||||
"role_codes": [link.role.role_code for link in user.user_roles],
|
||||
"overrides": [], "revision": auth.setting(session).revision,
|
||||
"is_payroll_maker": auth.is_maker(session, user)}
|
||||
|
||||
|
||||
def preview(session, user_id, payload):
|
||||
user = _user(session, user_id)
|
||||
_roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
|
||||
overrides = _overrides(payload)
|
||||
if "admin" in payload.role_codes and overrides:
|
||||
raise BadRequestError("管理员权限为固定基线,不能设置个人覆盖")
|
||||
proposed = auth.calculate(session, user, roles=payload.role_codes, overrides=overrides)
|
||||
before = set(auth.calculate(session, user)["permissions"])
|
||||
after = set(proposed["permissions"])
|
||||
return {**proposed, "added": sorted(after - before), "removed": sorted(before - after)}
|
||||
|
||||
|
||||
def guard_user_change(session, user, actor, *, roles=None, status=None, deleting=False):
|
||||
"""调用方已持全局授权锁;防止两个管理员并发把系统管理资格撤空。"""
|
||||
session.refresh(user, with_for_update=True)
|
||||
current = auth.role_codes(session, user.id, for_update=True)
|
||||
removes_admin = "admin" in current and (
|
||||
deleting or status == 0 or (roles is not None and "admin" not in roles)
|
||||
)
|
||||
if removes_admin:
|
||||
if actor.id == user.id:
|
||||
raise ForbiddenError("不能撤销自己的管理员资格或停用自己")
|
||||
active = list(session.scalars(select(User.id).join(UserRole).join(Role).where(
|
||||
User.status == 1, Role.role_code == "admin", Role.status == 1
|
||||
).with_for_update()))
|
||||
if len(active) <= 1:
|
||||
raise ForbiddenError("不能移除最后一个启用的管理员")
|
||||
|
||||
|
||||
def save_user(session, user_id, payload, actor, meta=None):
|
||||
audit_data = payload.model_dump(mode="json")
|
||||
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
"权限管理", "用户授权", f"修改用户 #{user_id} 的权限", audit_data
|
||||
)):
|
||||
policy = lock_policy(session, actor, payload.expected_revision)
|
||||
user = _user(session, user_id)
|
||||
before = user_authorization(session, user_id)
|
||||
changes = preview(session, user_id, payload)
|
||||
guard_user_change(session, user, actor, roles=payload.role_codes)
|
||||
roles = _roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
|
||||
user_repository.replace_roles(session, user, roles)
|
||||
bump(policy, actor)
|
||||
session.flush()
|
||||
result = user_authorization(session, user_id)
|
||||
result["changes"] = {"added": changes["added"], "removed": changes["removed"],
|
||||
"before_roles": before["role_codes"], "after_roles": result["role_codes"]}
|
||||
audit_data["changes"] = result["changes"]
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def role_permissions(session, role_id):
|
||||
role = session.get(Role, role_id)
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
codes = sorted(CATALOG) if role.role_code == "admin" else list(session.scalars(
|
||||
select(Permission.perm_code).join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.where(RolePermission.role_id == role.id, Permission.perm_code.in_(CATALOG))
|
||||
))
|
||||
return {"role_id": role.id, "role_code": role.role_code, "role_name": role.role_name,
|
||||
"permission_codes": codes, "revision": auth.setting(session).revision,
|
||||
"fixed": role.role_code == "admin"}
|
||||
|
||||
|
||||
def validate_role_permissions(permission_codes):
|
||||
"""新建与修改角色共用同一权限目录、管理员边界及查看依赖校验。"""
|
||||
codes = set(permission_codes)
|
||||
if codes - CATALOG.keys() or len(codes) != len(permission_codes):
|
||||
raise BadRequestError("权限未知或重复,请刷新目录")
|
||||
if any(CATALOG[c]["admin_only"] for c in codes):
|
||||
raise BadRequestError("管理员专用权限不能分配给普通角色")
|
||||
if any(CATALOG[c]["view_code"] not in codes for c in codes):
|
||||
raise BadRequestError("操作权限必须同时授予相应查看权限")
|
||||
return codes
|
||||
|
||||
|
||||
def save_role(session, role_id, payload, actor, meta=None):
|
||||
audit_data = payload.model_dump(mode="json")
|
||||
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
"权限管理", "角色授权", f"修改角色 #{role_id} 的权限", audit_data
|
||||
)):
|
||||
policy = lock_policy(session, actor, payload.expected_revision)
|
||||
role = session.get(Role, role_id)
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
if role.role_code == "admin":
|
||||
raise ForbiddenError("管理员角色权限固定,不能修改")
|
||||
codes = validate_role_permissions(payload.permission_codes)
|
||||
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
|
||||
audit_data["after"] = sorted(codes)
|
||||
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
|
||||
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
|
||||
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
|
||||
bump(policy, actor)
|
||||
session.flush()
|
||||
result = role_permissions(session, role.id)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def settings_out(session):
|
||||
policy = auth.setting(session)
|
||||
return {"mode": "roles", "revision": policy.revision, "maker_user_id": None,
|
||||
"maker_username": None,
|
||||
"maker_status": None, "updated_by": policy.updated_by,
|
||||
"updated_at": policy.update_time}
|
||||
|
||||
|
||||
def save_settings(session, payload, actor, meta=None):
|
||||
raise BadRequestError("已取消单独的制作人绑定,请在角色管理配置权限,再在用户管理分配角色")
|
||||
101
backend/app/services/authorization_metadata.py
Normal file
101
backend/app/services/authorization_metadata.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessRole,
|
||||
CalcTemplateDefinition,
|
||||
Customer,
|
||||
CustomerRatingGrade,
|
||||
Personnel,
|
||||
Product,
|
||||
Shipment,
|
||||
)
|
||||
from app.services.authorization_service import calculate
|
||||
|
||||
OPTIONS = {
|
||||
"managers": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
|
||||
"base:personnel:view", "base:personnel:create", "base:personnel:update",
|
||||
)),
|
||||
"rating_grades": (CustomerRatingGrade, ("id", "grade_name", "min_score", "max_score", "sort_order", "remark"), (
|
||||
"base:customers:view", "base:customers:rating", "business:ratings:view",
|
||||
)),
|
||||
"personnel": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
|
||||
"base:personnel:view", "base:customers:view", "base:customers:create", "base:customers:update",
|
||||
"base:customers:ownership", "business:businesses:view", "business:businesses:create",
|
||||
"business:businesses:update", "business:businesses:ownership", "payroll:prepare", "adjustments:create",
|
||||
"payroll:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
|
||||
)),
|
||||
"customers": (Customer, ("id", "company_name", "customer_type"), (
|
||||
"base:customers:view", "business:businesses:view", "business:businesses:create", "business:businesses:update",
|
||||
"reports:business:view",
|
||||
)),
|
||||
"products": (Product, ("id", "product_name", "spec_model", "unit"), (
|
||||
"base:products:view", "business:businesses:create", "business:businesses:update",
|
||||
"business:shipments:create", "business:shipments:update",
|
||||
)),
|
||||
"businesses": (Business, ("id", "business_no", "status"), (
|
||||
"business:businesses:view", "business:shipments:view", "business:shipments:create",
|
||||
"business:shipments:update", "business:receipts:view", "business:receipts:create", "business:receipts:update",
|
||||
)),
|
||||
"shipments": (Shipment, ("id", "shipment_no", "business_id", "ship_date"), (
|
||||
"business:shipments:view", "business:receipts:view", "business:receipts:create", "business:receipts:update",
|
||||
)),
|
||||
"business_roles": (BusinessRole, ("id", "role_code", "role_name", "status"), (
|
||||
"base:personnel:view", "base:personnel:create", "base:personnel:update", "system:business_roles:view",
|
||||
"system:params:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
|
||||
)),
|
||||
}
|
||||
|
||||
|
||||
def payroll_options(session, actor, business_id):
|
||||
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models import ShipmentItem
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, actor, "payroll:view")
|
||||
if session.get(Business, business_id) is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
|
||||
if len(heads) > 200:
|
||||
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
|
||||
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
|
||||
if len(people) > 200:
|
||||
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
|
||||
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
|
||||
by_head = {head.id: head for head in heads}
|
||||
return {"personnel": people, "shipments": [
|
||||
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
|
||||
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
|
||||
for row in lines
|
||||
]}
|
||||
|
||||
|
||||
def options(session, actor, resource, *, business_id=None, search=None):
|
||||
definition = OPTIONS.get(resource)
|
||||
if definition is None:
|
||||
raise BadRequestError("不支持的参照类型")
|
||||
model, fields, callers = definition
|
||||
if not set(calculate(session, actor)["permissions"]) & set(callers):
|
||||
raise ForbiddenError("没有使用此参照数据的业务权限")
|
||||
statement = select(*(getattr(model, f) for f in fields))
|
||||
if resource == "managers":
|
||||
statement = (
|
||||
statement.join(BusinessRole, BusinessRole.id == Personnel.business_role_id)
|
||||
.join(CalcTemplateDefinition, CalcTemplateDefinition.template_code == BusinessRole.calc_template)
|
||||
.where(
|
||||
Personnel.status == 1, BusinessRole.status == 1,
|
||||
CalcTemplateDefinition.formula_type == "manager_share",
|
||||
)
|
||||
)
|
||||
if business_id is not None and model == Shipment:
|
||||
statement = statement.where(Shipment.business_id == business_id)
|
||||
if search:
|
||||
name = getattr(model, fields[1])
|
||||
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
statement = statement.where(name.like(f"%{escaped}%", escape="\\"))
|
||||
order = (model.sort_order, model.id) if model == CustomerRatingGrade else (model.id,)
|
||||
rows = session.execute(statement.order_by(*order).limit(200)).mappings()
|
||||
return [dict(row) for row in rows]
|
||||
91
backend/app/services/authorization_policy.py
Normal file
91
backend/app/services/authorization_policy.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""每个受保护接口必须映射权限;未登记接口默认拒绝。"""
|
||||
|
||||
RESOURCE_MODULES = {
|
||||
"base.personnel": "base:personnel", "base.products": "base:products",
|
||||
"base.customers": "base:customers", "business.businesses": "business:businesses",
|
||||
"business.shipments": "business:shipments", "business.receipts": "business:receipts",
|
||||
"business.ratings": "business:ratings", "system.users": "system:users",
|
||||
"system.roles": "system:roles", "system.business_roles": "system:business_roles",
|
||||
"system.logs": "system:logs", "system.settings": "system:settings",
|
||||
"system.monitor": "system:monitor",
|
||||
}
|
||||
REPORT_MODULES = {
|
||||
"personal-detail": "reports:personal", "department-summary": "reports:department",
|
||||
"business-stats": "reports:business", "payment-stats": "reports:payments",
|
||||
}
|
||||
PUBLIC_ENDPOINTS = {("auth", "get_captcha"), ("auth", "login"),
|
||||
("system.settings", "get_settings_public"), ("system.settings", "get_logo_public")}
|
||||
LOGIN_ENDPOINTS = {("auth", "me"), ("auth", "logout")}
|
||||
|
||||
|
||||
def permission_for(module, name, method, *, params=None, body=None):
|
||||
"""返回目录操作码;None 表示没有登记,空串仅用于显式登录态接口。"""
|
||||
params, body = params or {}, body or {}
|
||||
if (module, name) in PUBLIC_ENDPOINTS | LOGIN_ENDPOINTS:
|
||||
return ""
|
||||
if module == "dashboard":
|
||||
return "dashboard:view"
|
||||
if module == "system.authorization":
|
||||
resource = "system:users" if name in ("user_authorization", "preview", "save_user") else "system:roles"
|
||||
return resource + (":view" if method == "GET" or name == "preview" else ":update")
|
||||
if module == "metadata":
|
||||
return "" # 此端点按请求的参照资源执行独立的关联功能校验。
|
||||
if module == "base.business_roles":
|
||||
return "" # 下拉只返回选项;由端点校验人员查看/维护能力。
|
||||
if module in RESOURCE_MODULES:
|
||||
resource = RESOURCE_MODULES[module]
|
||||
if module == "base.products" and name in ("extraction_preview", "extraction_apply", "restore_source"):
|
||||
return resource + (":create" if name == "extraction_apply" else ":update" if name == "restore_source" else ":view")
|
||||
if method == "GET":
|
||||
return resource + ":view"
|
||||
special = {"set_status": "status", "change_status": "status", "reset_password": "password",
|
||||
"replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating",
|
||||
"change_ownership": "ownership", "upload_logo": "update"}
|
||||
action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method)
|
||||
return resource + ":" + action if action else None
|
||||
if module == "system.params":
|
||||
action = "view" if method == "GET" else {"submit_change": "update", "withdraw_batch": "withdraw", "preview": "preview"}.get(name)
|
||||
if name == "export_changes":
|
||||
action = "export"
|
||||
return "system:params:" + action if action else None
|
||||
if module == "system.coefficients":
|
||||
return "system:params:" + ({"save": "update", "export": "export", "import_preview": "preview"}.get(name, "view"))
|
||||
if module == "system.calc_templates":
|
||||
return "system:params:" + ("view" if method == "GET" else "update")
|
||||
if module == "system.monitor":
|
||||
return "system:monitor:" + ("export" if name == "diagnostic" else "view")
|
||||
if module == "system.tplus":
|
||||
return "tplus:" + ("view" if method == "GET" else "retry" if name == "retry" else "collect")
|
||||
if module == "system.tplus_workbench":
|
||||
if name == "work_report":
|
||||
return "tplus:export"
|
||||
if method == "GET":
|
||||
return "tplus:view"
|
||||
if name == "save_fact":
|
||||
return "tplus:facts"
|
||||
if name == "create_work":
|
||||
return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview"
|
||||
if module in ("payroll", "calc"):
|
||||
if name == "probation_adjustment":
|
||||
return "adjustments:create"
|
||||
if name in ("probation_export", "salary_export"):
|
||||
return "reports:personal:export"
|
||||
if method == "GET":
|
||||
return "payroll:view"
|
||||
return "adjustments:create" if name == "adjustment" else "payroll:prepare" if name == "save_input" else "payroll:calculate"
|
||||
if module == "settlements":
|
||||
if name in ("create_adjustment", "list_adjustments"):
|
||||
return "adjustments:" + ("view" if method == "GET" else "create")
|
||||
return "settlements:" + ("view" if method == "GET" else "generate" if name == "generate" else "submit")
|
||||
if module == "system.approvals":
|
||||
return "approvals:view" # 节点动作由服务层根据当前节点检查 finance/leader。
|
||||
if module == "reports":
|
||||
code = params.get("code") if name == "export_report" else {
|
||||
"personal_detail": "personal-detail", "department_summary": "department-summary",
|
||||
"business_stats": "business-stats", "payment_stats": "payment-stats",
|
||||
"payment_detail": "payment-stats", "payment_day_buckets": "payment-stats",
|
||||
}.get(name)
|
||||
resource = REPORT_MODULES.get(code)
|
||||
return resource + (":export" if name == "export_report" else ":view") if resource else None
|
||||
return None
|
||||
|
||||
92
backend/app/services/authorization_service.py
Normal file
92
backend/app/services/authorization_service.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""授权查询与计算,权限每次从数据库解析,不采用 JWT 中的角色作为依据。"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.models import (
|
||||
AuthorizationSetting,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.services.permission_catalog import CATALOG, PAYROLL_WRITE_PERMISSIONS
|
||||
|
||||
|
||||
def setting(session, *, lock=False):
|
||||
statement = select(AuthorizationSetting).where(AuthorizationSetting.id == 1)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
row = session.scalar(statement)
|
||||
if row is None:
|
||||
raise ForbiddenError("授权配置尚未初始化,请先完成数据库迁移")
|
||||
return row
|
||||
|
||||
|
||||
def role_codes(session, user_id, *, for_update=False):
|
||||
statement = select(Role.role_code).join(UserRole).where(
|
||||
UserRole.user_id == user_id, Role.status == 1
|
||||
)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
return list(session.scalars(statement))
|
||||
|
||||
|
||||
def calculate(session, user, *, roles=None, overrides=None):
|
||||
"""用户继承启用角色权限的并集;管理员固定全权限,操作依赖模块查看。"""
|
||||
if overrides:
|
||||
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
|
||||
if user is None or user.status != 1:
|
||||
return {"permissions": [], "sources": {}, "roles": []}
|
||||
roles = roles if roles is not None else role_codes(session, user.id)
|
||||
if "admin" in roles:
|
||||
return {"permissions": sorted(CATALOG), "sources": {
|
||||
code: {"roles": ["admin"], "override": None, "effective": True}
|
||||
for code in CATALOG
|
||||
}, "roles": roles}
|
||||
inherited = {}
|
||||
for code, role in session.execute(
|
||||
select(Permission.perm_code, Role.role_code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(Role, Role.id == RolePermission.role_id)
|
||||
.where(Role.role_code.in_(roles), Role.status == 1, Permission.status == 1)
|
||||
):
|
||||
inherited.setdefault(code, []).append(role)
|
||||
allowed = set(inherited)
|
||||
allowed &= set(CATALOG)
|
||||
allowed = {c for c in allowed if not CATALOG[c]["admin_only"]}
|
||||
allowed = {c for c in allowed if CATALOG[c]["view_code"] in allowed}
|
||||
sources = {c: {"roles": inherited.get(c, []), "override": None,
|
||||
"effective": c in allowed} for c in CATALOG}
|
||||
return {"permissions": sorted(allowed), "sources": sources, "roles": roles}
|
||||
|
||||
|
||||
def is_maker(session, user):
|
||||
"""兼容旧会话字段:现表示账号具备工资写权限,不再查询全局绑定。"""
|
||||
return bool(set(calculate(session, user)["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
|
||||
|
||||
|
||||
def require_permission(session, user, code, *, check_maker=True):
|
||||
item = CATALOG.get(code)
|
||||
if item is None or code not in calculate(session, user)["permissions"]:
|
||||
label = f"{item['module_name']}:{item['name']}" if item else "此操作"
|
||||
raise ForbiddenError(f"当前账号没有「{label}」权限,请联系管理员授权")
|
||||
return user
|
||||
|
||||
|
||||
def session_info(session, user):
|
||||
result = calculate(session, user)
|
||||
maker = bool(set(result["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
|
||||
return {"permissions": result["permissions"], "authorization_revision": setting(session).revision,
|
||||
"is_payroll_maker": maker, "can_write_payroll": maker,
|
||||
"maker_required_permissions": [],
|
||||
"roles": result["roles"]}
|
||||
|
||||
|
||||
def user_session(user):
|
||||
session = object_session(user) if isinstance(user, User) else None
|
||||
if session is None:
|
||||
raise ForbiddenError("账号状态不可用,请重新登录")
|
||||
return session
|
||||
@ -28,7 +28,7 @@ from app.core.constants import (
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import BusinessRole, User
|
||||
from app.repositories import business_role_repository
|
||||
from app.repositories import business_role_repository, calc_template_repository
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOptionOut,
|
||||
@ -60,10 +60,13 @@ def _to_out(role: BusinessRole) -> BusinessRoleOut:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_template(calc_template: str) -> None:
|
||||
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
|
||||
if calc_template not in TEMPLATES:
|
||||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||||
def _ensure_template(session: Session, calc_template: str) -> None:
|
||||
"""模板必须存在于模板定义;迁移前旧库回退内置注册表兜底。"""
|
||||
if calc_template_repository.get_by_code(session, calc_template) is not None:
|
||||
return
|
||||
if calc_template in TEMPLATES:
|
||||
return
|
||||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||||
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
@ -106,18 +109,40 @@ def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||
return _to_out(role)
|
||||
|
||||
|
||||
def list_template_options() -> list[TemplateOptionOut]:
|
||||
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
|
||||
def list_template_options(session: Session) -> list[TemplateOptionOut]:
|
||||
"""公式模板下拉:数据库定义优先;迁移前旧库回退内置注册表。
|
||||
|
||||
含停用模板(历史岗位仍绑定);新增岗位表单按 status 过滤启用项。
|
||||
"""
|
||||
options: list[TemplateOptionOut] = []
|
||||
for tpl in TEMPLATES.values():
|
||||
rows = calc_template_repository.list_all(session)
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
seen.add(row.template_code)
|
||||
options.append(
|
||||
TemplateOptionOut(
|
||||
code=tpl.code,
|
||||
name=tpl.name,
|
||||
description=tpl.description,
|
||||
code=row.template_code,
|
||||
name=row.template_name,
|
||||
description=row.description or "",
|
||||
params=[
|
||||
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
|
||||
for code in tpl.params
|
||||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||||
for param_code in (row.params_json or {}).values()
|
||||
if param_code in PARAM_REGISTRY
|
||||
],
|
||||
)
|
||||
)
|
||||
for spec in TEMPLATES.values():
|
||||
if spec.code in seen:
|
||||
continue
|
||||
options.append(
|
||||
TemplateOptionOut(
|
||||
code=spec.code,
|
||||
name=spec.name,
|
||||
description=spec.description,
|
||||
params=[
|
||||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||||
for param_code in spec.params.values()
|
||||
if param_code in PARAM_REGISTRY
|
||||
],
|
||||
)
|
||||
)
|
||||
@ -135,7 +160,7 @@ def create_role(
|
||||
|
||||
:raises BadRequestError: 编码已存在 / 模板不存在
|
||||
"""
|
||||
_ensure_template(payload.calc_template)
|
||||
_ensure_template(session, payload.calc_template)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@ -188,7 +213,7 @@ def update_role(
|
||||
:raises BadRequestError: 模板不存在
|
||||
"""
|
||||
role = business_role_repository.get_by_id(session, role_id)
|
||||
_ensure_template(payload.calc_template)
|
||||
_ensure_template(session, payload.calc_template)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
|
||||
@ -483,7 +483,6 @@ def update_business(
|
||||
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(
|
||||
@ -523,9 +522,19 @@ def update_business(
|
||||
business.remark = payload.remark
|
||||
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
|
||||
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
new_items = _build_item_rows(business_id, payload.items)
|
||||
from app.services.payroll_calculation import latest_input
|
||||
|
||||
formal = latest_input(session, business_id)
|
||||
if formal and formal.data_json.get("guidance_finalized"):
|
||||
from app.models import BusinessItem
|
||||
|
||||
old_items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)))
|
||||
def costs(items):
|
||||
return sorted((i.product_name, i.spec_model, str(i.quantity.normalize()), str(i.cost_amount.normalize()) if i.cost_amount is not None else "", str(i.guided_price.normalize()) if i.guided_price is not None else "") for i in items)
|
||||
if costs(old_items) != costs(new_items):
|
||||
raise BadRequestError("指导成本已封存,不能通过业务编辑改变数量或指导成本;差额请走更正或当月调整")
|
||||
business_repository.replace_items(session, business_id, new_items)
|
||||
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||
old_allocations = business_repository.list_allocations(session, business_id)
|
||||
@ -957,7 +966,10 @@ def _gross_profit_row(
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
from app.services.cost_rules import guided_total
|
||||
|
||||
cost = item.cost_amount if item.cost_amount is not None else guided_total(item.quantity, item.guided_price)
|
||||
if cost is None:
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
@ -967,17 +979,17 @@ def _gross_profit_row(
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), None
|
||||
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
profit = item.sale_amount - cost
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, cost)
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
|
||||
@ -559,7 +559,11 @@ def _personal_rate(
|
||||
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
|
||||
|
||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
from app.services.calc_templates import resolve_definition
|
||||
|
||||
definition = resolve_definition(session, template)
|
||||
params = definition.params
|
||||
if definition.formula_type == "manager_share":
|
||||
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||
@ -570,35 +574,33 @@ def _personal_rate(
|
||||
None,
|
||||
True,
|
||||
)
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
decay = resolve_definition(session, "TPL_SALESPERSON_DECAY").params
|
||||
start = parse_fraction(snapshot.get(decay.get("start", "salesperson.rate.start")))
|
||||
slope = parse_fraction(snapshot.get(decay.get("slope", "salesperson.rate.slope")))
|
||||
floor = parse_fraction(snapshot.get(decay.get("floor", "salesperson.rate.floor")))
|
||||
boundary = int(parse_fraction(snapshot.get(decay.get("boundary", "salesperson.days.boundary"))))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
if definition.formula_type == "fixed_sales":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
rate = parse_fraction(snapshot.get(params.get("rate", "info.rate"))) / 1000
|
||||
return _RateDecision(rate, "按岗位固定比例计提,不随回款天数递减", None, False)
|
||||
if definition.formula_type == "linear_decay":
|
||||
start = parse_fraction(snapshot.get(params.get("start", "salesperson.rate.start")))
|
||||
slope = parse_fraction(snapshot.get(params.get("slope", "salesperson.rate.slope")))
|
||||
floor = parse_fraction(snapshot.get(params.get("floor", "salesperson.rate.floor")))
|
||||
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
boundary = int(parse_fraction(snapshot.get(params.get("boundary", "salesperson.days.boundary"))))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = (
|
||||
f"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||
)
|
||||
return _RateDecision(rate, note, None, False)
|
||||
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
|
||||
from app.services.calc_templates import TEMPLATES
|
||||
|
||||
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
|
||||
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是配置名称
|
||||
raise BadRequestError(f"「{definition.template_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||
|
||||
|
||||
def _role_of(session: Session, person):
|
||||
@ -697,6 +699,7 @@ def batch_calc(
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("批量核算单笔失败 | receipt_id=%s", rid)
|
||||
results.append(
|
||||
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
)
|
||||
|
||||
195
backend/app/services/calc_template_service.py
Normal file
195
backend/app/services/calc_template_service.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""计提公式模板管理服务。
|
||||
|
||||
页面可维护:名称、说明、参数绑定(受骨架角色约束);公式骨架是受控枚举,
|
||||
创建后锁定,避免自由表达式注入与执行错位。系统模板随迁移种子初始化,
|
||||
禁删且骨架/编码/参数锁定;自定义模板无岗位引用才可删除。
|
||||
"""
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.constants import ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import CalcTemplateDefinition, User
|
||||
from app.repositories import calc_template_repository
|
||||
from app.schemas.calc_template import (
|
||||
CalcTemplateCreateIn,
|
||||
CalcTemplateOut,
|
||||
CalcTemplateUpdateIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.calc_templates import FORMULAS, get_formula
|
||||
from app.services.param_registry import PARAM_REGISTRY
|
||||
|
||||
MODULE_CALC_TEMPLATE = "公式模板"
|
||||
|
||||
|
||||
def _to_out(row: CalcTemplateDefinition, role_count: int = 0) -> CalcTemplateOut:
|
||||
return CalcTemplateOut(
|
||||
id=row.id,
|
||||
template_code=row.template_code,
|
||||
template_name=row.template_name,
|
||||
formula_type=row.formula_type,
|
||||
params=dict(row.params_json or {}),
|
||||
description=row.description,
|
||||
sort_order=row.sort_order,
|
||||
status=row.status,
|
||||
is_system=row.is_system,
|
||||
role_count=role_count,
|
||||
)
|
||||
|
||||
|
||||
def _validate_params(formula_type: str, params: dict[str, str]) -> dict[str, str]:
|
||||
"""骨架角色必须全覆盖且不引入多余角色;目标参数必须已注册。"""
|
||||
spec = get_formula(formula_type)
|
||||
expected = set(spec.roles)
|
||||
provided = set(params)
|
||||
if provided != expected:
|
||||
missing = sorted(expected - provided)
|
||||
extra = sorted(provided - expected)
|
||||
details = []
|
||||
if missing:
|
||||
details.append("缺少参数角色:" + "、".join(missing))
|
||||
if extra:
|
||||
details.append("多余参数角色:" + "、".join(extra))
|
||||
raise BadRequestError(";".join(details))
|
||||
unknown = sorted(code for code in params.values() if code not in PARAM_REGISTRY)
|
||||
if unknown:
|
||||
raise BadRequestError("参数编码未注册:" + "、".join(unknown))
|
||||
return {role: params[role] for role in spec.roles}
|
||||
|
||||
|
||||
def list_templates(session) -> PageResult[CalcTemplateOut]:
|
||||
"""模板列表(含停用与引用计数,供管理页)。"""
|
||||
rows = calc_template_repository.list_all(session)
|
||||
counts = calc_template_repository.count_roles_by_codes(session, [r.template_code for r in rows])
|
||||
return PageResult[CalcTemplateOut](
|
||||
records=[_to_out(row, counts.get(row.template_code, 0)) for row in rows],
|
||||
total=len(rows),
|
||||
page_num=1,
|
||||
page_size=max(len(rows), 1),
|
||||
)
|
||||
|
||||
|
||||
def list_formula_options() -> list[dict]:
|
||||
"""骨架下拉(受控集合)。"""
|
||||
return [
|
||||
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
|
||||
for spec in FORMULAS.values()
|
||||
]
|
||||
|
||||
|
||||
def create_template(
|
||||
session,
|
||||
payload: CalcTemplateCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> CalcTemplateOut:
|
||||
"""新增模板。编码唯一;骨架受控;参数绑定按骨架角色校验。"""
|
||||
if calc_template_repository.get_by_code(session, payload.template_code) is not None:
|
||||
raise BadRequestError(f"模板编码 {payload.template_code} 已存在")
|
||||
try:
|
||||
get_formula(payload.formula_type)
|
||||
except ValueError as exc:
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
params = _validate_params(payload.formula_type, payload.params)
|
||||
row = CalcTemplateDefinition(
|
||||
template_code=payload.template_code,
|
||||
template_name=payload.template_name,
|
||||
formula_type=payload.formula_type,
|
||||
params_json=params,
|
||||
description=payload.description,
|
||||
sort_order=payload.sort_order,
|
||||
status=1,
|
||||
is_system=0,
|
||||
)
|
||||
try:
|
||||
calc_template_repository.create(session, row)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise BadRequestError(f"模板编码 {payload.template_code} 已存在") from exc
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_CREATE,
|
||||
target=payload.template_code,
|
||||
summary=f"新增计提公式模板 {payload.template_name}",
|
||||
detail={"formula_type": payload.formula_type, "params": params},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def update_template(
|
||||
session,
|
||||
template_id: int,
|
||||
payload: CalcTemplateUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> CalcTemplateOut:
|
||||
"""编辑模板。系统模板参数锁定;自定义模板参数仍按骨架角色校验。"""
|
||||
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
|
||||
if row is None:
|
||||
raise NotFoundError("模板不存在")
|
||||
params = dict(row.params_json or {})
|
||||
if row.is_system != 1:
|
||||
params = _validate_params(row.formula_type, payload.params)
|
||||
row.template_name = payload.template_name
|
||||
row.description = payload.description
|
||||
row.sort_order = payload.sort_order
|
||||
row.params_json = params
|
||||
session.commit()
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_UPDATE,
|
||||
target=row.template_code,
|
||||
summary=f"修改计提公式模板 {row.template_name}",
|
||||
detail={"params": params},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def delete_template(
|
||||
session,
|
||||
template_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> None:
|
||||
"""删除模板。系统模板禁删;被岗位引用禁删。"""
|
||||
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
|
||||
if row is None:
|
||||
raise NotFoundError("模板不存在")
|
||||
if row.is_system == 1:
|
||||
raise ForbiddenError("系统内置模板不可删除")
|
||||
counts = calc_template_repository.count_roles_by_codes(session, [row.template_code])
|
||||
if counts.get(row.template_code):
|
||||
raise BadRequestError("模板已被岗位引用,不能删除;可先调整岗位模板后再删除")
|
||||
calc_template_repository.delete(session, row)
|
||||
session.commit()
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_DELETE,
|
||||
target=row.template_code,
|
||||
summary=f"删除计提公式模板 {row.template_name}",
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
@ -1,22 +1,140 @@
|
||||
"""计提公式模板注册表(代码内有限集合)。
|
||||
"""计提公式模板骨架注册与解析。
|
||||
|
||||
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
|
||||
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
|
||||
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
|
||||
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
|
||||
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
|
||||
一期骨架仍是代码内受控集合:页面可维护模板名称、说明、参数绑定(JSON),
|
||||
但不允许自由表达式,避免注入与算错。数据库定义优先;迁移前的旧库用
|
||||
`SYSTEM_TEMPLATES` 兜底解析,保证读写与历史行为一致。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateSpec:
|
||||
"""公式模板元数据。"""
|
||||
class FormulaSpec:
|
||||
"""公式骨架元数据:参数角色 → 模板参数映射键。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
params: tuple[str, ...]
|
||||
roles: tuple[str, ...]
|
||||
description: str
|
||||
|
||||
|
||||
# 骨架类型(formula_type)。新增骨架需同步执行引擎分派点。
|
||||
FORMULAS: dict[str, FormulaSpec] = {
|
||||
"linear_decay": FormulaSpec(
|
||||
code="linear_decay",
|
||||
name="天数线性递减",
|
||||
roles=("start", "slope", "floor", "boundary"),
|
||||
description="按回款天数线性递减,超过截止天数取下限比例",
|
||||
),
|
||||
"fixed_profit": FormulaSpec(
|
||||
code="fixed_profit",
|
||||
name="折算毛利固定比例",
|
||||
roles=("rate",),
|
||||
description="折算毛利润额 × 固定比例",
|
||||
),
|
||||
"fixed_sales": FormulaSpec(
|
||||
code="fixed_sales",
|
||||
name="销售额固定比例",
|
||||
roles=("rate",),
|
||||
description="本人销售额 × 固定比例",
|
||||
),
|
||||
"manager_share": FormulaSpec(
|
||||
code="manager_share",
|
||||
name="经理团队分成",
|
||||
roles=("team", "company"),
|
||||
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
|
||||
),
|
||||
"prepay_fixed": FormulaSpec(
|
||||
code="prepay_fixed",
|
||||
name="预付款固定比例",
|
||||
roles=("rate",),
|
||||
description="预付回款额 × 固定比例",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateDefinition:
|
||||
"""模板定义解析结果:数据库行或旧库兜底系统模板。"""
|
||||
|
||||
template_code: str
|
||||
template_name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str | None
|
||||
is_system: bool
|
||||
status: int
|
||||
|
||||
|
||||
def get_formula(code: str) -> FormulaSpec:
|
||||
"""取骨架元数据;未知编码属配置错误,调用方转用户错误。"""
|
||||
spec = FORMULAS.get(code)
|
||||
if spec is None:
|
||||
raise ValueError(f"公式骨架未注册:{code}")
|
||||
return spec
|
||||
|
||||
|
||||
def system_definition(code: str) -> TemplateDefinition | None:
|
||||
"""旧库兜底:迁移未应用时按内置注册表合成定义,行为与历史一致。"""
|
||||
spec = TEMPLATES.get(code)
|
||||
if spec is None:
|
||||
return None
|
||||
return TemplateDefinition(
|
||||
template_code=spec.code,
|
||||
template_name=spec.name,
|
||||
formula_type=spec.formula_type,
|
||||
params=dict(spec.params),
|
||||
description=spec.description,
|
||||
is_system=True,
|
||||
status=1,
|
||||
)
|
||||
|
||||
|
||||
def resolve_definition(session: Session, code: str) -> TemplateDefinition:
|
||||
"""按编码解析模板定义:数据库优先,旧库兜底系统模板。"""
|
||||
from app.repositories import calc_template_repository
|
||||
|
||||
row = calc_template_repository.get_by_code(session, code)
|
||||
if row is not None:
|
||||
return TemplateDefinition(
|
||||
template_code=row.template_code,
|
||||
template_name=row.template_name,
|
||||
formula_type=row.formula_type,
|
||||
params=dict(row.params_json or {}),
|
||||
description=row.description,
|
||||
is_system=row.is_system == 1,
|
||||
status=row.status,
|
||||
)
|
||||
fallback = system_definition(code)
|
||||
if fallback is None:
|
||||
raise ValueError(f"计提公式模板未定义:{code}")
|
||||
return fallback
|
||||
|
||||
|
||||
def formula_type_of(session: Session, code: str) -> str:
|
||||
"""执行分派用:模板 → 公式骨架类型。"""
|
||||
return resolve_definition(session, code).formula_type
|
||||
|
||||
|
||||
# 模板参数角色到参数编码的内置映射(与 V28 种子一致;旧库兜底用)。
|
||||
_TPL_SALESPERSON = {
|
||||
"start": "salesperson.rate.start",
|
||||
"slope": "salesperson.rate.slope",
|
||||
"floor": "salesperson.rate.floor",
|
||||
"boundary": "salesperson.days.boundary",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateSpec:
|
||||
"""旧库兜底模板元数据(历史编码锚点)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str
|
||||
|
||||
|
||||
@ -24,49 +142,36 @@ TEMPLATES: dict[str, TemplateSpec] = {
|
||||
"TPL_SALESPERSON_DECAY": TemplateSpec(
|
||||
code="TPL_SALESPERSON_DECAY",
|
||||
name="业务员个人户·天数线性递减",
|
||||
params=(
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.days.boundary",
|
||||
),
|
||||
formula_type="linear_decay",
|
||||
params=_TPL_SALESPERSON,
|
||||
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
|
||||
),
|
||||
"TPL_MANAGER_SHARE": TemplateSpec(
|
||||
code="TPL_MANAGER_SHARE",
|
||||
name="营销经理·团队与全公司分成",
|
||||
params=("manager.share.team", "manager.share.company"),
|
||||
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
|
||||
formula_type="manager_share",
|
||||
params={"team": "manager.share.team", "company": "manager.share.company"},
|
||||
description="聚合:本团队业务员绩效×团队比例 + 全公司业务员绩效×公司比例",
|
||||
),
|
||||
"TPL_ENTERPRISE_FIXED": TemplateSpec(
|
||||
code="TPL_ENTERPRISE_FIXED",
|
||||
name="企业户·固定计提",
|
||||
params=("enterprise.rate",),
|
||||
formula_type="fixed_profit",
|
||||
params={"rate": "enterprise.rate"},
|
||||
description="乘法:折算毛利润额 × 计提比例",
|
||||
),
|
||||
"TPL_INFO_SALES_FIXED": TemplateSpec(
|
||||
code="TPL_INFO_SALES_FIXED",
|
||||
name="信息员·销售额固定比例",
|
||||
params=("info.rate",),
|
||||
formula_type="fixed_sales",
|
||||
params={"rate": "info.rate"},
|
||||
description="乘法:本人销售额 × 计提比例",
|
||||
),
|
||||
"TPL_PREPAY_FIXED": TemplateSpec(
|
||||
code="TPL_PREPAY_FIXED",
|
||||
name="预付款·固定计提",
|
||||
params=("prepay.rate",),
|
||||
description="乘法:回款额 × 计提比例",
|
||||
formula_type="prepay_fixed",
|
||||
params={"rate": "prepay.rate"},
|
||||
description="乘法:预付回款额 × 计提比例",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_template(code: str) -> TemplateSpec:
|
||||
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||
tpl = TEMPLATES.get(code)
|
||||
if tpl is None:
|
||||
raise ValueError(f"公式模板编码未注册:{code}")
|
||||
return tpl
|
||||
|
||||
|
||||
def required_params(code: str) -> tuple[str, ...]:
|
||||
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
|
||||
return get_template(code).params
|
||||
|
||||
330
backend/app/services/coefficient_service.py
Normal file
330
backend/app/services/coefficient_service.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""系数表是参数版本的编辑视图,不保留第二套工资公式或独立系数数据。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.schemas.coefficient import CoefficientRowIn
|
||||
from app.schemas.param import ParamChangeIn, ParamChangeItemIn
|
||||
from app.services import param_service
|
||||
from app.services.param_registry import get_spec, validate_and_normalize
|
||||
from app.services.rate_rules import decide, personal_values
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
CATEGORIES = ("municipal_water", "rail_transit")
|
||||
HEADERS = ("product_category", "customer_type", "stage", "value", "slope", "boundary")
|
||||
|
||||
|
||||
def _coefficient(rate):
|
||||
return str(1 / rate) if rate else None
|
||||
|
||||
|
||||
def _average_text(value):
|
||||
if value is None:
|
||||
return None
|
||||
return format(
|
||||
param_service.to_decimal(value).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP), "f"
|
||||
)
|
||||
|
||||
|
||||
def _revision(session, lock=False):
|
||||
if session is None:
|
||||
return []
|
||||
from app.models import ParamChangeBatch
|
||||
|
||||
query = select(
|
||||
ParamChangeBatch.id, ParamChangeBatch.effective_date, ParamChangeBatch.version_no
|
||||
).order_by(ParamChangeBatch.id)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
return [tuple(row) for row in session.execute(query)]
|
||||
|
||||
|
||||
def catalog(session, as_of=None, revision=None):
|
||||
day = as_of or date.today()
|
||||
snap = param_service.resolve_snapshot(session, day)
|
||||
rows = []
|
||||
for category in CATEGORIES:
|
||||
values = personal_values(snap, category)
|
||||
params = {**values, "enterprise": snap.get("enterprise.rate", product_category=category)}
|
||||
rates = [
|
||||
decide(
|
||||
params, category=category, customer_type="personal", role_code="salesperson", days=d
|
||||
)[0]
|
||||
for d in range(1, 361)
|
||||
]
|
||||
average = sum((1 / r for r in rates if r), Fraction(0)) / 360 if all(rates) else None
|
||||
for stage, field, days in (
|
||||
("first", "first", 1),
|
||||
("linear", "start", 2),
|
||||
("after", "floor", int(Fraction(values["boundary"])) + 1),
|
||||
):
|
||||
rate = decide(
|
||||
params,
|
||||
category=category,
|
||||
customer_type="personal",
|
||||
role_code="salesperson",
|
||||
days=days,
|
||||
)[0]
|
||||
rows.append(
|
||||
{
|
||||
"product_category": category,
|
||||
"customer_type": "personal",
|
||||
"stage": stage,
|
||||
"value": values[field],
|
||||
"slope": values["slope"] if stage == "linear" else None,
|
||||
"boundary": int(Fraction(values["boundary"])) if stage == "linear" else None,
|
||||
"rate": str(rate),
|
||||
"system_coefficient": _coefficient(rate),
|
||||
"average_coefficient_360": _average_text(average),
|
||||
}
|
||||
)
|
||||
rate = Fraction(params["enterprise"]) / 1000
|
||||
rows.append(
|
||||
{
|
||||
"product_category": category,
|
||||
"customer_type": "enterprise",
|
||||
"stage": "fixed",
|
||||
"value": params["enterprise"],
|
||||
"slope": None,
|
||||
"boundary": None,
|
||||
"rate": str(rate),
|
||||
"system_coefficient": _coefficient(rate),
|
||||
"average_coefficient_360": _average_text(1 / rate if rate else None),
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"product_category": category,
|
||||
"customer_type": "manager_personal",
|
||||
"stage": "excluded",
|
||||
"value": "0",
|
||||
"rate": "0",
|
||||
"system_coefficient": None,
|
||||
"average_coefficient_360": None,
|
||||
"readonly": True,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"as_of": str(day),
|
||||
"rows": rows,
|
||||
"fingerprint": digest(
|
||||
{
|
||||
"as_of": day,
|
||||
"rows": rows,
|
||||
"revision": _revision(session) if revision is None else revision,
|
||||
}
|
||||
),
|
||||
"average_rule": "第1—360天每日系统系数的算术平均,展示保留6位小数;存在零计提日时不适用。经理个人户不单笔计提。",
|
||||
}
|
||||
|
||||
|
||||
def items_for(rows):
|
||||
items = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
key = (row.product_category, row.customer_type, row.stage)
|
||||
if key in seen:
|
||||
raise BadRequestError("存在重复的产品/户型/阶段")
|
||||
seen.add(key)
|
||||
if row.customer_type == "enterprise":
|
||||
if row.stage != "fixed":
|
||||
raise BadRequestError("企业户使用固定阶段")
|
||||
fields = [("enterprise.rate", row.value)]
|
||||
elif row.stage == "fixed":
|
||||
raise BadRequestError("个人户请选择首日、线性或超期阶段")
|
||||
elif row.stage == "linear":
|
||||
if row.slope is None or row.boundary is None:
|
||||
raise BadRequestError("线性阶段须填写日斜率和截止天数")
|
||||
try:
|
||||
endpoint = Fraction(row.value) - row.boundary * Fraction(row.slope)
|
||||
except (ValueError, ZeroDivisionError) as exc:
|
||||
raise BadRequestError("比例及日斜率须为有效数值或分数") from exc
|
||||
if endpoint < 0:
|
||||
raise BadRequestError("线性阶段末比例不能为负数")
|
||||
fields = [
|
||||
("coefficient.personal.start", row.value),
|
||||
("coefficient.personal.slope", row.slope),
|
||||
("coefficient.personal.boundary", str(row.boundary)),
|
||||
]
|
||||
else:
|
||||
fields = [
|
||||
(
|
||||
"coefficient.personal.first"
|
||||
if row.stage == "first"
|
||||
else "coefficient.personal.floor",
|
||||
row.value,
|
||||
)
|
||||
]
|
||||
for code, value in fields:
|
||||
normalized = validate_and_normalize(get_spec(code), value)
|
||||
items.append(
|
||||
ParamChangeItemIn(
|
||||
param_code=code, dimension_value=row.product_category, value=normalized
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def save(session, payload, operator, meta=None):
|
||||
from app.models import ParamDefinition
|
||||
|
||||
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
|
||||
revision = _revision(session, lock=True)
|
||||
if payload.fingerprint != catalog(session, payload.base_date, revision)["fingerprint"]:
|
||||
raise BadRequestError("系数表已变化,请重新查询或重新导入预览")
|
||||
return param_service.submit_change(
|
||||
session,
|
||||
ParamChangeIn(
|
||||
change_type=2,
|
||||
effective_date=payload.effective_date,
|
||||
remark=payload.remark,
|
||||
items=items_for(payload.rows),
|
||||
),
|
||||
operator=operator,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
def workbook(session, as_of=None):
|
||||
data = catalog(session, as_of)
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.title = "系数参数"
|
||||
sheet.append([*HEADERS, "system_coefficient", "average_coefficient_360"])
|
||||
for row in data["rows"]:
|
||||
if row.get("readonly"):
|
||||
continue
|
||||
sheet.append(
|
||||
[row.get(k) for k in (*HEADERS, "system_coefficient", "average_coefficient_360")]
|
||||
)
|
||||
note = book.create_sheet("填写说明")
|
||||
note.append(["基准日期", data["as_of"]])
|
||||
for line in (
|
||||
"value为‰数;线性阶段value为公式起点,slope为每日递减‰数,可填写分数。",
|
||||
"产品:municipal_water市政水利/rail_transit轨道交通;户型personal个人户/enterprise企业户。",
|
||||
"阶段:first首日/linear线性/after超期/fixed企业固定;经理个人户不单笔计提,不导入。",
|
||||
data["average_rule"],
|
||||
"系统系数及平均系数是只读派生列,导入时可留空,填写则必须与参数一致。",
|
||||
):
|
||||
note.append([line])
|
||||
for column in sheet.columns:
|
||||
sheet.column_dimensions[column[0].column_letter].width = 28
|
||||
sheet.freeze_panes = "A2"
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def import_preview(session, content):
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise BadRequestError("系数文件不能超过2MB")
|
||||
try:
|
||||
with ZipFile(BytesIO(content)) as archive:
|
||||
if sum(item.file_size for item in archive.infolist()) > 20 * 1024 * 1024:
|
||||
raise BadRequestError("工作簿解压后超过20MB,请使用系数模板")
|
||||
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
|
||||
sheet = book["系数参数"]
|
||||
if sheet.max_row > 30 or sheet.max_column > 8:
|
||||
raise BadRequestError("系数表最多16条数据")
|
||||
values = list(sheet.iter_rows(values_only=True))
|
||||
if tuple(values[0][:6]) != HEADERS:
|
||||
raise BadRequestError("请使用系数表模板,保留前六列列名")
|
||||
rows = []
|
||||
errors = []
|
||||
for number, value in enumerate(values[1:], 2):
|
||||
if not any(v is not None for v in value):
|
||||
continue
|
||||
try:
|
||||
raw = {
|
||||
k: str(v) if v is not None else None
|
||||
for k, v in zip(HEADERS, value[:6], strict=True)
|
||||
}
|
||||
raw["boundary"] = int(raw["boundary"]) if raw["boundary"] else None
|
||||
row = CoefficientRowIn.model_validate(raw)
|
||||
items_for([row])
|
||||
rows.append(row)
|
||||
except (ValidationError, ValueError, BadRequestError) as exc:
|
||||
errors.append({"row": number, "message": str(exc)[:180]})
|
||||
if not rows and not errors:
|
||||
errors.append({"row": 2, "message": "文件没有可导入数据"})
|
||||
if rows:
|
||||
items_for(rows)
|
||||
# 派生列只做校验,不作为第二套输入。用临时参数快照复算。
|
||||
base = date.fromisoformat(str(book["填写说明"].cell(1, 2).value))
|
||||
snap = param_service.resolve_snapshot(session, base)
|
||||
temporary = {code: dict(dims) for code, dims in snap._values.items()}
|
||||
for item in items_for(rows):
|
||||
temporary.setdefault(item.param_code, {})[item.dimension_value] = item.value
|
||||
candidate = param_service.ParamSnapshot(base, temporary, {})
|
||||
for number, raw in enumerate(values[1:], 2):
|
||||
if len(raw) < 7:
|
||||
continue
|
||||
matching = next(
|
||||
(
|
||||
r
|
||||
for r in rows
|
||||
if (r.product_category, r.customer_type, r.stage) == tuple(raw[:3])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching is None:
|
||||
continue
|
||||
pv = personal_values(candidate, matching.product_category)
|
||||
day = {"first": 1, "linear": 2, "after": int(Fraction(pv["boundary"])) + 1, "fixed": 1}[
|
||||
matching.stage
|
||||
]
|
||||
rate = decide(
|
||||
{
|
||||
**pv,
|
||||
"enterprise": candidate.get(
|
||||
"enterprise.rate", product_category=matching.product_category
|
||||
),
|
||||
},
|
||||
category=matching.product_category,
|
||||
customer_type=matching.customer_type,
|
||||
role_code="salesperson",
|
||||
days=day,
|
||||
)[0]
|
||||
if raw[6] not in (None, "") and (rate == 0 or Fraction(str(raw[6])) != 1 / rate):
|
||||
errors.append(
|
||||
{"row": number, "message": "系统系数与公式参数不一致,请清空派生列或重新导出"}
|
||||
)
|
||||
if len(raw) > 7 and raw[7] not in (None, ""):
|
||||
if matching.customer_type == "enterprise":
|
||||
average = 1 / rate if rate else None
|
||||
else:
|
||||
daily = [
|
||||
decide(
|
||||
{**pv, "enterprise": "0"},
|
||||
category=matching.product_category,
|
||||
customer_type="personal",
|
||||
role_code="salesperson",
|
||||
days=d,
|
||||
)[0]
|
||||
for d in range(1, 361)
|
||||
]
|
||||
average = sum((1 / r for r in daily), Fraction(0)) / 360 if all(daily) else None
|
||||
if average is None or Decimal(str(raw[7])) != Decimal(_average_text(average)):
|
||||
errors.append({"row": number, "message": "360天平均系数与公式参数不一致"})
|
||||
return {
|
||||
"rows": [r.model_dump() for r in rows],
|
||||
"errors": errors,
|
||||
"can_apply": not errors,
|
||||
"fingerprint": catalog(session, base)["fingerprint"],
|
||||
"base_date": str(base),
|
||||
}
|
||||
except BadRequestError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise BadRequestError("无法读取系数表,请检查工作表名称和文件格式") from exc
|
||||
finally:
|
||||
if "book" in locals():
|
||||
book.close()
|
||||
14
backend/app/services/cost_rules.py
Normal file
14
backend/app/services/cost_rules.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""含税单位指导成本到总成本的唯一换算,不改写已封存的成本快照。"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
|
||||
def guided_total(quantity, guided_price, cost_amount=None):
|
||||
if guided_price is None:
|
||||
return Decimal(str(cost_amount)) if cost_amount is not None else None
|
||||
total = (Decimal(str(quantity)) * Decimal(str(guided_price))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if cost_amount is not None and Decimal(str(cost_amount)) != total:
|
||||
raise ValueError("成本额与数量×含税单位指导成本不一致")
|
||||
return total
|
||||
@ -6,8 +6,7 @@
|
||||
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
||||
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
||||
计划开放项,待业务确认是否物化
|
||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||
- 同步记录与采集开关分别展示;读取关闭不抹掉已完成的采集记录
|
||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||
@ -16,11 +15,14 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||
from app.models import TPlusSyncBatch
|
||||
from app.repositories import dashboard_repository, operation_log_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||
from app.services import approval_service
|
||||
@ -42,17 +44,54 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
|
||||
"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
pending_statuses = approval_service.pending_statuses_for_roles(
|
||||
user_repository.get_role_codes(current_user)
|
||||
)
|
||||
from app.services.authorization_service import calculate
|
||||
|
||||
permissions = calculate(session, current_user)["permissions"]
|
||||
pending_statuses = approval_service.pending_statuses_for_roles([
|
||||
role for role in ("finance", "leader") if "approvals:" + role in permissions
|
||||
])
|
||||
sync = _sync_status(session)
|
||||
return {
|
||||
"month": resolved,
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session, pending_statuses),
|
||||
"warnings": _warnings(session, warning_params),
|
||||
"todos": _todos(session, pending_statuses, sync),
|
||||
"warnings": _warnings(session, warning_params, sync),
|
||||
"sync": sync,
|
||||
}
|
||||
|
||||
|
||||
def _sync_status(session: Session) -> dict:
|
||||
"""只读本地批次元数据,不连接源库,兼容尚未应用自动化迁移的开发库。"""
|
||||
settings = get_settings()
|
||||
result = {
|
||||
"available": False,
|
||||
"enabled": settings.t_plus_enabled,
|
||||
"automation_enabled": settings.t_plus_automation_enabled,
|
||||
"latest_status": None,
|
||||
"last_success_at": None,
|
||||
"historical_failure_total": 0,
|
||||
}
|
||||
if not inspect(session.connection()).has_table(TPlusSyncBatch.__tablename__):
|
||||
return result
|
||||
# 关联检查成功不能替代数据采集成功;仅查询原有列,避免加载 V20 新字段。
|
||||
reads = TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL"))
|
||||
result["available"] = True
|
||||
result["latest_status"] = session.scalar(
|
||||
select(TPlusSyncBatch.status).where(reads).order_by(TPlusSyncBatch.id.desc()).limit(1)
|
||||
)
|
||||
result["last_success_at"] = session.scalar(
|
||||
select(func.max(TPlusSyncBatch.finished_at)).where(
|
||||
reads, TPlusSyncBatch.status == "SUCCEEDED"
|
||||
)
|
||||
)
|
||||
result["historical_failure_total"] = session.scalar(
|
||||
select(func.count()).select_from(TPlusSyncBatch).where(
|
||||
reads, TPlusSyncBatch.status.in_(("FAILED", "PARTIAL"))
|
||||
)
|
||||
) or 0
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_month(month: str | None) -> str:
|
||||
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
|
||||
if not month:
|
||||
@ -120,7 +159,7 @@ def _overview(session: Session, month: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...], sync: dict) -> dict:
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。
|
||||
|
||||
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
||||
@ -156,9 +195,8 @@ def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
}
|
||||
for row in receipts
|
||||
],
|
||||
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
|
||||
"sync_available": False,
|
||||
"sync_failure_total": 0,
|
||||
"sync_available": sync["available"],
|
||||
"sync_failure_total": sync["historical_failure_total"],
|
||||
}
|
||||
|
||||
|
||||
@ -174,8 +212,8 @@ def _warning_params(session: Session) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _warnings(session: Session, params: dict) -> dict:
|
||||
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
|
||||
def _warnings(session: Session, params: dict, sync: dict) -> dict:
|
||||
"""风险预警(FR-035);采集状态与经营预警开关分别展示。"""
|
||||
if not params["enabled"]:
|
||||
return {
|
||||
"enabled": False,
|
||||
@ -185,7 +223,7 @@ def _warnings(session: Session, params: dict) -> dict:
|
||||
"total": 0,
|
||||
"receipt_overdue_total": 0,
|
||||
"approval_timeout_total": 0,
|
||||
"sync_available": False,
|
||||
"sync_available": sync["available"],
|
||||
}
|
||||
|
||||
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
|
||||
@ -245,7 +283,7 @@ def _warnings(session: Session, params: dict) -> dict:
|
||||
"total": receipt_overdue_total + approval_timeout_total,
|
||||
"receipt_overdue_total": receipt_overdue_total,
|
||||
"approval_timeout_total": approval_timeout_total,
|
||||
"sync_available": False,
|
||||
"sync_available": sync["available"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
477
backend/app/services/monitor_collector.py
Normal file
477
backend/app/services/monitor_collector.py
Normal file
@ -0,0 +1,477 @@
|
||||
"""单应用进程的独立采集线程;探测有超时,写入异常不会进入业务请求。"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from time import monotonic, perf_counter
|
||||
|
||||
import psutil
|
||||
from redis import Redis
|
||||
from sqlalchemy import create_engine, delete, select, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
from app.core.monitoring import (
|
||||
REQUEST_ID,
|
||||
Recorder,
|
||||
fingerprint,
|
||||
merge_metrics,
|
||||
minute_of,
|
||||
now_local,
|
||||
)
|
||||
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||||
from app.repositories import monitor_repository as repo
|
||||
from app.services.tplus import automation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeErrorHandler(logging.Handler):
|
||||
"""后台错误只采集固定摘要;HTTP 错误由中间件记录,避免同一异常重复。"""
|
||||
|
||||
def __init__(self, recorder):
|
||||
super().__init__(logging.ERROR)
|
||||
self.recorder = recorder
|
||||
|
||||
def emit(self, record):
|
||||
if (
|
||||
record.name.startswith("app.")
|
||||
and ".monitor" not in record.name
|
||||
and REQUEST_ID.get() == "-"
|
||||
and getattr(record, "request_id", "-") == "-"
|
||||
):
|
||||
self.recorder.runtime_error(record.name)
|
||||
|
||||
|
||||
class MonitorCollector:
|
||||
def __init__(self, settings, recorder: Recorder):
|
||||
self.settings, self.recorder = settings, recorder
|
||||
self.recorder.slow_ms = settings.monitor_slow_request_ms
|
||||
self.lock = threading.Lock()
|
||||
self.stopped = threading.Event()
|
||||
self.thread = None
|
||||
self.engine = None
|
||||
self.redis = None
|
||||
self.process = None
|
||||
self.samples = OrderedDict()
|
||||
self.conditions = {}
|
||||
self.unknown_codes = set()
|
||||
self.pending_incidents = {}
|
||||
self.since = {}
|
||||
self.persisted = set()
|
||||
self.last_log = 0
|
||||
self.handler = RuntimeErrorHandler(recorder)
|
||||
self.state = {
|
||||
"collected_at": None,
|
||||
"last_persisted_at": None,
|
||||
"resources": {},
|
||||
"dependencies": [],
|
||||
"tasks": {},
|
||||
"alerts": [],
|
||||
"collection_status": "unknown",
|
||||
"collection_message": "正在等待首次采集。"
|
||||
if settings.monitor_enabled
|
||||
else "后台采集未启用。",
|
||||
}
|
||||
|
||||
def current(self):
|
||||
with self.lock:
|
||||
return deepcopy(self.state)
|
||||
|
||||
def sample_history(self):
|
||||
with self.lock:
|
||||
return deepcopy(self.samples)
|
||||
|
||||
def start(self):
|
||||
if not self.settings.monitor_enabled or self.thread:
|
||||
return
|
||||
self.stopped.clear()
|
||||
logging.getLogger().addHandler(self.handler)
|
||||
self.thread = threading.Thread(target=self._run, name="system-monitor", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=15)
|
||||
if self.thread.is_alive():
|
||||
logger.warning("监控采集线程未在关闭窗口内结束")
|
||||
return
|
||||
logging.getLogger().removeHandler(self.handler)
|
||||
if self.redis:
|
||||
self.redis.close()
|
||||
if self.engine:
|
||||
self.engine.dispose()
|
||||
self.thread = None
|
||||
|
||||
def _factory(self):
|
||||
if self.engine is None:
|
||||
self.engine = create_engine(
|
||||
self.settings.database_url,
|
||||
pool_size=1,
|
||||
max_overflow=0,
|
||||
pool_timeout=2,
|
||||
pool_pre_ping=True,
|
||||
connect_args={"connect_timeout": 2, "read_timeout": 2, "write_timeout": 2},
|
||||
)
|
||||
return sessionmaker(self.engine, expire_on_commit=False)
|
||||
|
||||
def _resources(self):
|
||||
first = self.process is None
|
||||
if first:
|
||||
self.process = psutil.Process()
|
||||
psutil.cpu_percent(None)
|
||||
self.process.cpu_percent(None)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage(str(BACKEND_DIR.anchor))
|
||||
return {
|
||||
"cpu_percent": None if first else psutil.cpu_percent(None),
|
||||
"memory_percent": memory.percent,
|
||||
"disk_percent": disk.percent,
|
||||
"memory_total_mb": round(memory.total / 1024**2),
|
||||
"disk_free_gb": round(disk.free / 1024**3, 2),
|
||||
"process_cpu_percent": None
|
||||
if first
|
||||
else round(self.process.cpu_percent(None) / (psutil.cpu_count() or 1), 2),
|
||||
"process_memory_mb": round(self.process.memory_info().rss / 1024**2, 2),
|
||||
"scope": "进程可见系统资源,未换算容器限额"
|
||||
if Path("/.dockerenv").exists()
|
||||
else "应用所在系统的可见资源",
|
||||
"process_cpu_scope": "占可见 CPU 总量的比例",
|
||||
}
|
||||
|
||||
def _dependencies(self, at):
|
||||
dependencies = [
|
||||
{
|
||||
"name": "应用",
|
||||
"status": "normal",
|
||||
"message": "采集线程正在运行。",
|
||||
"checked_at": at,
|
||||
"duration_ms": None,
|
||||
}
|
||||
]
|
||||
tasks = {"status": "unknown", "message": "尚无采集记录。", "alerts": []}
|
||||
for name in ("MySQL", "Redis"):
|
||||
started = perf_counter()
|
||||
try:
|
||||
if name == "MySQL":
|
||||
with self._factory()() as session:
|
||||
session.execute(text("SELECT 1"))
|
||||
else:
|
||||
if self.redis is None:
|
||||
self.redis = Redis.from_url(
|
||||
self.settings.redis_url, socket_connect_timeout=2, socket_timeout=2
|
||||
)
|
||||
self.redis.ping()
|
||||
status, message = "normal", "连接正常。"
|
||||
except Exception:
|
||||
status, message = "error", "连接检查失败,请核对服务和网络。"
|
||||
dependencies.append(
|
||||
{
|
||||
"name": name,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"checked_at": at,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 2),
|
||||
}
|
||||
)
|
||||
if dependencies[1]["status"] == "normal":
|
||||
try:
|
||||
with self._factory()() as session:
|
||||
tasks = automation.overview(session, settings=self.settings, now=at)
|
||||
source_configured = bool(
|
||||
self.settings.t_plus_enabled or self.settings.t_plus_connection_file
|
||||
)
|
||||
if not source_configured:
|
||||
status, message = "unknown", "T+ 未配置。"
|
||||
elif tasks.get("stop_requested"):
|
||||
status, message = "unknown", "已主动请求停止采集。"
|
||||
elif not tasks["enabled"]:
|
||||
status, message = "unknown", "自动采集未启用,可在原页面查看手动任务。"
|
||||
else:
|
||||
status = "warning" if tasks["alerts"] else "normal"
|
||||
message = (
|
||||
"存在采集异常,请查看任务与同步。"
|
||||
if tasks["alerts"]
|
||||
else "周期采集状态正常。"
|
||||
)
|
||||
tasks.update(status=status, message=message)
|
||||
except Exception:
|
||||
tasks = {
|
||||
"status": "unknown",
|
||||
"message": "任务状态读取失败,请检查迁移和服务日志。",
|
||||
"alerts": [],
|
||||
}
|
||||
dependencies.append(
|
||||
{
|
||||
"name": "T+ 采集进程",
|
||||
"status": tasks["status"],
|
||||
"message": tasks["message"],
|
||||
"checked_at": at,
|
||||
"duration_ms": None,
|
||||
}
|
||||
)
|
||||
return dependencies, tasks
|
||||
|
||||
def _evaluate(self, at, resources, dependencies, tasks):
|
||||
active = {}
|
||||
self.unknown_codes = set()
|
||||
for dependency in dependencies:
|
||||
if dependency["status"] == "error":
|
||||
code = "MYSQL_UNAVAILABLE" if dependency["name"] == "MySQL" else "REDIS_UNAVAILABLE"
|
||||
active[code] = ("error", dependency["name"], dependency["message"], None)
|
||||
for field, threshold, code, title in (
|
||||
("cpu_percent", self.settings.monitor_cpu_threshold, "CPU_HIGH", "CPU 占用持续偏高"),
|
||||
(
|
||||
"memory_percent",
|
||||
self.settings.monitor_memory_threshold,
|
||||
"MEMORY_HIGH",
|
||||
"内存占用持续偏高",
|
||||
),
|
||||
("disk_percent", self.settings.monitor_disk_threshold, "DISK_HIGH", "磁盘空间不足"),
|
||||
):
|
||||
value = resources.get(field)
|
||||
if value is None:
|
||||
self.unknown_codes.add(code)
|
||||
self.since.pop(code, None)
|
||||
# 采集未知时不将仍存在的问题标为恢复。
|
||||
if code in self.conditions:
|
||||
previous = self.conditions[code]
|
||||
active[code] = (
|
||||
previous["level"],
|
||||
previous["component"],
|
||||
previous["message"],
|
||||
None,
|
||||
)
|
||||
continue
|
||||
if value >= threshold:
|
||||
since = self.since.setdefault(code, at)
|
||||
# 重启后的持续时间尚未建立时,不关闭历史同类告警。
|
||||
self.unknown_codes.add(code)
|
||||
if (
|
||||
field == "disk_percent"
|
||||
or (at - since).total_seconds()
|
||||
>= self.settings.monitor_resource_duration_seconds
|
||||
):
|
||||
active[code] = ("warning", "resources", title + ",请检查资源趋势。", None)
|
||||
else:
|
||||
self.since.pop(code, None)
|
||||
recent = self.recorder.metrics(minute_of(at) - timedelta(minutes=4))
|
||||
metric = merge_metrics(m for endpoints in recent.values() for m in endpoints.values())
|
||||
if (
|
||||
metric["count"] >= self.settings.monitor_error_min_requests
|
||||
and metric["error_rate"] >= self.settings.monitor_error_rate_threshold
|
||||
):
|
||||
active["HTTP_ERROR_RATE"] = (
|
||||
"warning",
|
||||
"http",
|
||||
"近 5 分钟服务端错误率偏高,请查看失败接口。",
|
||||
None,
|
||||
)
|
||||
if tasks.get("status") == "unknown" and "任务状态读取失败" in tasks.get("message", ""):
|
||||
active["TASK_STATUS_UNKNOWN"] = ("warning", "tplus", tasks["message"], None)
|
||||
for alert in tasks.get("alerts", []):
|
||||
active[alert["code"]] = ("warning", "tplus", alert["message"], alert.get("batch_no"))
|
||||
if "enabled" not in tasks:
|
||||
self.unknown_codes.update(
|
||||
{"WORKER_OFFLINE", "QUEUE_OVERDUE", "SYNC_STALE", "SYNC_REQUIRES_ATTENTION"}
|
||||
)
|
||||
for code, previous in self.conditions.items():
|
||||
if code in self.unknown_codes:
|
||||
active[code] = (
|
||||
previous["level"],
|
||||
previous["component"],
|
||||
previous["message"],
|
||||
previous.get("batch_no"),
|
||||
)
|
||||
for code, (level, component, message, batch) in active.items():
|
||||
previous = self.conditions.get(code)
|
||||
incident = previous or {
|
||||
"id": 0,
|
||||
"instance_id": self.recorder.instance_id,
|
||||
"kind": "HEALTH",
|
||||
"first_seen": at,
|
||||
"occurrences": 0,
|
||||
"code": code,
|
||||
"fingerprint": fingerprint(code),
|
||||
"resolved_at": None,
|
||||
}
|
||||
incident.update(
|
||||
last_seen=at,
|
||||
occurrences=incident["occurrences"] + 1,
|
||||
level=level,
|
||||
component=component,
|
||||
message=message,
|
||||
batch_no=batch,
|
||||
)
|
||||
self.conditions[code] = incident
|
||||
self.pending_incidents[code] = deepcopy(incident)
|
||||
for code in set(self.conditions) - set(active):
|
||||
incident = self.conditions.pop(code)
|
||||
incident.update(resolved_at=at)
|
||||
self.pending_incidents[code] = deepcopy(incident)
|
||||
|
||||
def _persist(self, at, *, final=False):
|
||||
pending = self.recorder.take_events()
|
||||
try:
|
||||
with self._factory()() as session:
|
||||
# 首次采集复用历史未恢复事件,跨应用重启保留故障起点。
|
||||
historical = {item.code: item for item in repo.active_events(session)}
|
||||
for code, item in self.pending_incidents.items():
|
||||
row = historical.get(code)
|
||||
fields = {key: value for key, value in item.items() if key != "id"}
|
||||
if row is None:
|
||||
row = MonitorEvent(**fields)
|
||||
session.add(row)
|
||||
else:
|
||||
for key in (
|
||||
"last_seen",
|
||||
"resolved_at",
|
||||
"level",
|
||||
"message",
|
||||
"batch_no",
|
||||
"instance_id",
|
||||
):
|
||||
setattr(row, key, fields.get(key))
|
||||
row.occurrences = (
|
||||
max(row.occurrences, fields["occurrences"])
|
||||
if item.get("id") == row.id
|
||||
else row.occurrences + fields["occurrences"]
|
||||
)
|
||||
session.flush()
|
||||
if code in self.conditions:
|
||||
self.conditions[code].update(
|
||||
id=row.id, first_seen=row.first_seen, occurrences=row.occurrences
|
||||
)
|
||||
for code, row in historical.items():
|
||||
if (
|
||||
code not in self.conditions
|
||||
and code not in self.pending_incidents
|
||||
and code not in self.unknown_codes
|
||||
):
|
||||
# 只在本次完整采集后确认历史异常已恢复。
|
||||
row.resolved_at = at
|
||||
for item in pending:
|
||||
session.add(MonitorEvent(instance_id=self.recorder.instance_id, **item))
|
||||
completed = minute_of(at) + (timedelta(minutes=1) if final else timedelta())
|
||||
metrics = self.recorder.metrics(end=completed)
|
||||
written = []
|
||||
for minute in sorted(set(self.samples) | set(metrics)):
|
||||
if minute >= completed or minute in self.persisted:
|
||||
continue
|
||||
samples = self.samples.get(minute, [])
|
||||
endpoints = metrics.get(minute, {})
|
||||
resources = dict(samples[-1]) if samples else {}
|
||||
for key in (
|
||||
"cpu_percent",
|
||||
"memory_percent",
|
||||
"disk_percent",
|
||||
"process_cpu_percent",
|
||||
"process_memory_mb",
|
||||
):
|
||||
numbers = [sample[key] for sample in samples if sample.get(key) is not None]
|
||||
resources[key] = round(sum(numbers) / len(numbers), 2) if numbers else None
|
||||
session.add(
|
||||
MonitorSnapshot(
|
||||
instance_id=self.recorder.instance_id,
|
||||
minute=minute,
|
||||
sample_count=len(samples),
|
||||
resources=resources,
|
||||
requests=merge_metrics(endpoints.values()),
|
||||
)
|
||||
)
|
||||
for (method, route), values in endpoints.items():
|
||||
session.add(
|
||||
MonitorRequestMinute(
|
||||
instance_id=self.recorder.instance_id,
|
||||
minute=minute,
|
||||
method=method,
|
||||
route=route,
|
||||
metrics=values,
|
||||
)
|
||||
)
|
||||
written.append(minute)
|
||||
# 每次最多删除每表 1000 条,限定监控表及过期时间,避免长事务。
|
||||
cutoff = at - timedelta(days=7)
|
||||
for model, column in (
|
||||
(MonitorSnapshot, MonitorSnapshot.minute),
|
||||
(MonitorRequestMinute, MonitorRequestMinute.minute),
|
||||
(MonitorEvent, MonitorEvent.last_seen),
|
||||
):
|
||||
expired = select(model.id).where(column < cutoff)
|
||||
if model is MonitorEvent:
|
||||
expired = expired.where(
|
||||
(MonitorEvent.kind != "HEALTH") | MonitorEvent.resolved_at.is_not(None)
|
||||
)
|
||||
ids = session.scalars(expired.order_by(column).limit(1000)).all()
|
||||
if ids:
|
||||
session.execute(delete(model).where(model.id.in_(ids)))
|
||||
session.commit()
|
||||
self.persisted.update(written)
|
||||
self.persisted = {
|
||||
minute
|
||||
for minute in self.persisted
|
||||
if minute >= minute_of(at) - timedelta(minutes=10)
|
||||
}
|
||||
self.pending_incidents.clear()
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
last_persisted_at=at,
|
||||
collection_status="normal",
|
||||
collection_message="采集正常,分钟历史保留 7 天。",
|
||||
)
|
||||
except Exception:
|
||||
self.recorder.restore_events(pending)
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
collection_status="warning",
|
||||
collection_message="历史写入失败,部分数据可能缺失;请检查数据库迁移和服务日志。",
|
||||
)
|
||||
if monotonic() - self.last_log >= 60:
|
||||
logger.warning("监控历史写入失败,请检查迁移及数据库", exc_info=True)
|
||||
self.last_log = monotonic()
|
||||
|
||||
def tick(self):
|
||||
at = now_local()
|
||||
try:
|
||||
resources = self._resources()
|
||||
except Exception:
|
||||
resources = {"scope": "资源采集失败"}
|
||||
logger.warning("系统资源采集失败", exc_info=True)
|
||||
dependencies, tasks = self._dependencies(at)
|
||||
with self.lock:
|
||||
self.samples.setdefault(minute_of(at), []).append(resources)
|
||||
while len(self.samples) > 10:
|
||||
self.samples.popitem(last=False)
|
||||
self._evaluate(at, resources, dependencies, tasks)
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
collected_at=at, resources=resources, dependencies=dependencies, tasks=tasks
|
||||
)
|
||||
self._persist(at)
|
||||
with self.lock:
|
||||
self.state["alerts"] = deepcopy(list(self.conditions.values()))
|
||||
if not resources.get("memory_percent"):
|
||||
self.state.update(
|
||||
collection_status="warning",
|
||||
collection_message="资源采集失败,当前资源指标未知。",
|
||||
)
|
||||
|
||||
def _run(self):
|
||||
while not self.stopped.is_set():
|
||||
started = monotonic()
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logger.warning("监控采集周期失败", exc_info=True)
|
||||
with self.lock:
|
||||
self.state.update(
|
||||
collection_status="warning",
|
||||
collection_message="采集周期失败,请检查服务日志。",
|
||||
)
|
||||
self.stopped.wait(max(0, 15 - (monotonic() - started)))
|
||||
if self.engine is not None:
|
||||
self._persist(now_local(), final=True)
|
||||
198
backend/app/services/monitor_service.py
Normal file
198
backend/app/services/monitor_service.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""管理员监控视图:已落库历史与当前分钟合并,保持无数据与故障语义。"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from time import monotonic
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.monitoring import SHANGHAI, merge_metrics, minute_of, now_local
|
||||
from app.core.response import PageResult
|
||||
from app.repositories import monitor_repository as repo
|
||||
from app.schemas.monitor import EventOut, OverviewOut
|
||||
|
||||
|
||||
def window(query):
|
||||
now = now_local()
|
||||
end = query.end or now
|
||||
if end.tzinfo:
|
||||
end = end.astimezone(SHANGHAI).replace(tzinfo=None)
|
||||
if end > now + timedelta(seconds=30) or end < now - timedelta(days=7):
|
||||
raise BadRequestError("查询时间须在最近 7 天内")
|
||||
return max(end - timedelta(hours=query.hours), now - timedelta(days=7)), end
|
||||
|
||||
|
||||
def overview(session, collector):
|
||||
state = collector.current()
|
||||
now = now_local()
|
||||
current_minute = minute_of(now)
|
||||
stored = repo.snapshots(session, current_minute - timedelta(minutes=4), current_minute)
|
||||
# 未落库分钟从有界缓冲补齐;仅跳过同实例已持久化的分钟。
|
||||
known = {(row.instance_id, row.minute) for row in stored}
|
||||
recent = collector.recorder.metrics(current_minute - timedelta(minutes=4))
|
||||
values = [row.requests for row in stored]
|
||||
values.extend(
|
||||
metric
|
||||
for minute, endpoints in recent.items()
|
||||
if (collector.recorder.instance_id, minute) not in known
|
||||
for metric in endpoints.values()
|
||||
)
|
||||
state["requests"] = merge_metrics(values)
|
||||
stale = not state["collected_at"] or (now - state["collected_at"]).total_seconds() > 45
|
||||
if stale:
|
||||
state.update(
|
||||
collection_status="unknown",
|
||||
collection_message="采集未启用或已超过 45 秒未更新,当前状态未知。",
|
||||
)
|
||||
for dependency in state["dependencies"]:
|
||||
dependency.update(status="unknown", message="采集已过期,请检查服务日志。")
|
||||
persisted_alerts = {
|
||||
row.code: EventOut.model_validate(row) for row in repo.active_events(session)
|
||||
}
|
||||
for item in state["alerts"]:
|
||||
persisted_alerts[item["code"]] = EventOut.model_validate(item)
|
||||
state["alerts"] = list(persisted_alerts.values())
|
||||
status = (
|
||||
"unknown"
|
||||
if stale
|
||||
else "error"
|
||||
if any(dep["status"] == "error" for dep in state["dependencies"])
|
||||
else "warning"
|
||||
if state["alerts"] or state["collection_status"] == "warning"
|
||||
else "normal"
|
||||
)
|
||||
return OverviewOut(
|
||||
status=status,
|
||||
environment=collector.settings.app_env,
|
||||
version="0.1.0",
|
||||
instance_id=collector.recorder.instance_id,
|
||||
uptime_seconds=int(monotonic() - collector.recorder.started),
|
||||
dropped_events=collector.recorder.dropped_events,
|
||||
**state,
|
||||
)
|
||||
|
||||
|
||||
def trends(session, collector, query):
|
||||
start, end = window(query)
|
||||
stored = repo.snapshots(session, minute_of(start), end)
|
||||
# 24 小时按 5 分钟、7 天按 30 分钟聚合,间断采集保留 null 点。
|
||||
step = 1 if query.hours == 1 else 5 if query.hours == 24 else 30
|
||||
groups = defaultdict(list)
|
||||
for row in stored:
|
||||
minute = row.minute.replace(minute=(row.minute.minute // step) * step)
|
||||
groups[minute].append(
|
||||
{"sample_count": row.sample_count, "resources": row.resources, "requests": row.requests}
|
||||
)
|
||||
at = minute_of(end)
|
||||
recent = collector.recorder.metrics(start, end)
|
||||
# 只补尚未持久化的分钟,历史查看不引入窗口外最新资源。
|
||||
known = {(row.instance_id, row.minute) for row in stored}
|
||||
sample_history = collector.sample_history()
|
||||
for minute in set(recent) | set(sample_history):
|
||||
if not start <= minute < end or (collector.recorder.instance_id, minute) in known:
|
||||
continue
|
||||
samples = sample_history.get(minute, [])
|
||||
groups[minute.replace(minute=(minute.minute // step) * step)].append(
|
||||
{
|
||||
"sample_count": len(samples),
|
||||
"resources": samples[-1] if samples else {},
|
||||
"requests": merge_metrics(recent.get(minute, {}).values()),
|
||||
}
|
||||
)
|
||||
cursor = minute_of(start).replace(minute=(start.minute // step) * step)
|
||||
points = []
|
||||
while cursor <= at:
|
||||
rows = groups.get(cursor, [])
|
||||
point = {"at": cursor, "sample_count": sum(row["sample_count"] for row in rows)}
|
||||
for key in ("cpu_percent", "memory_percent", "disk_percent"):
|
||||
values = [
|
||||
(row["resources"].get(key), row["sample_count"])
|
||||
for row in rows
|
||||
if row["resources"].get(key) is not None and row["sample_count"]
|
||||
]
|
||||
point[key] = (
|
||||
round(
|
||||
sum(value * count for value, count in values)
|
||||
/ sum(count for _, count in values),
|
||||
2,
|
||||
)
|
||||
if values
|
||||
else None
|
||||
)
|
||||
metric = merge_metrics(row["requests"] for row in rows)
|
||||
point.update(
|
||||
request_count=metric["count"] if rows else None,
|
||||
error_rate=metric["error_rate"] if rows else None,
|
||||
p95_ms=metric["p95_ms"],
|
||||
)
|
||||
points.append(point)
|
||||
cursor += timedelta(minutes=step)
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"step_minutes": step,
|
||||
"points": points,
|
||||
"p95_approximate": True,
|
||||
}
|
||||
|
||||
|
||||
def interfaces(session, collector, query):
|
||||
start, end = window(query)
|
||||
rows = repo.request_minutes(session, minute_of(start), end, query.route)
|
||||
truncated = len(rows) > 20000
|
||||
rows = rows[:20000]
|
||||
grouped, known = defaultdict(list), set()
|
||||
for row in rows:
|
||||
grouped[(row.method, row.route)].append(row.metrics)
|
||||
known.add((row.instance_id, row.minute, row.method, row.route))
|
||||
for minute, endpoints in collector.recorder.metrics(minute_of(start), end).items():
|
||||
for (method, route), metric in endpoints.items():
|
||||
if (not query.route or route == query.route) and (
|
||||
collector.recorder.instance_id,
|
||||
minute,
|
||||
method,
|
||||
route,
|
||||
) not in known:
|
||||
grouped[(method, route)].append(metric)
|
||||
records = [
|
||||
{"method": method, "route": route, **merge_metrics(items)}
|
||||
for (method, route), items in grouped.items()
|
||||
]
|
||||
records.sort(key=lambda item: (item["p95_ms"] or 0, item["count"]), reverse=True)
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"list": records[:200],
|
||||
"truncated": truncated or len(records) > 200,
|
||||
"p95_approximate": True,
|
||||
}
|
||||
|
||||
|
||||
def events(session, query):
|
||||
start, end = window(query)
|
||||
rows, total = repo.events(session, query, start, end)
|
||||
return PageResult(
|
||||
records=[EventOut.model_validate(row) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def event(session, event_id):
|
||||
row = repo.event(session, event_id)
|
||||
if row is None or (
|
||||
row.last_seen < now_local() - timedelta(days=7)
|
||||
and not (row.kind == "HEALTH" and row.resolved_at is None)
|
||||
):
|
||||
raise NotFoundError("诊断事件不存在或已过保留期")
|
||||
return EventOut.model_validate(row)
|
||||
|
||||
|
||||
def diagnostic(session, collector, query):
|
||||
# 仅导出与页面相同的白名单模型;不读取日志文件、配置、请求正文或工资明细。
|
||||
return {
|
||||
"generated_at": now_local(),
|
||||
"overview": overview(session, collector),
|
||||
"events": events(session, query),
|
||||
"note": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。",
|
||||
}
|
||||
@ -47,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||
description="业务员个人户的起始计提比例;实际比例按回款天数递减,超过分界天数后使用计提下限。",
|
||||
sort_order=1,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -61,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4/360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||
description="回款每延后一天,计提比例减少的幅度。可填写分数,例如 4/360,以保留准确取值。",
|
||||
sort_order=2,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -75,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||
description="回款天数超过分界天数后,按此固定比例计提。",
|
||||
sort_order=3,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -89,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||
description="按日递减的回款天数上限。超过该天数后,使用业务员计提下限。",
|
||||
sort_order=4,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -103,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="12.5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||
description="按分管业务员的绩效总额计算团队分成。正式核算使用发货时登记的业务分管关系。",
|
||||
sort_order=5,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -117,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||
description="按全公司业务员的绩效总额计算公司分成,并与本团队分成相加。",
|
||||
sort_order=6,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -131,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||
description="企业户按此比例计提,不随回款天数递减。实际适用值以回款到账日的生效记录为准。",
|
||||
sort_order=7,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -145,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||
description="信息员按本人销售额和此比例计提,不随回款天数递减。",
|
||||
sort_order=8,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -159,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||
description="历史核算参数。正式核算中,预收款在实际发货后才参与计提,并按最高计提比例计算。",
|
||||
sort_order=9,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -173,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="80",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||
description="历史核算参数,用于以物抵顶绩效折算。当前特殊抵顶业务按线下流程单独核算。",
|
||||
sort_order=10,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -187,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="60",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||
description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -201,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="FULL",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||
description="历史核算的支付基数设置。正式核算按累计回款比例的平方计算累计应付,本月只计与历史应付的差额。",
|
||||
sort_order=12,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -215,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="30",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||
description="折算毛利润额=不含运费的含税销售额×(毛利率-阈值)。低于阈值时结果可为负值,仍按规则计入。",
|
||||
sort_order=13,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -229,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="INCLUDE",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||
description="设置基本工资是否与绩效相加、扣除已发基本工资,或不参与月工资计算。",
|
||||
sort_order=14,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -243,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="18000",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||
description="试用期绩效正常核算、暂不发放;年内累计超过此金额的部分,在年底一次性结算。",
|
||||
sort_order=15,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -257,7 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||
description="旧版比例分配方案的审核设置,仅用于历史核算流程。当前正式核算按各人发货对应的绩效分配。",
|
||||
sort_order=17,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -271,7 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||
description="启用系统审核后,设置月度结算是否经过财务复核和领导审核。系统审核关闭时,工资生成即完成,不产生审核流程记录。",
|
||||
sort_order=18,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -285,7 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||
description="启用系统审核后,设置调整单是否经过财务复核和领导审核。关闭时调整单直接生效,业务操作记录仍保留。",
|
||||
sort_order=19,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -299,7 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||
description="历史核算的经理个人户汇总设置。当前正式核算排除经理本人业务,不受此参数影响。",
|
||||
sort_order=20,
|
||||
),
|
||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||
@ -315,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
|
||||
description="控制首页的业务风险预警,关闭后停止按回款逾期、审批超时等规则扫描。",
|
||||
sort_order=21,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -329,7 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
|
||||
description="发货后超过设定天数仍未收到回款的批次,会显示在首页风险预警中。",
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
@ -343,13 +343,29 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
|
||||
description="审核流程开启后,提交超过设定天数仍未处理的单据,会显示在首页风险预警中。",
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
for _field, _title, _kind, _unit in (
|
||||
("first", "个人户首日比例", "DECIMAL", "‰"),
|
||||
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
|
||||
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
|
||||
("floor", "个人户超期比例", "DECIMAL", "‰"),
|
||||
("boundary", "个人户线性阶段截止天数", "INT", "天"),
|
||||
):
|
||||
_code = f"coefficient.personal.{_field}"
|
||||
PARAM_REGISTRY[_code] = ParamSpec(
|
||||
code=_code, name=_title, group="产品系数表", value_type=_kind, unit=_unit,
|
||||
dimension_type="PRODUCT_CATEGORY", options=None, default_value=None,
|
||||
value_min="1" if _kind == "INT" else "0", value_max="3660" if _kind == "INT" else None,
|
||||
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。", sort_order=30 + len(PARAM_REGISTRY),
|
||||
)
|
||||
|
||||
|
||||
def get_spec(code: str) -> ParamSpec:
|
||||
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||
spec = PARAM_REGISTRY.get(code)
|
||||
|
||||
@ -179,7 +179,8 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
|
||||
default_value=definition.default_value,
|
||||
value_min=definition.value_min,
|
||||
value_max=definition.value_max,
|
||||
description=definition.description,
|
||||
# 说明文案随已确认规则更新,数值与生效日期仍从历史记录读取。
|
||||
description=spec.description,
|
||||
values=by_code.get(definition.param_code, []),
|
||||
)
|
||||
)
|
||||
@ -280,6 +281,11 @@ def submit_change(
|
||||
if payload.change_type == 2 and effective < date.today():
|
||||
raise BadRequestError("指定生效日期不得早于今日")
|
||||
|
||||
# 系数表与原参数页共用串行化入口,未来版本也不能绕过预览指纹。
|
||||
from sqlalchemy import select
|
||||
|
||||
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
|
||||
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||
normalized = _normalize_items(session, payload.items)
|
||||
|
||||
@ -545,20 +551,10 @@ class _PreviewPayment:
|
||||
|
||||
|
||||
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||
|
||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||
"""试算使用正式规则的优先级和基数,并记录真正使用的覆盖参数。"""
|
||||
from app.services.rate_rules import decide, personal_values
|
||||
|
||||
payload, take = ctx.payload, ctx.take
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
@ -566,36 +562,32 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||
template = role.calc_template
|
||||
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
raise BadRequestError(
|
||||
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||
)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||
rate = take("info.rate") / 1000
|
||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = take("salesperson.rate.start")
|
||||
slope = take("salesperson.rate.slope")
|
||||
floor = take("salesperson.rate.floor")
|
||||
boundary = int(take("salesperson.days.boundary"))
|
||||
rate = formula.salesperson_rate(
|
||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||
)
|
||||
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||
return (
|
||||
rate,
|
||||
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||
None,
|
||||
)
|
||||
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
|
||||
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
|
||||
raise BadRequestError(
|
||||
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
|
||||
)
|
||||
definition = calc_templates.resolve_definition(ctx.session, template)
|
||||
prepayment = payload.receipt_date < payload.ship_date
|
||||
if definition.formula_type == "manager_share" or payload.customer_type == "manager_personal":
|
||||
return Fraction(0), "经理本人业务不单笔计提,团队分成请到月度工资查看", None
|
||||
if definition.formula_type == "fixed_sales":
|
||||
rate = take(definition.params.get("rate", "info.rate")) / 1000
|
||||
return rate, "按岗位固定比例计提", Decimal(payload.revenue)
|
||||
if payload.customer_type == "enterprise" and not prepayment:
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户按产品分类及到账日的固定比例计提", None
|
||||
if definition.formula_type != "linear_decay" and not prepayment:
|
||||
raise BadRequestError(f"「{definition.template_name}」不支持单笔试算,请检查岗位模板")
|
||||
decay = definition.params
|
||||
for code in (decay.get("start", "salesperson.rate.start"), decay.get("slope", "salesperson.rate.slope"), decay.get("floor", "salesperson.rate.floor"), decay.get("boundary", "salesperson.days.boundary")):
|
||||
take(code)
|
||||
snapshot = resolve_snapshot(ctx.session, payload.receipt_date)
|
||||
category = payload.product_category or "municipal_water"
|
||||
values = personal_values(snapshot, category)
|
||||
for field in ("first", "start", "slope", "floor", "boundary"):
|
||||
code = f"coefficient.personal.{field}"
|
||||
dimensions = snapshot._values.get(code, {})
|
||||
if category in dimensions or "" in dimensions:
|
||||
take(code)
|
||||
rate, _, rule = decide({**values, "enterprise": "0"}, category=category,
|
||||
customer_type=payload.customer_type, role_code="salesperson", days=days, prepayment=prepayment)
|
||||
return rate, rule, None
|
||||
|
||||
|
||||
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
"""工资操作按已绑定的账号身份授权,管理员和岗位角色不构成旁路。"""
|
||||
"""工资操作使用账号继承的角色权限,不使用全局制作人绑定。"""
|
||||
|
||||
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
|
||||
"""兼容工资写入口;各操作的具体权限仍由接口和服务校验。"""
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from app.models import User
|
||||
from app.services.authorization_service import is_maker
|
||||
|
||||
if not isinstance(user, User) or user.status != 1:
|
||||
return False
|
||||
session = object_session(user)
|
||||
return session is not None and is_maker(session, user)
|
||||
|
||||
|
||||
def ensure_payroll_writer(user):
|
||||
if not can_write_payroll(user):
|
||||
raise ForbiddenError("仅指定的工资制作人可以修改业务数据")
|
||||
raise ForbiddenError("当前账号没有工资操作权限,请联系管理员分配相应角色")
|
||||
return user
|
||||
|
||||
@ -28,6 +28,9 @@ from app.services.tplus.calculation_preview import _parameters
|
||||
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
|
||||
|
||||
def latest_input(session, business_id, *, for_update=False):
|
||||
statement = (
|
||||
@ -133,6 +136,8 @@ def _post(
|
||||
version,
|
||||
reason=None,
|
||||
):
|
||||
from app.services.probation_service import record as record_probation
|
||||
|
||||
person = session.get(Personnel, owner)
|
||||
if person is None:
|
||||
raise BadRequestError("正式归属人员不存在")
|
||||
@ -166,7 +171,7 @@ def _post(
|
||||
weight_r=weight_r,
|
||||
payment_weight=formula.to_decimal(formula.payment_weight(weight_r)),
|
||||
settlement_month=month,
|
||||
is_probation=person.is_probation,
|
||||
is_probation=1 if event.get("is_probation", False) else 0,
|
||||
missing_cost=0,
|
||||
calc_reason=reason,
|
||||
snapshot_json={
|
||||
@ -178,12 +183,13 @@ def _post(
|
||||
"manager_id": manager,
|
||||
"manager_own_excluded": event.get("excluded_manager_own", False),
|
||||
"payable_delta": amount_text(payable),
|
||||
"rule_version": "2026-10-30.2",
|
||||
"rule_version": RULE_VERSION,
|
||||
"recalc_reason": reason,
|
||||
},
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
record_probation(session, row)
|
||||
session.add(
|
||||
PayrollAllocation(
|
||||
ledger_id=row.id,
|
||||
@ -279,6 +285,7 @@ def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
|
||||
def _calculate_month(session, business, record, month, reason=None):
|
||||
from app.services.manager_share import month_end
|
||||
from app.services.probation_service import on_date
|
||||
|
||||
business_id = business.id
|
||||
until = month_end(month)
|
||||
@ -298,7 +305,12 @@ def _calculate_month(session, business, record, month, reason=None):
|
||||
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"]))
|
||||
shipments_by_line = {
|
||||
str(s["line_id"]): s for s in values["shipments"]
|
||||
}
|
||||
ship = shipments_by_line.get(str(line["line_id"]))
|
||||
if ship is None:
|
||||
raise BadRequestError(f"发货明细缺失,行号 {line['line_id']} 未找到,请重新核对资料")
|
||||
current[key] = {
|
||||
**line,
|
||||
"category": ship["category"],
|
||||
@ -344,6 +356,14 @@ def _calculate_month(session, business, record, month, reason=None):
|
||||
owner = values["maps"]["personnel"].get(str(target))
|
||||
if owner is None:
|
||||
raise BadRequestError("接手人员尚未正式导入")
|
||||
person = session.get(Personnel, owner)
|
||||
# 试用判定以回款日为准:_post 会把结果写进快照,历史台账不再按当前状态重分类。
|
||||
receipt_day = date.fromisoformat(event["receipt_date"])
|
||||
event = {
|
||||
**event,
|
||||
"is_probation": on_date(person, receipt_day),
|
||||
"probation_year": event["receipt_date"][:4],
|
||||
}
|
||||
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:
|
||||
|
||||
@ -29,9 +29,10 @@ def month_state(session, month):
|
||||
}
|
||||
|
||||
|
||||
def lock_month(session, month, operator):
|
||||
def lock_month(session, month, operator, *, require_maker=True):
|
||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
||||
ensure_payroll_writer(operator)
|
||||
if require_maker:
|
||||
ensure_payroll_writer(operator)
|
||||
validate_month(month)
|
||||
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
||||
if session.bind.dialect.name == "mysql":
|
||||
@ -50,8 +51,8 @@ def lock_month(session, month, operator):
|
||||
return control
|
||||
|
||||
|
||||
def ensure_allocation_editable(session, month, operator):
|
||||
control = lock_month(session, month, operator)
|
||||
def ensure_allocation_editable(session, month, operator, *, require_maker=True):
|
||||
control = lock_month(session, month, operator, require_maker=require_maker)
|
||||
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
|
||||
old = session.scalar(
|
||||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||
|
||||
79
backend/app/services/permission_catalog.py
Normal file
79
backend/app/services/permission_catalog.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""权限目录随代码发布;菜单、按钮和 API 共用同一操作码。"""
|
||||
|
||||
MODULES = (
|
||||
("dashboard", "工作台", "首页", ("view",)),
|
||||
("system:users", "系统管理", "用户管理", ("view", "create", "update", "delete", "status", "password")),
|
||||
("system:roles", "系统管理", "角色管理", ("view", "create", "update", "delete", "status")),
|
||||
("system:business_roles", "系统管理", "计提岗位", ("view", "create", "update", "delete", "status")),
|
||||
("system:params", "系统管理", "计提参数", ("view", "update", "withdraw", "export", "preview")),
|
||||
("system:settings", "系统管理", "公司信息", ("view", "update")),
|
||||
("system:logs", "系统管理", "日志查询", ("view",)),
|
||||
("system:monitor", "系统管理", "系统监控", ("view", "export")),
|
||||
("base:personnel", "基础资料", "人员管理", ("view", "create", "update", "delete", "status", "regions", "plans")),
|
||||
("base:products", "基础资料", "产品管理", ("view", "create", "update", "delete", "status")),
|
||||
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
|
||||
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
|
||||
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
|
||||
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
|
||||
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
|
||||
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
|
||||
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
|
||||
("settlements", "绩效工资", "月度工资", ("view", "generate", "submit")),
|
||||
("adjustments", "绩效工资", "工资调整", ("view", "create")),
|
||||
("approvals", "绩效工资", "审核记录", ("view", "finance", "leader")),
|
||||
("reports:business", "报表统计", "业务统计", ("view", "export")),
|
||||
("reports:personal", "报表统计", "个人明细", ("view", "export")),
|
||||
("reports:department", "报表统计", "部门汇总", ("view", "export")),
|
||||
("reports:payments", "报表统计", "回款统计", ("view", "export")),
|
||||
)
|
||||
ACTION_NAMES = {
|
||||
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
|
||||
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
|
||||
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",
|
||||
"withdraw": "撤回参数", "collect": "创建采集", "retry": "重试采集", "import": "执行导入",
|
||||
"facts": "维护补充资料", "prepare": "准备核算资料", "calculate": "核算与重算",
|
||||
"allocation": "修改工资分配", "generate": "生成工资", "submit": "提交审核",
|
||||
"finance": "财务复核", "leader": "领导审核",
|
||||
}
|
||||
CATALOG = {
|
||||
f"{module}:{action}": {
|
||||
"code": f"{module}:{action}", "module": module, "group": group,
|
||||
"module_name": name, "name": ACTION_NAMES[action], "action": action,
|
||||
"view_code": f"{module}:view", "admin_only": module in (
|
||||
"system:users", "system:roles", "system:authorization", "system:monitor"
|
||||
),
|
||||
"maker_required": False,
|
||||
}
|
||||
for module, group, name, actions in MODULES for action in actions
|
||||
}
|
||||
MAKER_ROLE = "payroll_maker"
|
||||
PAYROLL_WRITE_PERMISSIONS = frozenset(
|
||||
code for code, item in CATALOG.items()
|
||||
if item["module"] in ("payroll", "settlements", "adjustments") and item["action"] != "view"
|
||||
)
|
||||
MAKER_PERMISSIONS = {
|
||||
code for code, item in CATALOG.items()
|
||||
if code in PAYROLL_WRITE_PERMISSIONS or item["module"] in (
|
||||
"base:personnel", "base:products", "base:customers", "business:businesses",
|
||||
"business:shipments", "business:receipts", "business:ratings", "tplus",
|
||||
) or (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计"))
|
||||
}
|
||||
USER_PERMISSIONS = {
|
||||
code for code, item in CATALOG.items()
|
||||
if item["action"] == "view" and item["group"] in ("基础资料", "业务数据")
|
||||
}
|
||||
ROLE_DEFAULTS = {
|
||||
"admin": set(CATALOG), "user": USER_PERMISSIONS,
|
||||
"marketing_ops": USER_PERMISSIONS,
|
||||
"finance": USER_PERMISSIONS | {
|
||||
code for code, item in CATALOG.items()
|
||||
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
|
||||
or (item["group"] == "报表统计" and item["action"] == "export")
|
||||
} | {"approvals:finance", "system:params:view", "system:params:preview"},
|
||||
"leader": USER_PERMISSIONS | {
|
||||
code for code, item in CATALOG.items()
|
||||
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
|
||||
or (item["group"] == "报表统计" and item["action"] == "export")
|
||||
} | {"approvals:leader", "system:params:view", "system:params:preview"},
|
||||
MAKER_ROLE: MAKER_PERMISSIONS,
|
||||
}
|
||||
84
backend/app/services/personnel_salary.py
Normal file
84
backend/app/services/personnel_salary.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""人员工资与任职资料按生效日期留存,生成历史工资不读后来变更的标准。"""
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import MonthlySettlement
|
||||
|
||||
FIELDS = (
|
||||
"salary_standard",
|
||||
"business_role_id",
|
||||
"hire_date",
|
||||
"resignation_date",
|
||||
"probation_start_date",
|
||||
"regularization_date",
|
||||
"is_probation",
|
||||
)
|
||||
|
||||
|
||||
def capture(person, effective):
|
||||
return {
|
||||
"status": str(person.status),
|
||||
"effective_date": str(effective),
|
||||
**{
|
||||
key: str(getattr(person, key)) if getattr(person, key) is not None else None
|
||||
for key in FIELDS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def begin_update(session, person, effective):
|
||||
if session.scalar(
|
||||
select(MonthlySettlement.id).where(
|
||||
MonthlySettlement.settlement_month >= effective.strftime("%Y-%m")
|
||||
)
|
||||
):
|
||||
raise BadRequestError(
|
||||
"生效月份或后续月份已生成工资,请使用未来生效日期;历史差额走当月调整"
|
||||
)
|
||||
history = list(person.salary_history_json or [])
|
||||
if not history:
|
||||
baseline = person.hire_date or person.create_time.date()
|
||||
history.append(capture(person, baseline))
|
||||
return history
|
||||
|
||||
|
||||
def save_version(person, effective, history=None):
|
||||
entries = list(history or person.salary_history_json or [])
|
||||
entries.append(capture(person, effective))
|
||||
person.salary_history_json = entries
|
||||
|
||||
|
||||
def resolve(person, day):
|
||||
entries = [
|
||||
entry for entry in (person.salary_history_json or []) if entry["effective_date"] <= str(day)
|
||||
]
|
||||
values = {key: getattr(person, key) for key in FIELDS}
|
||||
values["status"] = person.status
|
||||
if person.salary_history_json and not entries:
|
||||
values["salary_standard"] = None
|
||||
elif entries:
|
||||
selected = max(enumerate(entries), key=lambda pair: (pair[1]["effective_date"], pair[0]))[1]
|
||||
values["status"] = int(selected.get("status", "1"))
|
||||
for key in FIELDS:
|
||||
raw = selected.get(key)
|
||||
if key.endswith("date"):
|
||||
values[key] = date.fromisoformat(raw) if raw else None
|
||||
elif key in ("business_role_id", "is_probation"):
|
||||
values[key] = int(raw) if raw else 0
|
||||
elif key == "salary_standard":
|
||||
from decimal import Decimal
|
||||
|
||||
values[key] = Decimal(raw) if raw is not None else None
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def employed(person, profile, day):
|
||||
if profile.hire_date and profile.hire_date > day:
|
||||
return False
|
||||
if profile.resignation_date:
|
||||
return day < profile.resignation_date
|
||||
return profile.status == 1
|
||||
@ -4,8 +4,8 @@
|
||||
|
||||
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
|
||||
会让 M3 核算找不到模板
|
||||
- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5%
|
||||
聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算
|
||||
- 直属经理是可空的档案参考;实际绩效按业务分管及冻结核算资料归属。
|
||||
新选经理不能是自己,须在职且使用启用的经理计提岗位;旧参考关系保留供核对
|
||||
- 删除保护:被团队成员(personnel.manager_id)或客户归属
|
||||
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
|
||||
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
|
||||
@ -16,6 +16,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -42,7 +43,7 @@ from app.schemas.personnel import (
|
||||
PlanReplaceIn,
|
||||
RegionReplaceIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, calc_templates
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -94,6 +95,7 @@ def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut:
|
||||
personnel, role_name, manager_name = row
|
||||
return PersonnelDetailOut(
|
||||
**_to_out(personnel, role_name, manager_name).model_dump(),
|
||||
salary_history_json=personnel.salary_history_json,
|
||||
regions=[
|
||||
PersonnelRegionOut.model_validate(item)
|
||||
for item in personnel_repository.list_regions(session, personnel_id)
|
||||
@ -140,12 +142,18 @@ def create_personnel(
|
||||
phone=payload.phone,
|
||||
hire_date=payload.hire_date,
|
||||
is_probation=payload.is_probation,
|
||||
probation_start_date=payload.probation_start_date,
|
||||
regularization_date=payload.regularization_date,
|
||||
resignation_date=payload.resignation_date,
|
||||
salary_standard=_parse_salary(payload.salary_standard),
|
||||
status=1,
|
||||
remark=payload.remark,
|
||||
)
|
||||
try:
|
||||
personnel_repository.create(session, personnel)
|
||||
from app.services.personnel_salary import save_version
|
||||
|
||||
save_version(personnel, payload.salary_effective_date or payload.hire_date or date.today())
|
||||
out = _to_out(personnel, role.role_name, manager_name)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
@ -186,18 +194,28 @@ def update_personnel(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("人员不存在")
|
||||
personnel = row[0]
|
||||
personnel = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
|
||||
role, manager_name = _validate_refs(
|
||||
session, payload.business_role_id, payload.manager_id, personnel.id
|
||||
)
|
||||
from app.services.personnel_salary import FIELDS, begin_update, save_version
|
||||
|
||||
salary_changed = any(str(getattr(personnel, key)) != str(getattr(payload, key)) for key in FIELDS)
|
||||
effective = payload.salary_effective_date or date.today()
|
||||
history = begin_update(session, personnel, effective) if salary_changed else None
|
||||
personnel.personnel_name = payload.personnel_name
|
||||
personnel.business_role_id = payload.business_role_id
|
||||
personnel.manager_id = payload.manager_id
|
||||
personnel.phone = payload.phone
|
||||
personnel.hire_date = payload.hire_date
|
||||
personnel.is_probation = payload.is_probation
|
||||
personnel.probation_start_date = payload.probation_start_date
|
||||
personnel.regularization_date = payload.regularization_date
|
||||
personnel.resignation_date = payload.resignation_date
|
||||
personnel.salary_standard = _parse_salary(payload.salary_standard)
|
||||
personnel.remark = payload.remark
|
||||
if salary_changed:
|
||||
save_version(personnel, effective, history)
|
||||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||
out = _to_out(personnel, role.role_name, manager_name)
|
||||
session.commit()
|
||||
@ -260,6 +278,7 @@ def set_status(
|
||||
personnel_id: int,
|
||||
status: int,
|
||||
*,
|
||||
effective_date: date | None = None,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> PersonnelOut:
|
||||
@ -284,8 +303,21 @@ def set_status(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("人员不存在")
|
||||
personnel, role_name, manager_name = row
|
||||
_, role_name, manager_name = row
|
||||
personnel = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
|
||||
from app.services.personnel_salary import begin_update, save_version
|
||||
|
||||
effective = effective_date or date.today()
|
||||
history = begin_update(session, personnel, effective)
|
||||
previous_status = personnel.status
|
||||
personnel.status = status
|
||||
if status == 0:
|
||||
personnel.resignation_date = effective
|
||||
else:
|
||||
personnel.resignation_date = None
|
||||
if previous_status == 0:
|
||||
personnel.hire_date = effective
|
||||
save_version(personnel, effective, history)
|
||||
out = _to_out(personnel, role_name, manager_name)
|
||||
session.commit()
|
||||
logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status)
|
||||
@ -381,6 +413,9 @@ def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | Non
|
||||
phone=personnel.phone,
|
||||
hire_date=personnel.hire_date,
|
||||
is_probation=personnel.is_probation,
|
||||
probation_start_date=personnel.probation_start_date,
|
||||
regularization_date=personnel.regularization_date,
|
||||
resignation_date=personnel.resignation_date,
|
||||
salary_standard=(
|
||||
str(personnel.salary_standard) if personnel.salary_standard is not None else None
|
||||
),
|
||||
@ -395,7 +430,7 @@ def _validate_refs(
|
||||
manager_id: int | None,
|
||||
self_id: int | None,
|
||||
) -> tuple[BusinessRole, str | None]:
|
||||
"""新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。
|
||||
"""新选经理须在职且绑定启用的经理计提岗位;原有参考关系保留供核对。
|
||||
|
||||
``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。
|
||||
返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。
|
||||
@ -411,8 +446,18 @@ def _validate_refs(
|
||||
if manager_id == self_id:
|
||||
raise BadRequestError("直属经理不能是自己")
|
||||
manager = personnel_repository.get_by_id(session, manager_id)
|
||||
if manager is None or manager.status != 1:
|
||||
if manager is None:
|
||||
raise BadRequestError("直属经理不存在或已离职")
|
||||
original = session.get(Personnel, self_id) if self_id is not None else None
|
||||
unchanged = original is not None and original.manager_id == manager_id
|
||||
if manager.status != 1 and not unchanged:
|
||||
raise BadRequestError("直属经理不存在或已离职")
|
||||
manager_role = business_role_repository.get_by_id(session, manager.business_role_id)
|
||||
if not unchanged and (
|
||||
manager_role is None or manager_role.status != 1
|
||||
or calc_templates.formula_type_of(session, manager_role.calc_template) != "manager_share"
|
||||
):
|
||||
raise BadRequestError("直属经理须选择在职且使用经理计提模板的启用岗位人员")
|
||||
manager_name = manager.personnel_name
|
||||
return role, manager_name
|
||||
|
||||
|
||||
320
backend/app/services/probation_service.py
Normal file
320
backend/app/services/probation_service.py
Normal file
@ -0,0 +1,320 @@
|
||||
"""试用期计提、缓发和年度结清;支付受原业务起付及平方权重约束。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AdjustmentOrder, PerformanceLedger, ProbationDeferral, ProbationPayment
|
||||
from app.services import param_service
|
||||
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
def payment_state(session, payment, lock=False):
|
||||
if payment.adjustment_order_id:
|
||||
order = session.get(AdjustmentOrder, payment.adjustment_order_id, with_for_update=lock, populate_existing=lock)
|
||||
return "ignored" if order is None or order.status not in (1, 2) else "reserved" if order.status == 1 else "paid"
|
||||
if payment.settlement_id:
|
||||
from app.models import MonthlySettlement
|
||||
|
||||
settlement = session.get(MonthlySettlement, payment.settlement_id, with_for_update=lock, populate_existing=lock)
|
||||
return "ignored" if settlement is None or settlement.status == 4 else "reserved" if settlement.status != 3 else "paid"
|
||||
return "ignored"
|
||||
|
||||
|
||||
def on_date(person, day):
|
||||
if hasattr(person, "salary_history_json"):
|
||||
from app.services.personnel_salary import resolve
|
||||
|
||||
person = resolve(person, day)
|
||||
start = person.probation_start_date
|
||||
end = person.regularization_date
|
||||
if start or end:
|
||||
return (start is None or day >= start) and (end is None or day < end)
|
||||
return person.is_probation == 1
|
||||
|
||||
|
||||
def record(session, ledger):
|
||||
if ledger.is_probation != 1:
|
||||
return
|
||||
if session.scalar(select(ProbationDeferral.id).where(ProbationDeferral.ledger_id == ledger.id)):
|
||||
return
|
||||
event = (ledger.snapshot_json or {}).get("event", {})
|
||||
session.add(
|
||||
ProbationDeferral(
|
||||
ledger_id=ledger.id,
|
||||
personnel_id=ledger.personnel_id,
|
||||
year=str(event.get("probation_year") or ledger.receipt_date.year),
|
||||
month=ledger.settlement_month,
|
||||
accrued=ledger.performance_amount or ZERO,
|
||||
eligible=ledger.payable_amount or ZERO,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def preview(session, month, personnel_id=None, *, lock=False):
|
||||
"""查询不写表;旧试用期台账同样进入缓发查询,历史标记不重分类。"""
|
||||
from app.core.month import validate_month
|
||||
|
||||
validate_month(month)
|
||||
statement = (
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.is_active == 1,
|
||||
PerformanceLedger.is_probation == 1,
|
||||
PerformanceLedger.settlement_month <= month,
|
||||
)
|
||||
.order_by(PerformanceLedger.personnel_id, PerformanceLedger.id)
|
||||
)
|
||||
if personnel_id:
|
||||
statement = statement.where(PerformanceLedger.personnel_id == personnel_id)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
grouped = {}
|
||||
for ledger in session.scalars(statement):
|
||||
event = (ledger.snapshot_json or {}).get("event", {})
|
||||
year = str(event.get("probation_year") or ledger.receipt_date.year)
|
||||
key = ledger.personnel_id, year
|
||||
row = grouped.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": ledger.personnel_id,
|
||||
"personnel_name": ledger.personnel_name,
|
||||
"year": year,
|
||||
"accrued": ZERO,
|
||||
"eligible": ZERO,
|
||||
"paid": ZERO,
|
||||
"reserved": ZERO,
|
||||
"details": [],
|
||||
},
|
||||
)
|
||||
row["accrued"] += ledger.performance_amount or ZERO
|
||||
row["eligible"] += ledger.payable_amount or ZERO
|
||||
row["details"].append(
|
||||
{
|
||||
"ledger_id": ledger.id,
|
||||
"business_no": ledger.business_no,
|
||||
"month": ledger.settlement_month,
|
||||
"receipt_date": str(ledger.receipt_date),
|
||||
"accrued": str(ledger.performance_amount or ZERO),
|
||||
"eligible": str(ledger.payable_amount or ZERO),
|
||||
}
|
||||
)
|
||||
payments = select(ProbationPayment).where(ProbationPayment.month <= month)
|
||||
if lock:
|
||||
payments = payments.with_for_update().execution_options(populate_existing=True)
|
||||
for payment in session.scalars(payments):
|
||||
state = payment_state(session, payment, lock)
|
||||
if state == "ignored":
|
||||
continue
|
||||
key = payment.personnel_id, payment.year
|
||||
if key in grouped:
|
||||
grouped[key][state] += payment.amount
|
||||
result = []
|
||||
for (_, year), row in sorted(grouped.items()):
|
||||
snap = param_service.resolve_snapshot(session, date(int(year), 12, 31))
|
||||
threshold = param_service.to_decimal(Fraction(snap.get("probation.annual.threshold")))
|
||||
entitlement = max(row["accrued"] - threshold, ZERO)
|
||||
available = min(entitlement, max(row["eligible"], ZERO))
|
||||
enabled = month >= year + "-12"
|
||||
delta = available - row["paid"] - row["reserved"] if enabled else ZERO
|
||||
result.append(
|
||||
{
|
||||
**{
|
||||
k: v
|
||||
for k, v in row.items()
|
||||
if k not in ("accrued", "eligible", "paid", "reserved")
|
||||
},
|
||||
"accrued": str(row["accrued"]),
|
||||
"eligible": str(row["eligible"]),
|
||||
"paid": str(row["paid"]),
|
||||
"threshold": str(threshold),
|
||||
"entitlement": str(entitlement),
|
||||
"available": str(available),
|
||||
"reserved": str(row["reserved"]),
|
||||
"remaining": str(max(entitlement - row["paid"], ZERO)),
|
||||
"delta": str(delta),
|
||||
"payment_month": month,
|
||||
"can_settle": enabled,
|
||||
}
|
||||
)
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
result[-1]["fingerprint"] = digest(result[-1])
|
||||
return {
|
||||
"month": month,
|
||||
"rows": result,
|
||||
"rule": "仅累计试用期绩效;超年度门槛部分仍受业务起付和R×R限制;12月及以后月份结清差额。",
|
||||
}
|
||||
|
||||
|
||||
def persist_payments(session, settlement, items):
|
||||
for item in items:
|
||||
for row in (item.snapshot_json or {}).get("probation_settlements", []):
|
||||
amount = Decimal(row["delta"])
|
||||
if amount:
|
||||
session.add(
|
||||
ProbationPayment(
|
||||
settlement_id=settlement.id,
|
||||
personnel_id=item.personnel_id,
|
||||
year=row["year"],
|
||||
month=settlement.settlement_month,
|
||||
amount=amount,
|
||||
snapshot_json=row,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def export(session, month):
|
||||
data = preview(session, month)
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.title = "缓发及年度结清"
|
||||
sheet.append(
|
||||
[
|
||||
"人员",
|
||||
"原年度",
|
||||
"试用期计提",
|
||||
"符合支付条件",
|
||||
"年度门槛",
|
||||
"超额权益",
|
||||
"累计可补发",
|
||||
"历史已补发",
|
||||
"待审核占用",
|
||||
"本月差额",
|
||||
"剩余权益",
|
||||
]
|
||||
)
|
||||
keys = (
|
||||
"personnel_name",
|
||||
"year",
|
||||
"accrued",
|
||||
"eligible",
|
||||
"threshold",
|
||||
"entitlement",
|
||||
"available",
|
||||
"paid",
|
||||
"reserved",
|
||||
"delta",
|
||||
"remaining",
|
||||
)
|
||||
detail = book.create_sheet("原台账明细")
|
||||
detail.append(["人员", "年度", "台账ID", "业务编号", "月份", "到账日", "计提额", "应付变化"])
|
||||
for row in data["rows"]:
|
||||
values = [row[k] for k in keys]
|
||||
sheet.append(values)
|
||||
for cell in sheet[sheet.max_row]:
|
||||
if cell.data_type == "f":
|
||||
cell.data_type = "s"
|
||||
for entry in row["details"]:
|
||||
detail.append(
|
||||
[
|
||||
row["personnel_name"],
|
||||
row["year"],
|
||||
*[
|
||||
entry[k]
|
||||
for k in (
|
||||
"ledger_id",
|
||||
"business_no",
|
||||
"month",
|
||||
"receipt_date",
|
||||
"accrued",
|
||||
"eligible",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
for cell in detail[detail.max_row]:
|
||||
if cell.data_type == "f":
|
||||
cell.data_type = "s"
|
||||
for page in (sheet, detail):
|
||||
page.freeze_panes = "A2"
|
||||
for col in page.columns:
|
||||
page.column_dimensions[col[0].column_letter].width = 22
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def adjustment(session, payload, actor, meta=None):
|
||||
"""月结已生成后只创建当月差额调整;年度余额与调整单原子写入。"""
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import MonthlySettlement, Personnel
|
||||
from app.services import approval_service, audit_service, payroll_months
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.no_generator import next_no
|
||||
from app.services.settlement_service import _wire_adjustment_approval
|
||||
|
||||
month = payroll_months.current_month()
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
|
||||
ensure_payroll_writer(actor)
|
||||
payroll_months.lock_month(session, month, actor)
|
||||
if (
|
||||
session.scalar(
|
||||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||
)
|
||||
is None
|
||||
):
|
||||
raise BadRequestError("本月工资尚未生成,请随月结结清,无需单独调整")
|
||||
person = session.get(Personnel, payload.personnel_id, with_for_update=True)
|
||||
if person is None:
|
||||
raise BadRequestError("人员不存在")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=actor,
|
||||
record=AuditRecord(
|
||||
module="月度工资",
|
||||
action="试用期结清调整",
|
||||
summary=f"人员#{person.id}年度{payload.year}结清差额",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
row = next(
|
||||
(r for r in preview(session, month, person.id, lock=True)["rows"] if r["year"] == payload.year),
|
||||
None,
|
||||
)
|
||||
if row is None or row["fingerprint"] != payload.fingerprint:
|
||||
raise BadRequestError("年度余额已变化,请重新查询预览")
|
||||
amount = Decimal(row["delta"])
|
||||
if not amount:
|
||||
raise BadRequestError("没有待补发或扣回差额")
|
||||
order = AdjustmentOrder(
|
||||
order_no=next_no(session, model=AdjustmentOrder, column_name="order_no", prefix="TZ"),
|
||||
settlement_month=month,
|
||||
adjustment_type="PROBATION",
|
||||
personnel_id=person.id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=1 if amount > 0 else 2,
|
||||
amount=abs(amount),
|
||||
reason=payload.reason,
|
||||
submitter_id=actor.id,
|
||||
submitter_name=actor.username,
|
||||
attribution_json={"probation_year": payload.year, "snapshot": row},
|
||||
)
|
||||
session.add(order)
|
||||
session.flush()
|
||||
if get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3):
|
||||
_wire_adjustment_approval(session, order, actor)
|
||||
else:
|
||||
order.status = 2
|
||||
session.add(
|
||||
ProbationPayment(
|
||||
adjustment_order_id=order.id,
|
||||
personnel_id=person.id,
|
||||
year=payload.year,
|
||||
month=month,
|
||||
amount=amount,
|
||||
snapshot_json=row,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
out = {"order_no": order.order_no, "amount": str(amount), "month": month}
|
||||
session.commit()
|
||||
return out
|
||||
263
backend/app/services/product_completion.py
Normal file
263
backend/app/services/product_completion.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。"""
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import Business, BusinessItem, Product
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.tplus.snapshots import source_records, source_statement
|
||||
from app.services.tplus.workbench import WorkbenchError, digest
|
||||
|
||||
|
||||
def recent_trades(session, source, ids):
|
||||
if not ids:
|
||||
return {}
|
||||
try:
|
||||
lines = list(
|
||||
session.scalars(
|
||||
source_statement(session, source, "shipment_items").where(
|
||||
source_statement_inventory_filter(ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_ids = {
|
||||
str(r.payload_json["source_parent_id"])
|
||||
for r in lines
|
||||
if r.payload_json.get("source_parent_id") is not None
|
||||
}
|
||||
heads = {
|
||||
r.source_id: r for r in source_records(session, source, "shipments", list(parent_ids))
|
||||
}
|
||||
except WorkbenchError:
|
||||
return {}
|
||||
latest = {}
|
||||
for record in lines:
|
||||
raw = record.payload_json
|
||||
head = heads.get(str(raw.get("source_parent_id")))
|
||||
if head is None:
|
||||
continue
|
||||
value = head.payload_json
|
||||
if str(value.get("voucherState")) != "189" or value.get("isCounteractDelivery") not in (
|
||||
None,
|
||||
0,
|
||||
False,
|
||||
"0",
|
||||
):
|
||||
continue
|
||||
day = str(value.get("voucherdate") or "")[:10]
|
||||
try:
|
||||
quantity = Decimal(str(raw.get("quantity")))
|
||||
price = (
|
||||
Decimal(str(raw["taxPrice"]))
|
||||
if raw.get("taxPrice") is not None
|
||||
else Decimal(str(raw["taxAmount"])) / quantity
|
||||
)
|
||||
if not quantity.is_finite() or quantity <= 0 or not price.is_finite() or price < 0:
|
||||
continue
|
||||
except (ValueError, ArithmeticError, TypeError, KeyError):
|
||||
continue
|
||||
key = str(raw.get("idinventory"))
|
||||
order = day, int(head.source_id), int(record.source_id)
|
||||
if not day or (key in latest and order <= latest[key][0]):
|
||||
continue
|
||||
if (
|
||||
quantity > Decimal("999999999999.99")
|
||||
or quantity.normalize().as_tuple().exponent < -2
|
||||
or price > Decimal("9999999999.9999")
|
||||
):
|
||||
# 无法以当前产品档案精度无损保存的记录保持待核对,不截断数量。
|
||||
continue
|
||||
latest[key] = (
|
||||
order,
|
||||
{
|
||||
"quantity": str(quantity),
|
||||
"price": str(price.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)),
|
||||
"date": day,
|
||||
"source_no": head.source_no,
|
||||
"source_id": head.source_id,
|
||||
"line_id": record.source_id,
|
||||
"source_database": source,
|
||||
},
|
||||
)
|
||||
return {key: value[1] for key, value in latest.items()}
|
||||
|
||||
|
||||
def source_statement_inventory_filter(ids):
|
||||
from app.models import TPlusSyncRecord
|
||||
|
||||
return TPlusSyncRecord.payload_json["idinventory"].as_integer().in_([int(v) for v in ids])
|
||||
|
||||
|
||||
def candidates(session, keyword="", page_num=1, page_size=20):
|
||||
condition = BusinessItem.product_id.is_(None)
|
||||
if keyword:
|
||||
condition &= or_(
|
||||
BusinessItem.product_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
)
|
||||
rows = session.execute(
|
||||
select(BusinessItem, Business.business_no, Business.business_date)
|
||||
.join(Business, Business.id == BusinessItem.business_id)
|
||||
.where(condition)
|
||||
.order_by(BusinessItem.id.desc())
|
||||
.offset((page_num - 1) * page_size)
|
||||
.limit(page_size + 1)
|
||||
).all()
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": row.id,
|
||||
"business_no": number,
|
||||
"business_date": str(day) if day else None,
|
||||
"product_name": row.product_name,
|
||||
"spec_model": row.spec_model,
|
||||
"unit": row.unit,
|
||||
"product_category": row.product_category,
|
||||
"quantity": str(row.quantity),
|
||||
"sale_price": str(row.sale_price) if row.sale_price is not None else None,
|
||||
}
|
||||
for row, number, day in rows[:page_size]
|
||||
],
|
||||
"has_more": len(rows) > page_size,
|
||||
"page_num": page_num,
|
||||
}
|
||||
|
||||
|
||||
def extraction_plan(session, ids, *, lock=False):
|
||||
if len(ids) != len(set(ids)):
|
||||
raise BadRequestError("不能重复选择同一业务明细")
|
||||
statement = (
|
||||
select(BusinessItem, Business.business_date)
|
||||
.join(Business, Business.id == BusinessItem.business_id)
|
||||
.where(BusinessItem.id.in_(ids))
|
||||
.order_by(BusinessItem.id)
|
||||
)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
source = session.execute(statement).all()
|
||||
if len(source) != len(ids):
|
||||
raise BadRequestError("所选业务明细不存在或已变化")
|
||||
groups = defaultdict(list)
|
||||
for item, day in source:
|
||||
groups[(item.product_name.strip(), (item.spec_model or "").strip())].append((item, day))
|
||||
rows = []
|
||||
for (name, spec), members in groups.items():
|
||||
target_statement = select(Product).where(
|
||||
Product.product_name == name, Product.spec_model == spec
|
||||
)
|
||||
if lock:
|
||||
target_statement = target_statement.with_for_update().execution_options(
|
||||
populate_existing=True
|
||||
)
|
||||
target = session.scalar(target_statement)
|
||||
units = {m.unit or "" for m, _ in members}
|
||||
categories = {m.product_category for m, _ in members}
|
||||
conflict = "所选明细单位或核算分类不一致" if len(units) > 1 or len(categories) > 1 else None
|
||||
if target and (
|
||||
target.product_category not in categories or (target.unit or "") not in units
|
||||
):
|
||||
conflict = "同名同规格档案的单位或核算分类不同"
|
||||
newest, day = max(
|
||||
members, key=lambda pair: (str(pair[1] or ""), pair[0].business_id, pair[0].id)
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"product_name": name,
|
||||
"spec_model": spec,
|
||||
"unit": newest.unit,
|
||||
"product_category": newest.product_category,
|
||||
"quantity": str(newest.quantity),
|
||||
"sale_price": str(newest.sale_price) if newest.sale_price is not None else None,
|
||||
"item_ids": [m.id for m, _ in members],
|
||||
"target_id": target.id if target else None,
|
||||
"action": "CONFLICT" if conflict else "LINK" if target else "CREATE",
|
||||
"reason": conflict,
|
||||
"already_linked": [m.product_id for m, _ in members],
|
||||
"target_fingerprint": digest(
|
||||
{c.name: getattr(target, c.name) for c in target.__table__.columns}
|
||||
)
|
||||
if target
|
||||
else None,
|
||||
"date": str(day) if day else None,
|
||||
}
|
||||
)
|
||||
return {"rows": rows, "fingerprint": digest(rows)}
|
||||
|
||||
|
||||
def extract(session, payload, actor, meta=None):
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=actor,
|
||||
record=AuditRecord(
|
||||
module="产品管理",
|
||||
action="提取产品",
|
||||
summary=f"从{len(payload.item_ids)}条业务明细提取产品",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
plan = extraction_plan(session, payload.item_ids, lock=True)
|
||||
if payload.fingerprint != plan["fingerprint"]:
|
||||
raise BadRequestError("业务明细或产品档案已变化,请重新预览")
|
||||
for row in plan["rows"]:
|
||||
if row["action"] == "CONFLICT":
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
target = (
|
||||
session.get(Product, row["target_id"])
|
||||
if row["target_id"]
|
||||
else Product(
|
||||
product_name=row["product_name"],
|
||||
spec_model=row["spec_model"],
|
||||
unit=row["unit"],
|
||||
product_category=row["product_category"],
|
||||
stock_quantity=Decimal(row["quantity"]),
|
||||
sale_price=Decimal(row["sale_price"])
|
||||
if row["sale_price"] is not None
|
||||
else None,
|
||||
status=1,
|
||||
quantity_price_manual=1,
|
||||
)
|
||||
)
|
||||
session.add(target)
|
||||
session.flush()
|
||||
row["target_id"] = target.id
|
||||
for item_id in row["item_ids"]:
|
||||
item = session.get(BusinessItem, item_id)
|
||||
# 仅建立档案关联,不更改历史冗余名称、金额或冻结核算资料。
|
||||
if item.product_id is None:
|
||||
item.product_id = target.id
|
||||
elif item.product_id != target.id:
|
||||
raise BadRequestError("业务明细已关联不同产品,请重新核对")
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
row["action"], row["reason"] = "CONFLICT", "并发建立了同名档案,请重新预览"
|
||||
session.commit()
|
||||
return plan
|
||||
|
||||
|
||||
def restore_source(session, product_id, actor, meta=None):
|
||||
product = session.get(Product, product_id, with_for_update=True)
|
||||
if product is None:
|
||||
raise NotFoundError("产品不存在")
|
||||
if not product.source_trade_json:
|
||||
raise BadRequestError("没有可恢复的有效发货来源")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=actor,
|
||||
record=AuditRecord(
|
||||
module="产品管理", action="恢复源值", summary=f"产品#{product_id}恢复最近有效发货参照"
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
product.quantity_price_manual = 0
|
||||
product.stock_quantity = Decimal(product.source_trade_json["quantity"])
|
||||
product.sale_price = Decimal(product.source_trade_json["price"])
|
||||
session.commit()
|
||||
return {"id": product_id, "restored": True}
|
||||
@ -53,13 +53,15 @@ def _to_out(product: Product) -> ProductOut:
|
||||
product_name=product.product_name,
|
||||
spec_model=product.spec_model,
|
||||
unit=product.unit,
|
||||
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
|
||||
sale_price=str(product.sale_price) if product.sale_price is not None else None,
|
||||
stock_quantity=(product.source_trade_json.get("quantity") if product.source_trade_json and not product.quantity_price_manual else str(product.stock_quantity) if product.stock_quantity is not None else None),
|
||||
sale_price=(product.source_trade_json.get("price") if product.source_trade_json and not product.quantity_price_manual else str(product.sale_price) if product.sale_price is not None else None),
|
||||
product_category=product.product_category,
|
||||
status=product.status,
|
||||
remark=product.remark,
|
||||
create_time=product.create_time,
|
||||
update_time=product.update_time,
|
||||
source_trade_json=product.source_trade_json,
|
||||
quantity_price_manual=product.quantity_price_manual or 0,
|
||||
)
|
||||
|
||||
|
||||
@ -152,6 +154,7 @@ def create_product(
|
||||
raise BadRequestError("已存在同名称同规格的产品")
|
||||
|
||||
product = Product(
|
||||
quantity_price_manual=1 if payload.stock_quantity is not None or payload.sale_price is not None else 0,
|
||||
product_name=payload.product_name,
|
||||
spec_model=spec_model,
|
||||
unit=payload.unit,
|
||||
@ -208,6 +211,9 @@ def update_product(
|
||||
product.product_name = payload.product_name
|
||||
product.spec_model = payload.spec_model or ""
|
||||
product.unit = payload.unit
|
||||
old = _to_out(product)
|
||||
if _to_decimal(payload.stock_quantity) != _to_decimal(old.stock_quantity) or _to_decimal(payload.sale_price) != _to_decimal(old.sale_price):
|
||||
product.quantity_price_manual = 1
|
||||
product.stock_quantity = _to_decimal(payload.stock_quantity)
|
||||
product.sale_price = _to_decimal(payload.sale_price)
|
||||
product.product_category = payload.product_category
|
||||
|
||||
56
backend/app/services/rate_rules.py
Normal file
56
backend/app/services/rate_rules.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""正式核算与试算共用计提规则;比例保持 Fraction,产品覆盖存于参数版本。"""
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
|
||||
PERSONAL_FIELDS = ("first", "start", "slope", "floor", "boundary")
|
||||
BASE_CODES = {
|
||||
"first": "salesperson.rate.start",
|
||||
"start": "salesperson.rate.start",
|
||||
"slope": "salesperson.rate.slope",
|
||||
"floor": "salesperson.rate.floor",
|
||||
"boundary": "salesperson.days.boundary",
|
||||
}
|
||||
|
||||
|
||||
def personal_values(snapshot, category):
|
||||
"""未维护产品专属项时,继续使用同日期的通用参数,不复制初始值。"""
|
||||
result = {}
|
||||
for field in PERSONAL_FIELDS:
|
||||
code = f"coefficient.personal.{field}"
|
||||
overrides = snapshot._values.get(code, {})
|
||||
result[field] = overrides.get(
|
||||
category, overrides.get("", snapshot.get(BASE_CODES[field], role_code="salesperson"))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def decide(params, *, category, customer_type, role_code, days, prepayment=False):
|
||||
"""返回纯比例和计算基数类型。经理个人户排除优先于预收/企业户。"""
|
||||
if role_code == "sales_manager" or customer_type == "manager_personal":
|
||||
return Fraction(0), "converted", "经理本人/经理个人户不单笔计提"
|
||||
if role_code == "info_officer":
|
||||
return Fraction(str(params.get("info", "1"))) / 1000, "sales", "信息员按销售额计提"
|
||||
if prepayment:
|
||||
personal = params.get("personal", {}).get(category, params)
|
||||
maximum = max(
|
||||
Fraction(str(personal.get(key, personal["start"])))
|
||||
for key in ("first", "start", "floor")
|
||||
)
|
||||
return maximum / 1000, "converted", "预收发货后按最高比例计提"
|
||||
if customer_type == "enterprise":
|
||||
value = params.get("enterprise_by_category", {}).get(category, params["enterprise"])
|
||||
return Fraction(str(value)) / 1000, "converted", "企业户按产品分类固定计提"
|
||||
values = params.get("personal", {}).get(category, params)
|
||||
if days <= 1:
|
||||
rate = Fraction(str(values.get("first", values["start"]))) / 1000
|
||||
else:
|
||||
rate = formula.salesperson_rate(
|
||||
days,
|
||||
start=Fraction(str(values["start"])),
|
||||
slope=Fraction(str(values["slope"])),
|
||||
floor=Fraction(str(values["floor"])),
|
||||
boundary_days=int(Fraction(str(values.get("boundary", 360)))),
|
||||
)
|
||||
return rate, "converted", "个人户按产品分类及回款阶段计提"
|
||||
@ -135,6 +135,17 @@ def build_workbook(
|
||||
operator_name=operator_name,
|
||||
)
|
||||
buffer = BytesIO()
|
||||
if department.get("salary_rows"):
|
||||
salary = workbook.create_sheet("工资分项快照")
|
||||
salary.append(["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"])
|
||||
for row in department["salary_rows"]:
|
||||
salary.append([row["personnel_name"], *[Decimal(row[key]) if row.get(key) is not None else None for key in
|
||||
("basic_amount", "normal_payable_sum", "deferred_sum", "probation_payment_sum", "payable_sum", "net_amount")]])
|
||||
if salary.cell(salary.max_row, 1).data_type == "f":
|
||||
salary.cell(salary.max_row, 1).data_type = "s"
|
||||
salary.freeze_panes = "A2"
|
||||
for column in salary.columns:
|
||||
salary.column_dimensions[column[0].column_letter].width = 24
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@ -206,8 +206,13 @@ def department_summary(session, query) -> dict:
|
||||
company_share_amount = _company_share(company_base, share.company_pct)
|
||||
company_share_count = sum(1 for team in teams if team["has_aggregate"])
|
||||
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
|
||||
from app.services.salary_completion import frozen_rows
|
||||
|
||||
visible_people = {member["personnel_id"] for team in teams for member in team["members"]} | {team["manager_id"] for team in teams}
|
||||
salary_rows = frozen_rows(session, month, visible_people if query.manager_id is not None else None)
|
||||
return {
|
||||
"settlement_month": month,
|
||||
"salary_rows": salary_rows,
|
||||
"params": {
|
||||
"team_text": share.team_text,
|
||||
"company_text": share.company_text,
|
||||
|
||||
190
backend/app/services/salary_completion.py
Normal file
190
backend/app/services/salary_completion.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""完整工资按人汇总:基本工资只计一次,试用期缓发及年度释放分列。"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import BusinessRole, Personnel, ProbationPayment, SettlementItem
|
||||
from app.services import formula, probation_service
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
def build_items(session, rows, month):
|
||||
from app.services import personnel_salary
|
||||
from app.services.settlement_service import _salary_mode_of
|
||||
|
||||
end = month_end(month)
|
||||
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
|
||||
people = {
|
||||
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
|
||||
}
|
||||
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
|
||||
profiles = {pid: personnel_salary.resolve(person, end) for pid, person in people.items()}
|
||||
buckets = {}
|
||||
|
||||
def bucket(person):
|
||||
return buckets.setdefault(
|
||||
person.id,
|
||||
{
|
||||
"person": person,
|
||||
"performance": ZERO,
|
||||
"normal": ZERO,
|
||||
"deferred": ZERO,
|
||||
"ledger_ids": [],
|
||||
"annual": [],
|
||||
"details": [],
|
||||
},
|
||||
)
|
||||
|
||||
for person in people.values():
|
||||
if (
|
||||
personnel_salary.employed(person, profiles[person.id], end)
|
||||
and profiles[person.id].salary_standard is not None
|
||||
):
|
||||
bucket(person)
|
||||
for row in rows:
|
||||
person = people.get(row.personnel_id)
|
||||
if person is None:
|
||||
continue
|
||||
item = bucket(person)
|
||||
item["performance"] += row.performance_amount or ZERO
|
||||
key = "deferred" if row.is_probation == 1 else "normal"
|
||||
item[key] += row.payable_amount or ZERO
|
||||
item["ledger_ids"].append(row.id)
|
||||
item["details"].append(
|
||||
{
|
||||
"ledger_id": row.id,
|
||||
"calc_type": row.calc_type,
|
||||
"performance": str(row.performance_amount or ZERO),
|
||||
"payable": str(row.payable_amount or ZERO),
|
||||
}
|
||||
)
|
||||
probation_service.record(session, row)
|
||||
session.flush()
|
||||
preview = probation_service.preview(session, month, lock=True)
|
||||
for annual in preview["rows"]:
|
||||
person = people.get(annual["personnel_id"])
|
||||
if person is None or not Decimal(annual["delta"]):
|
||||
continue
|
||||
later = session.scalars(
|
||||
select(ProbationPayment).where(
|
||||
ProbationPayment.personnel_id == person.id,
|
||||
ProbationPayment.year == annual["year"],
|
||||
ProbationPayment.month > month,
|
||||
)
|
||||
)
|
||||
if any(probation_service.payment_state(session, payment, True) != "ignored" for payment in later):
|
||||
from app.core.exceptions import BadRequestError
|
||||
|
||||
raise BadRequestError("该年度已有后续月份结清记录,请按月份顺序处理差额")
|
||||
bucket(person)["annual"].append(annual)
|
||||
result = []
|
||||
for pid, values in sorted(buckets.items()):
|
||||
person = values["person"]
|
||||
profile = profiles[pid]
|
||||
role = roles.get(profile.business_role_id, "salesperson")
|
||||
mode = _salary_mode_of(session, role, month)
|
||||
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
|
||||
normal = values["normal"]
|
||||
basic = profile.salary_standard if personnel_salary.employed(person, profile, end) else ZERO
|
||||
trial = probation_service.on_date(profile, end)
|
||||
net = (
|
||||
((basic or ZERO) + normal + annual)
|
||||
if trial
|
||||
else formula.monthly_salary(normal + annual, basic, mode)
|
||||
)
|
||||
result.append(
|
||||
SettlementItem(
|
||||
personnel_id=pid,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role,
|
||||
calc_type=2 if role == "sales_manager" else 1,
|
||||
performance_sum=values["performance"].quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
),
|
||||
payable_sum=(normal + annual).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
normal_payable_sum=normal,
|
||||
deferred_sum=values["deferred"],
|
||||
probation_payment_sum=annual,
|
||||
basic_amount=basic,
|
||||
salary_mode="INCLUDE" if trial else mode,
|
||||
net_amount=net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if net is not None
|
||||
else None,
|
||||
is_probation=int(trial),
|
||||
probation_note="试用期绩效进入缓发台账;本月仅发基本工资及符合条件的年度补发"
|
||||
if trial
|
||||
else None,
|
||||
snapshot_json={
|
||||
"ledger_ids": values["ledger_ids"],
|
||||
"calculation_details": values["details"],
|
||||
"probation_settlements": values["annual"],
|
||||
},
|
||||
)
|
||||
)
|
||||
return result, [row.id for row in rows]
|
||||
|
||||
|
||||
def frozen_rows(session, month, personnel_ids=None):
|
||||
from app.models import MonthlySettlement
|
||||
|
||||
statement = (
|
||||
select(SettlementItem)
|
||||
.join(MonthlySettlement)
|
||||
.where(MonthlySettlement.settlement_month == month)
|
||||
)
|
||||
if personnel_ids is not None:
|
||||
statement = statement.where(SettlementItem.personnel_id.in_(personnel_ids))
|
||||
return [
|
||||
{
|
||||
"personnel_id": item.personnel_id,
|
||||
"personnel_name": item.personnel_name,
|
||||
"basic_amount": str(item.basic_amount) if item.basic_amount is not None else None,
|
||||
"normal_payable_sum": str(item.normal_payable_sum),
|
||||
"deferred_sum": str(item.deferred_sum),
|
||||
"probation_payment_sum": str(item.probation_payment_sum),
|
||||
"payable_sum": str(item.payable_sum),
|
||||
"net_amount": str(item.net_amount) if item.net_amount is not None else None,
|
||||
}
|
||||
for item in session.scalars(statement)
|
||||
]
|
||||
|
||||
|
||||
def export_settlement(session, month):
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.title = "工资分项"
|
||||
sheet.append(
|
||||
["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"]
|
||||
)
|
||||
for row in frozen_rows(session, month):
|
||||
sheet.append(
|
||||
[
|
||||
row["personnel_name"],
|
||||
*[
|
||||
Decimal(row[key]) if row[key] is not None else None
|
||||
for key in (
|
||||
"basic_amount",
|
||||
"normal_payable_sum",
|
||||
"deferred_sum",
|
||||
"probation_payment_sum",
|
||||
"payable_sum",
|
||||
"net_amount",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
if sheet.cell(sheet.max_row, 1).data_type == "f":
|
||||
sheet.cell(sheet.max_row, 1).data_type = "s"
|
||||
sheet.freeze_panes = "A2"
|
||||
for col in sheet.columns:
|
||||
sheet.column_dimensions[col[0].column_letter].width = 24
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return output.getvalue()
|
||||
@ -90,7 +90,9 @@ def generate(
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
if not ledger_rows:
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||
|
||||
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||
@ -98,6 +100,11 @@ def generate(
|
||||
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||
|
||||
settlement_repository.replace_items(session, settlement.id, items)
|
||||
if not items:
|
||||
raise BadRequestError(f"{month} 无可发薪人员或核算记录")
|
||||
from app.services.probation_service import persist_payments
|
||||
|
||||
persist_payments(session, settlement, items)
|
||||
_apply_totals(settlement, items)
|
||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||
@ -156,6 +163,11 @@ def _release_previous_round(
|
||||
"""
|
||||
if existing is None:
|
||||
return
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models import ProbationPayment
|
||||
|
||||
session.execute(delete(ProbationPayment).where(ProbationPayment.settlement_id == existing.id))
|
||||
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||
|
||||
@ -182,6 +194,12 @@ def _build_items(
|
||||
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||
) -> tuple[list[SettlementItem], list[int]]:
|
||||
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
from app.services.salary_completion import build_items
|
||||
|
||||
return build_items(session, ledger_rows + aggregate_rows, month)
|
||||
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||
@ -420,6 +438,7 @@ def _aggregate_row(
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
from app.services.probation_service import on_date
|
||||
|
||||
amount = manager_team_share(
|
||||
bucket["performance"],
|
||||
@ -449,7 +468,7 @@ def _aggregate_row(
|
||||
barter_applied=0,
|
||||
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
settlement_month=share.month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
is_probation=int(on_date(manager, share.param_date)),
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
|
||||
@ -148,7 +148,9 @@ def _cycle_current(session, state):
|
||||
|
||||
def tick(session, *, settings=None, now=None):
|
||||
settings, now = settings or get_settings(), now or local_now()
|
||||
if not settings.t_plus_automation_enabled:
|
||||
from app.services.tplus.automation_config import configuration
|
||||
|
||||
if not configuration(session, settings)["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}
|
||||
@ -243,10 +245,22 @@ def overview(session, *, settings=None, now=None):
|
||||
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": []}
|
||||
from app.services.tplus.automation_config import configuration
|
||||
|
||||
base.update(configuration(session, settings))
|
||||
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:
|
||||
database = task_service.history_source_database(session)
|
||||
if database:
|
||||
base["last_success_at"] = session.scalar(
|
||||
select(func.max(TPlusSyncBatch.finished_at)).where(
|
||||
TPlusSyncBatch.source_database == database,
|
||||
TPlusSyncBatch.status == "SUCCEEDED",
|
||||
TPlusSyncBatch.sync_mode == "FULL",
|
||||
)
|
||||
)
|
||||
return base
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
|
||||
base["alerts"] = [{"code": "MIGRATION_REQUIRED", "message": "运行状态结构尚未升级"}]
|
||||
@ -265,7 +279,7 @@ def overview(session, *, settings=None, now=None):
|
||||
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 base["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":
|
||||
|
||||
68
backend/app/services/tplus/automation_config.py
Normal file
68
backend/app/services/tplus/automation_config.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""源级自动采集设置;页面、周期维护共用,不修改进程环境配置。"""
|
||||
|
||||
from sqlalchemy import inspect, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.models import TPlusAutomationConfig
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus import task_service
|
||||
|
||||
|
||||
def configuration(session, settings):
|
||||
from app.services.tplus.automation import source_identity
|
||||
|
||||
result = {"enabled": settings.t_plus_automation_enabled, "version": 0,
|
||||
"editable": False, "configuration_origin": "ENVIRONMENT"}
|
||||
try:
|
||||
key, _database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
return result
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
|
||||
return result
|
||||
result["editable"] = True
|
||||
row = session.scalar(select(TPlusAutomationConfig).where(
|
||||
TPlusAutomationConfig.source_key == key
|
||||
).execution_options(populate_existing=True))
|
||||
if row is not None:
|
||||
result.update(enabled=bool(row.enabled), version=row.version, configuration_origin="SAVED")
|
||||
return result
|
||||
|
||||
|
||||
def save(session, payload, actor):
|
||||
from app.services.tplus.automation import source_identity
|
||||
|
||||
require_permission(session, actor.user, "tplus:collect")
|
||||
settings = task_service.get_settings()
|
||||
try:
|
||||
key, database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
raise BadRequestError("请先配置可用的 T+ 数据源") from None
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
|
||||
raise BadRequestError("自动采集设置尚未升级,请联系维护人员")
|
||||
data = {"enabled": payload.enabled, "expected_version": payload.expected_version}
|
||||
with task_service._audit(session, actor, "修改自动采集", data):
|
||||
row = session.scalar(select(TPlusAutomationConfig).where(TPlusAutomationConfig.source_key == key))
|
||||
if row is None:
|
||||
if payload.expected_version != 0:
|
||||
raise ConflictError()
|
||||
session.add(TPlusAutomationConfig(source_key=key, source_database=database,
|
||||
enabled=int(payload.enabled), version=1, updated_by=actor.user.id))
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise ConflictError() from None
|
||||
else:
|
||||
changed = session.execute(update(TPlusAutomationConfig).where(
|
||||
TPlusAutomationConfig.source_key == key,
|
||||
TPlusAutomationConfig.version == payload.expected_version,
|
||||
).values(enabled=int(payload.enabled), version=payload.expected_version + 1,
|
||||
updated_by=actor.user.id))
|
||||
if changed.rowcount != 1:
|
||||
session.rollback()
|
||||
raise ConflictError()
|
||||
session.commit()
|
||||
from app.services.tplus.automation import overview
|
||||
|
||||
return overview(session, settings=settings)
|
||||
@ -22,8 +22,8 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.services import payroll_months
|
||||
from app.services.authorization_service import require_permission
|
||||
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
|
||||
@ -367,13 +367,13 @@ def _create(session, run, row, frozen):
|
||||
|
||||
def execute_business(session, run):
|
||||
operator = session.get(User, run.requested_by)
|
||||
ensure_payroll_writer(operator)
|
||||
require_permission(session, operator, "tplus:import" if run.kind == "BUSINESS_APPLY" else "tplus:preview")
|
||||
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)
|
||||
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator, require_maker=False)
|
||||
rows, frozen, fingerprint = _build(session, request)
|
||||
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
|
||||
raise WorkbenchError("PREVIEW_STALE")
|
||||
|
||||
@ -10,6 +10,7 @@ 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.rate_rules import personal_values
|
||||
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
|
||||
@ -20,11 +21,24 @@ def _parameters(session, days):
|
||||
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"),
|
||||
"parameter_snapshot_hash": digest([
|
||||
(code, dimension, value, str(snap._effective.get((code, dimension))))
|
||||
for code, dimension, value in sorted(snap.iter_items())
|
||||
]),
|
||||
"start": snap.get("salesperson.rate.start", role_code="salesperson"),
|
||||
"slope": snap.get("salesperson.rate.slope", role_code="salesperson"),
|
||||
"floor": snap.get("salesperson.rate.floor", role_code="salesperson"),
|
||||
"boundary": snap.integer("salesperson.days.boundary", role_code="salesperson"),
|
||||
"enterprise": snap.get("enterprise.rate"),
|
||||
"enterprise_by_category": {
|
||||
category: snap.get("enterprise.rate", product_category=category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
},
|
||||
"info": snap.get("info.rate", role_code="info_officer"),
|
||||
"personal": {
|
||||
category: personal_values(snap, category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
},
|
||||
"thresholds": {
|
||||
category: snap.get("product.threshold", product_category=category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
|
||||
@ -4,6 +4,7 @@ from sqlalchemy.exc import TimeoutError as PoolTimeoutError
|
||||
|
||||
TRANSIENT_CODES = frozenset({"SOURCE_CONNECTION_LOST", "SOURCE_TIMEOUT"})
|
||||
MESSAGES = {
|
||||
"TASK_AUTHORIZATION_REVOKED": "请求账号已停用或权限已撤销,请重新授权后创建任务",
|
||||
"SOURCE_CONNECTION_LOST": "源连接中断,请检查网络和只读连接",
|
||||
"SOURCE_TIMEOUT": "源连接或查询超时",
|
||||
"SOURCE_AUTH_ERROR": "源账号认证失败,请核对只读账号配置",
|
||||
@ -15,6 +16,10 @@ MESSAGES = {
|
||||
|
||||
|
||||
def classify_source_error(exc, fallback="READ_ERROR"):
|
||||
from app.core.exceptions import ForbiddenError
|
||||
|
||||
if isinstance(exc, ForbiddenError):
|
||||
return "TASK_AUTHORIZATION_REVOKED"
|
||||
if isinstance(exc, (TimeoutError, PoolTimeoutError)):
|
||||
return "SOURCE_TIMEOUT"
|
||||
if isinstance(exc, ModuleNotFoundError):
|
||||
|
||||
@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
from app.services.rate_rules import decide
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
ZERO = Decimal("0")
|
||||
@ -110,20 +111,12 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
)
|
||||
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"]),
|
||||
)
|
||||
rate, basis, rule = decide(
|
||||
rate_params, category=category, customer_type=customer_type,
|
||||
role_code="sales_manager" if manager_own else person["role_code"],
|
||||
days=days, prepayment=is_prepayment,
|
||||
)
|
||||
calculation_base = matched if basis == "sales" else converted
|
||||
return {
|
||||
"status": "READY",
|
||||
"reasons": [],
|
||||
@ -145,7 +138,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
+ "%",
|
||||
"parameters": rate_params,
|
||||
"parameter_date": parameter_date.isoformat(),
|
||||
"performance": amount_text(formula.performance_amount(converted, rate)),
|
||||
"performance": amount_text(formula.performance_amount(calculation_base, rate)),
|
||||
"calculation_base": amount_text(calculation_base),
|
||||
"basis": basis,
|
||||
"rule": rule,
|
||||
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -32,7 +33,7 @@ def _target_snapshot(target):
|
||||
return {c.name: getattr(target, c.name) for c in target.__table__.columns}
|
||||
|
||||
|
||||
def _master_values(entity, record, facts, session):
|
||||
def _master_values(entity, record, facts, session, trades=None):
|
||||
row = record.payload_json
|
||||
if entity == "personnel":
|
||||
code = str(row.get("code") or "").strip()
|
||||
@ -59,11 +60,15 @@ def _master_values(entity, record, facts, session):
|
||||
unit = row.get("unit_name")
|
||||
if not unit or unit != fact.data_json["unit"]:
|
||||
return None, "PRODUCT_UNIT_PENDING"
|
||||
trade = (trades or {}).get(record.source_id)
|
||||
return {
|
||||
"product_name": row["name"],
|
||||
"spec_model": row.get("specification") or "",
|
||||
"unit": unit,
|
||||
"product_category": fact.data_json["category"],
|
||||
"source_trade_json": trade,
|
||||
"stock_quantity": Decimal(trade["quantity"]) if trade else None,
|
||||
"sale_price": Decimal(trade["price"]) if trade else None,
|
||||
}, None
|
||||
if row.get("partnerType") != CUSTOMER_ONLY:
|
||||
return None, "NOT_CUSTOMER_ONLY"
|
||||
@ -77,12 +82,14 @@ def _master_values(entity, record, facts, session):
|
||||
}, None
|
||||
|
||||
|
||||
def _protected_update(entity, values):
|
||||
def _protected_update(entity, values, target=None):
|
||||
allowed = {
|
||||
"personnel": {"personnel_name"},
|
||||
"products": {"product_name", "spec_model", "unit"},
|
||||
"products": {"product_name", "spec_model", "unit", "source_trade_json"},
|
||||
"customers": {"company_name"},
|
||||
}[entity]
|
||||
if entity == "products" and target is not None and not target.quantity_price_manual:
|
||||
allowed = allowed | {"stock_quantity", "sale_price"}
|
||||
return {k: v for k, v in values.items() if k in allowed}
|
||||
|
||||
|
||||
@ -121,6 +128,9 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
records = source_records(
|
||||
session, run.source_database, entity, run.input_json.get("source_ids") or None
|
||||
)
|
||||
from app.services.product_completion import recent_trades
|
||||
|
||||
trades = recent_trades(session, run.source_database, [r.source_id for r in records]) if entity == "products" else {}
|
||||
for record in records:
|
||||
link = links.get((entity, record.source_id))
|
||||
target = (
|
||||
@ -133,7 +143,7 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
if link
|
||||
else None
|
||||
)
|
||||
values, reason = _master_values(entity, record, facts, session)
|
||||
values, reason = _master_values(entity, record, facts, session, trades)
|
||||
action = "PENDING" if reason else "CREATE"
|
||||
if reason in ("NOT_IN_PERSONNEL_ROSTER", "NOT_CUSTOMER_ONLY"):
|
||||
action = "SKIP"
|
||||
@ -141,7 +151,7 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
action, reason = "CONFLICT", "TARGET_MISSING"
|
||||
elif values:
|
||||
if target:
|
||||
values = _protected_update(entity, values)
|
||||
values = _protected_update(entity, values, target)
|
||||
action = (
|
||||
"UPDATE"
|
||||
if any(getattr(target, k) != v for k, v in values.items())
|
||||
|
||||
67
backend/app/services/tplus/progress.py
Normal file
67
backend/app/services/tplus/progress.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""批次读取进度元数据;只暴露类别和数量,不携带业务原始载荷。"""
|
||||
import copy
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
|
||||
ENTITIES = frozenset(mapping.entity_type for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS))
|
||||
|
||||
|
||||
def write(batch, *, phase=None, totals=None, active=None, read=None, done=None, failed=None):
|
||||
report = copy.deepcopy(batch.audit_result_json or {})
|
||||
value = report.setdefault("progress", {"phase": "COUNTING", "entities": {}})
|
||||
if phase:
|
||||
value["phase"] = phase
|
||||
if totals is not None:
|
||||
value["total_records"] = sum(totals.values())
|
||||
for entity, count in totals.items():
|
||||
value["entities"][entity] = {"total": count, "processed": 0, "status": "WAITING"}
|
||||
if active is not None:
|
||||
value["active_entity"] = active
|
||||
value["entities"].setdefault(active, {"total": None, "processed": 0})["status"] = "READING"
|
||||
if read is not None and value.get("active_entity"):
|
||||
value["entities"][value["active_entity"]]["processed"] = read
|
||||
if done:
|
||||
value["entities"][done]["status"] = "COMPLETED"
|
||||
if failed:
|
||||
value["entities"].setdefault(failed, {"total": None, "processed": 0})["status"] = "FAILED"
|
||||
value["updated_at"] = datetime.now().isoformat()
|
||||
batch.audit_result_json = report
|
||||
|
||||
|
||||
def public(batch, report=None):
|
||||
"""旧任务总量未知时不给百分比;成功必须以批次终态为准。"""
|
||||
report = report or batch.__dict__.get("audit_result_json") or {}
|
||||
value = report.get("progress") or {}
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
total = value.get("total_records")
|
||||
total = total if isinstance(total, int) and not isinstance(total, bool) and total >= 0 else None
|
||||
processed = batch.source_record_count
|
||||
status = batch.status
|
||||
percentage = None
|
||||
if status == "SUCCEEDED":
|
||||
total, percentage = processed, 100.0
|
||||
elif total and processed >= 0:
|
||||
percentage = min(99.9, round(processed * 100 / total, 1))
|
||||
entities = []
|
||||
observed = report.get("observed_entities", [])
|
||||
for entity in batch.entity_types_json or []:
|
||||
if not isinstance(entity, str) or entity not in ENTITIES:
|
||||
continue
|
||||
details = value.get("entities", {}).get(entity, {})
|
||||
count = details.get("total")
|
||||
entities.append({"entity_type": entity, "total_records": count,
|
||||
"processed_records": details.get("processed"),
|
||||
"status": details.get("status", "COMPLETED" if entity in observed else "WAITING")})
|
||||
updated = value.get("updated_at")
|
||||
try:
|
||||
updated = datetime.fromisoformat(updated) if updated else None
|
||||
except (ValueError, TypeError):
|
||||
updated = None
|
||||
elapsed = max(0, int(((batch.finished_at or datetime.now()) - batch.started_at).total_seconds())) if batch.started_at else 0
|
||||
phase = "COMPLETED" if status == "SUCCEEDED" else "FAILED" if status in ("FAILED", "PARTIAL") else value.get("phase", status)
|
||||
return {"phase": phase, "total_records": total, "processed_records": processed,
|
||||
"percentage": percentage, "active_entity": value.get("active_entity")
|
||||
if value.get("active_entity") in ENTITIES else None,
|
||||
"updated_at": updated, "elapsed_seconds": elapsed, "entities": entities}
|
||||
@ -10,7 +10,7 @@ from app.schemas.tplus import (
|
||||
TPlusErrorOut,
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus import progress, 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
|
||||
@ -27,7 +27,7 @@ _ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS
|
||||
ERROR_MESSAGES.update(MESSAGES)
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
def batch_out(batch, active_parents: set[str], report=None) -> TPlusBatchOut:
|
||||
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
|
||||
entities = batch.entity_types_json
|
||||
scope_valid = (
|
||||
@ -56,6 +56,7 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
else None,
|
||||
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
|
||||
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
|
||||
progress=progress.public(batch, report),
|
||||
)
|
||||
|
||||
|
||||
@ -67,6 +68,7 @@ def get_batch(session, number: str) -> TPlusBatchDetailOut:
|
||||
out = batch_out(batch, repository.active_retry_parents(session, [number]))
|
||||
return TPlusBatchDetailOut(
|
||||
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
|
||||
if batch.sync_mode == "AUDIT" else None
|
||||
)
|
||||
|
||||
|
||||
@ -74,8 +76,9 @@ def list_batches(session, query):
|
||||
"""分页批次并批量查询重试状态。"""
|
||||
rows, total = repository.list_batches(session, query)
|
||||
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
|
||||
reports = repository.progress_reports(session, [row.batch_no for row in rows])
|
||||
return PageResult[TPlusBatchOut](
|
||||
records=[batch_out(row, active) for row in rows],
|
||||
records=[batch_out(row, active, reports.get(row.batch_no)) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@ -112,17 +115,25 @@ def list_errors(session, number, query):
|
||||
|
||||
def overview(session) -> TPlusOverviewOut:
|
||||
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
|
||||
settings = task_service.get_settings()
|
||||
try:
|
||||
resolve_tplus_settings(task_service.get_settings())
|
||||
resolve_tplus_settings(settings)
|
||||
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
|
||||
except RuntimeError:
|
||||
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
|
||||
configured = False
|
||||
message = (
|
||||
"源读取未开启,仍可查看已有采集记录和资料"
|
||||
if not settings.t_plus_enabled
|
||||
else "T+ 只读配置未就绪,请联系管理员配置"
|
||||
)
|
||||
latest = repository.latest_audit(session)
|
||||
current = repository.current_batch(session)
|
||||
return TPlusOverviewOut(
|
||||
is_configured=configured,
|
||||
configuration_message=message,
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
current_batch=batch_out(current, set()) if current else None,
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
automation=automation_overview(session, settings=task_service.get_settings()),
|
||||
)
|
||||
|
||||
@ -34,6 +34,20 @@ class TPlusReader:
|
||||
def _default_query_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "db" / "queries" / "tplus"
|
||||
|
||||
def count_entities(self, entity_types) -> dict[str, int]:
|
||||
"""只统计选定的白名单范围,保留读取 SQL 的同一源过滤语义。"""
|
||||
entities = tuple(dict.fromkeys(entity_types))
|
||||
for entity in entities:
|
||||
get_mapping(entity)
|
||||
if not entities:
|
||||
return {}
|
||||
statement = text((self.query_dir / "progress" / "counts.sql").read_text(encoding="utf-8")).bindparams(
|
||||
bindparam("entities", expanding=True)
|
||||
)
|
||||
return {row.entity_type: int(row.total_records) for row in self.session.execute(
|
||||
statement, {"entities": entities}
|
||||
)}
|
||||
|
||||
def iter_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""T+ 只读快照同步:批量 staging、幂等去重和失败隔离。"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from itertools import batched
|
||||
@ -11,6 +12,7 @@ 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 import progress
|
||||
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
|
||||
@ -18,6 +20,8 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TPlusSyncService:
|
||||
"""按分页提交原始快照,业务表始终不参与本服务。"""
|
||||
@ -76,6 +80,17 @@ 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]
|
||||
progress.write(batch, phase="COUNTING")
|
||||
self.target_session.commit()
|
||||
try:
|
||||
totals = self.reader.count_entities(batch.entity_types_json)
|
||||
if isinstance(totals, dict) and all(isinstance(v, int) and v >= 0 for v in totals.values()) and set(totals) == set(batch.entity_types_json):
|
||||
progress.write(batch, phase="READING", totals=totals)
|
||||
except Exception as exc:
|
||||
# 总量统计不可用时仍继续读源;页面显示未知总量,不伪造百分比。
|
||||
self.source_session.rollback()
|
||||
logger.warning("T+总量统计暂不可用,继续读取(%s)", type(exc).__name__)
|
||||
self.target_session.commit()
|
||||
interrupted = False
|
||||
for mapping in mappings:
|
||||
try:
|
||||
@ -84,6 +99,7 @@ class TPlusSyncService:
|
||||
self.target_session.rollback()
|
||||
code = classify_source_error(exc)
|
||||
self._record_error(batch, mapping, {}, code, type(exc).__name__)
|
||||
progress.write(batch, failed=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
if code in TRANSIENT_CODES:
|
||||
interrupted = True
|
||||
@ -105,17 +121,23 @@ class TPlusSyncService:
|
||||
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
|
||||
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
||||
batch.finished_at = datetime.now()
|
||||
progress.write(batch, phase="COMPLETED" if batch.status == "SUCCEEDED" else "FAILED")
|
||||
batch.watermark_to = None
|
||||
self.target_session.commit()
|
||||
return batch
|
||||
|
||||
def _read_entity(self, batch: TPlusSyncBatch, mapping: TPlusMapping, size: int) -> None:
|
||||
"""批量写入一类数据;每页提交后即使进程中断也可幂等重跑。"""
|
||||
progress.write(batch, phase="READING", active=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
read = 0
|
||||
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)
|
||||
read += len(chunk)
|
||||
progress.write(batch, read=read)
|
||||
batch.staged_record_count += inserted
|
||||
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
||||
for row, reason in errors:
|
||||
@ -127,6 +149,7 @@ class TPlusSyncService:
|
||||
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
|
||||
)
|
||||
batch.audit_result_json = report
|
||||
progress.write(batch, done=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
|
||||
def _record_error(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user