From 1461a8d113d0c979dc8e3307e422b6595d01e933 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 3 Nov 2026 11:20:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E7=AB=AF):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E4=BD=93=E7=B3=BB=E3=80=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E4=B8=8E=E8=AF=95=E7=94=A8=E6=9C=9F=E8=AE=A1?= =?UTF-8?q?=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行 --- backend/.env.example | 14 +- backend/README.md | 10 + .../versions/22a3b4c5d6e7_authorization.py | 47 ++ .../versions/23b4c5d6e7f8_system_monitor.py | 39 ++ .../24c5d6e7f8a9_requirement_completion.py | 152 +++++ .../25d6e7f8a9b0_monitor_permissions.py | 24 + .../26e7f8a9b0c1_role_authorization.py | 31 + .../27f8a9b0c1d2_tplus_automation_config.py | 21 + .../28a9b0c1d2e3_calc_template_definitions.py | 31 + backend/app/api/deps.py | 55 +- backend/app/api/router.py | 16 +- backend/app/api/v1/auth.py | 8 +- backend/app/api/v1/base/business_roles.py | 4 +- backend/app/api/v1/base/personnel.py | 2 +- backend/app/api/v1/base/products.py | 33 +- backend/app/api/v1/metadata.py | 22 + backend/app/api/v1/payroll.py | 43 ++ backend/app/api/v1/system/authorization.py | 56 ++ backend/app/api/v1/system/business_roles.py | 2 +- backend/app/api/v1/system/calc_templates.py | 70 +++ backend/app/api/v1/system/coefficients.py | 37 ++ backend/app/api/v1/system/monitor.py | 56 ++ backend/app/api/v1/system/tplus.py | 11 +- backend/app/core/config.py | 27 +- backend/app/core/exception_handlers.py | 9 +- backend/app/core/exceptions.py | 7 + backend/app/core/logging.py | 7 +- backend/app/core/monitoring.py | 240 ++++++++ backend/app/main.py | 13 +- backend/app/models/__init__.py | 15 +- backend/app/models/authorization.py | 28 + backend/app/models/calc_template.py | 52 ++ backend/app/models/monitor.py | 79 +++ backend/app/models/personnel.py | 15 +- backend/app/models/probation.py | 81 +++ backend/app/models/product.py | 4 +- backend/app/models/settlement.py | 14 +- backend/app/models/tplus_automation.py | 20 +- .../repositories/calc_template_repository.py | 66 ++ .../app/repositories/monitor_repository.py | 59 ++ backend/app/repositories/tplus_repository.py | 20 +- backend/app/repositories/user_repository.py | 2 +- backend/app/schemas/auth.py | 4 + backend/app/schemas/authorization.py | 45 ++ backend/app/schemas/business.py | 9 + backend/app/schemas/calc_template.py | 82 +++ backend/app/schemas/coefficient.py | 23 + backend/app/schemas/dashboard.py | 17 +- backend/app/schemas/monitor.py | 78 +++ backend/app/schemas/param.py | 2 - backend/app/schemas/personnel.py | 18 +- backend/app/schemas/product.py | 14 + backend/app/schemas/report.py | 1 + backend/app/schemas/role.py | 3 + backend/app/schemas/settlement.py | 4 + backend/app/schemas/tplus.py | 28 + backend/app/services/approval_service.py | 27 +- backend/app/services/auth_service.py | 7 +- .../app/services/authorization_management.py | 171 ++++++ .../app/services/authorization_metadata.py | 101 +++ backend/app/services/authorization_policy.py | 91 +++ backend/app/services/authorization_service.py | 92 +++ backend/app/services/business_role_service.py | 55 +- backend/app/services/business_service.py | 28 +- backend/app/services/calc_service.py | 39 +- backend/app/services/calc_template_service.py | 195 ++++++ backend/app/services/calc_templates.py | 173 ++++-- backend/app/services/coefficient_service.py | 330 ++++++++++ backend/app/services/cost_rules.py | 14 + backend/app/services/dashboard_service.py | 70 ++- backend/app/services/monitor_collector.py | 477 +++++++++++++++ backend/app/services/monitor_service.py | 198 ++++++ backend/app/services/param_registry.py | 60 +- backend/app/services/param_service.py | 80 ++- backend/app/services/payroll_access.py | 18 +- backend/app/services/payroll_calculation.py | 26 +- backend/app/services/payroll_months.py | 9 +- backend/app/services/permission_catalog.py | 79 +++ backend/app/services/personnel_salary.py | 84 +++ backend/app/services/personnel_service.py | 59 +- backend/app/services/probation_service.py | 320 ++++++++++ backend/app/services/product_completion.py | 263 ++++++++ backend/app/services/product_service.py | 10 +- backend/app/services/rate_rules.py | 56 ++ backend/app/services/report_export.py | 11 + backend/app/services/report_service.py | 5 + backend/app/services/salary_completion.py | 190 ++++++ backend/app/services/settlement_service.py | 23 +- backend/app/services/tplus/automation.py | 18 +- .../app/services/tplus/automation_config.py | 68 +++ backend/app/services/tplus/business_import.py | 6 +- .../app/services/tplus/calculation_preview.py | 22 +- backend/app/services/tplus/errors.py | 5 + backend/app/services/tplus/fifo.py | 26 +- backend/app/services/tplus/master_import.py | 20 +- backend/app/services/tplus/progress.py | 67 ++ backend/app/services/tplus/query_service.py | 21 +- backend/app/services/tplus/reader.py | 14 + backend/app/services/tplus/sync_service.py | 23 + backend/app/services/tplus/task_service.py | 30 +- backend/app/services/tplus/workbench.py | 56 +- backend/app/services/tplus/worker.py | 6 + backend/app/services/user_service.py | 12 + backend/pyproject.toml | 1 + backend/tests/README.md | 57 ++ backend/tests/conftest.py | 9 +- backend/tests/integration/conftest.py | 25 +- .../tests/integration/test_approval_flow.py | 12 +- .../tests/integration/test_authorization.py | 255 ++++++++ backend/tests/integration/test_calc.py | 14 +- backend/tests/integration/test_dashboard.py | 53 +- .../tests/integration/test_formal_payroll.py | 20 +- .../integration/test_param_management.py | 46 +- backend/tests/integration/test_personnel.py | 47 +- backend/tests/integration/test_reports.py | 2 +- .../test_requirement_completion.py | 225 +++++++ .../tests/integration/test_role_management.py | 73 +++ backend/tests/integration/test_schema_sync.py | 6 +- backend/tests/integration/test_settlements.py | 8 +- .../integration/test_tplus_automation.py | 3 + .../integration/test_tplus_sync_console.py | 78 +++ .../tests/integration/test_tplus_workbench.py | 36 ++ backend/tests/unit/test_authorization.py | 165 +++++ .../tests/unit/test_authorization_policy.py | 34 ++ backend/tests/unit/test_dashboard_sync.py | 74 +++ backend/tests/unit/test_formal_payroll.py | 11 +- backend/tests/unit/test_models.py | 6 +- backend/tests/unit/test_monitor_service.py | 297 +++++++++ backend/tests/unit/test_monitoring.py | 71 +++ backend/tests/unit/test_param_registry.py | 61 +- .../tests/unit/test_payroll_workbench_api.py | 8 +- .../tests/unit/test_requirement_completion.py | 320 ++++++++++ backend/tests/unit/test_role_schemas.py | 19 + .../unit/test_tplus_automation_config.py | 94 +++ backend/tests/unit/test_tplus_progress.py | 80 +++ backend/tests/unit/test_tplus_task_service.py | 10 + backend/tests/unit/test_tplus_workbench.py | 109 +++- backend/uv.lock | 18 + db/init/init-dev.sql | 573 +++++++++++++++++- db/migration/V22__authorization.sql | 392 ++++++++++++ db/migration/V23__system_monitor.sql | 58 ++ db/migration/V24__requirement_completion.sql | 66 ++ db/migration/V25__monitor_permissions.sql | 5 + db/migration/V26__role_authorization.sql | 8 + db/migration/V27__tplus_automation_config.sql | 13 + .../V28__calc_template_definitions.sql | Bin 0 -> 2641 bytes db/queries/tplus/progress/counts.sql | 14 + scripts/run_isolated_tests.py | 1 + scripts/windows/README.md | 2 + 149 files changed, 8736 insertions(+), 368 deletions(-) create mode 100644 backend/alembic/versions/22a3b4c5d6e7_authorization.py create mode 100644 backend/alembic/versions/23b4c5d6e7f8_system_monitor.py create mode 100644 backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py create mode 100644 backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py create mode 100644 backend/alembic/versions/26e7f8a9b0c1_role_authorization.py create mode 100644 backend/alembic/versions/27f8a9b0c1d2_tplus_automation_config.py create mode 100644 backend/alembic/versions/28a9b0c1d2e3_calc_template_definitions.py create mode 100644 backend/app/api/v1/metadata.py create mode 100644 backend/app/api/v1/system/authorization.py create mode 100644 backend/app/api/v1/system/calc_templates.py create mode 100644 backend/app/api/v1/system/coefficients.py create mode 100644 backend/app/api/v1/system/monitor.py create mode 100644 backend/app/core/monitoring.py create mode 100644 backend/app/models/authorization.py create mode 100644 backend/app/models/calc_template.py create mode 100644 backend/app/models/monitor.py create mode 100644 backend/app/models/probation.py create mode 100644 backend/app/repositories/calc_template_repository.py create mode 100644 backend/app/repositories/monitor_repository.py create mode 100644 backend/app/schemas/authorization.py create mode 100644 backend/app/schemas/calc_template.py create mode 100644 backend/app/schemas/coefficient.py create mode 100644 backend/app/schemas/monitor.py create mode 100644 backend/app/services/authorization_management.py create mode 100644 backend/app/services/authorization_metadata.py create mode 100644 backend/app/services/authorization_policy.py create mode 100644 backend/app/services/authorization_service.py create mode 100644 backend/app/services/calc_template_service.py create mode 100644 backend/app/services/coefficient_service.py create mode 100644 backend/app/services/cost_rules.py create mode 100644 backend/app/services/monitor_collector.py create mode 100644 backend/app/services/monitor_service.py create mode 100644 backend/app/services/permission_catalog.py create mode 100644 backend/app/services/personnel_salary.py create mode 100644 backend/app/services/probation_service.py create mode 100644 backend/app/services/product_completion.py create mode 100644 backend/app/services/rate_rules.py create mode 100644 backend/app/services/salary_completion.py create mode 100644 backend/app/services/tplus/automation_config.py create mode 100644 backend/app/services/tplus/progress.py create mode 100644 backend/tests/integration/test_authorization.py create mode 100644 backend/tests/integration/test_requirement_completion.py create mode 100644 backend/tests/integration/test_tplus_sync_console.py create mode 100644 backend/tests/unit/test_authorization.py create mode 100644 backend/tests/unit/test_authorization_policy.py create mode 100644 backend/tests/unit/test_dashboard_sync.py create mode 100644 backend/tests/unit/test_monitor_service.py create mode 100644 backend/tests/unit/test_monitoring.py create mode 100644 backend/tests/unit/test_requirement_completion.py create mode 100644 backend/tests/unit/test_tplus_automation_config.py create mode 100644 backend/tests/unit/test_tplus_progress.py create mode 100644 db/migration/V22__authorization.sql create mode 100644 db/migration/V23__system_monitor.sql create mode 100644 db/migration/V24__requirement_completion.sql create mode 100644 db/migration/V25__monitor_permissions.sql create mode 100644 db/migration/V26__role_authorization.sql create mode 100644 db/migration/V27__tplus_automation_config.sql create mode 100644 db/migration/V28__calc_template_definitions.sql create mode 100644 db/queries/tplus/progress/counts.sql diff --git a/backend/.env.example b/backend/.env.example index 6becda2..37349cb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/README.md b/backend/README.md index f144a2c..ed984f6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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)。 diff --git a/backend/alembic/versions/22a3b4c5d6e7_authorization.py b/backend/alembic/versions/22a3b4c5d6e7_authorization.py new file mode 100644 index 0000000..0f37826 --- /dev/null +++ b/backend/alembic/versions/22a3b4c5d6e7_authorization.py @@ -0,0 +1,47 @@ +"""V22 完整角色及个人授权;目录与模板固化于此版本。""" +from alembic import op +import sqlalchemy as sa + +revision: str = "22a3b4c5d6e7" +down_revision: str | None = "21f2a3b4c5d6" +branch_labels = None +depends_on = None + +DDL = ["CREATE TABLE authorization_settings (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, \n\tmaker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', \n\trevision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', \n\tupdated_by BIGINT UNSIGNED COMMENT '最近修改人ID', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_authorization_settings PRIMARY KEY (id), \n\tCONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, \n\tCONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci;", "CREATE TABLE user_permission_overrides (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tuser_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', \n\tpermission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', \n\teffect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', \n\tcreate_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n\tupdate_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, \n\tCONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), \n\tCONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), \n\tCONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, \n\tCONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci;", 'CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id);'] +PERMISSIONS = [('dashboard:view', '首页:查看', 1, None, 1), ('system:users:view', '用户管理:查看', 1, None, 2), ('system:users:create', '用户管理:新增', 2, 'system:users:view', 3), ('system:users:update', '用户管理:编辑', 2, 'system:users:view', 4), ('system:users:delete', '用户管理:删除', 2, 'system:users:view', 5), ('system:users:status', '用户管理:变更状态', 2, 'system:users:view', 6), ('system:users:password', '用户管理:重置密码', 2, 'system:users:view', 7), ('system:roles:view', '角色管理:查看', 1, None, 8), ('system:roles:create', '角色管理:新增', 2, 'system:roles:view', 9), ('system:roles:update', '角色管理:编辑', 2, 'system:roles:view', 10), ('system:roles:delete', '角色管理:删除', 2, 'system:roles:view', 11), ('system:roles:status', '角色管理:变更状态', 2, 'system:roles:view', 12), ('system:authorization:view', '权限与工资制作人:查看', 1, None, 13), ('system:authorization:update', '权限与工资制作人:编辑', 2, 'system:authorization:view', 14), ('system:business_roles:view', '岗位角色:查看', 1, None, 15), ('system:business_roles:create', '岗位角色:新增', 2, 'system:business_roles:view', 16), ('system:business_roles:update', '岗位角色:编辑', 2, 'system:business_roles:view', 17), ('system:business_roles:delete', '岗位角色:删除', 2, 'system:business_roles:view', 18), ('system:business_roles:status', '岗位角色:变更状态', 2, 'system:business_roles:view', 19), ('system:params:view', '计提参数:查看', 1, None, 20), ('system:params:update', '计提参数:编辑', 2, 'system:params:view', 21), ('system:params:withdraw', '计提参数:撤回参数', 2, 'system:params:view', 22), ('system:params:export', '计提参数:导出', 2, 'system:params:view', 23), ('system:params:preview', '计提参数:预览', 2, 'system:params:view', 24), ('system:settings:view', '公司信息:查看', 1, None, 25), ('system:settings:update', '公司信息:编辑', 2, 'system:settings:view', 26), ('system:logs:view', '日志查询:查看', 1, None, 27), ('base:personnel:view', '人员管理:查看', 1, None, 28), ('base:personnel:create', '人员管理:新增', 2, 'base:personnel:view', 29), ('base:personnel:update', '人员管理:编辑', 2, 'base:personnel:view', 30), ('base:personnel:delete', '人员管理:删除', 2, 'base:personnel:view', 31), ('base:personnel:status', '人员管理:变更状态', 2, 'base:personnel:view', 32), ('base:personnel:regions', '人员管理:维护区域', 2, 'base:personnel:view', 33), ('base:personnel:plans', '人员管理:维护计划', 2, 'base:personnel:view', 34), ('base:products:view', '产品管理:查看', 1, None, 35), ('base:products:create', '产品管理:新增', 2, 'base:products:view', 36), ('base:products:update', '产品管理:编辑', 2, 'base:products:view', 37), ('base:products:delete', '产品管理:删除', 2, 'base:products:view', 38), ('base:products:status', '产品管理:变更状态', 2, 'base:products:view', 39), ('base:customers:view', '客户管理:查看', 1, None, 40), ('base:customers:create', '客户管理:新增', 2, 'base:customers:view', 41), ('base:customers:update', '客户管理:编辑', 2, 'base:customers:view', 42), ('base:customers:delete', '客户管理:删除', 2, 'base:customers:view', 43), ('base:customers:rating', '客户管理:客户评级', 2, 'base:customers:view', 44), ('base:customers:ownership', '客户管理:变更归属', 2, 'base:customers:view', 45), ('business:businesses:view', '业务管理:查看', 1, None, 46), ('business:businesses:create', '业务管理:新增', 2, 'business:businesses:view', 47), ('business:businesses:update', '业务管理:编辑', 2, 'business:businesses:view', 48), ('business:businesses:delete', '业务管理:删除', 2, 'business:businesses:view', 49), ('business:businesses:status', '业务管理:变更状态', 2, 'business:businesses:view', 50), ('business:businesses:ownership', '业务管理:变更归属', 2, 'business:businesses:view', 51), ('business:shipments:view', '发货管理:查看', 1, None, 52), ('business:shipments:create', '发货管理:新增', 2, 'business:shipments:view', 53), ('business:shipments:update', '发货管理:编辑', 2, 'business:shipments:view', 54), ('business:shipments:delete', '发货管理:删除', 2, 'business:shipments:view', 55), ('business:receipts:view', '回款管理:查看', 1, None, 56), ('business:receipts:create', '回款管理:新增', 2, 'business:receipts:view', 57), ('business:receipts:update', '回款管理:编辑', 2, 'business:receipts:view', 58), ('business:receipts:delete', '回款管理:删除', 2, 'business:receipts:view', 59), ('business:ratings:view', '评级标准:查看', 1, None, 60), ('business:ratings:create', '评级标准:新增', 2, 'business:ratings:view', 61), ('business:ratings:update', '评级标准:编辑', 2, 'business:ratings:view', 62), ('business:ratings:delete', '评级标准:删除', 2, 'business:ratings:view', 63), ('tplus:view', '采集与导入:查看', 1, None, 64), ('tplus:collect', '采集与导入:创建采集', 2, 'tplus:view', 65), ('tplus:retry', '采集与导入:重试采集', 2, 'tplus:view', 66), ('tplus:preview', '采集与导入:预览', 2, 'tplus:view', 67), ('tplus:import', '采集与导入:执行导入', 2, 'tplus:view', 68), ('tplus:facts', '采集与导入:维护补充资料', 2, 'tplus:view', 69), ('tplus:export', '采集与导入:导出', 2, 'tplus:view', 70), ('payroll:view', '正式核算与分配:查看', 1, None, 71), ('payroll:prepare', '正式核算与分配:准备核算资料', 2, 'payroll:view', 72), ('payroll:calculate', '正式核算与分配:核算与重算', 2, 'payroll:view', 73), ('payroll:allocation', '正式核算与分配:修改工资分配', 2, 'payroll:view', 74), ('settlements:view', '月度工资:查看', 1, None, 75), ('settlements:generate', '月度工资:生成工资', 2, 'settlements:view', 76), ('settlements:submit', '月度工资:提交审核', 2, 'settlements:view', 77), ('adjustments:view', '工资调整:查看', 1, None, 78), ('adjustments:create', '工资调整:新增', 2, 'adjustments:view', 79), ('approvals:view', '审核记录:查看', 1, None, 80), ('approvals:finance', '审核记录:财务复核', 2, 'approvals:view', 81), ('approvals:leader', '审核记录:领导审核', 2, 'approvals:view', 82), ('reports:business:view', '业务统计:查看', 1, None, 83), ('reports:business:export', '业务统计:导出', 2, 'reports:business:view', 84), ('reports:personal:view', '个人明细:查看', 1, None, 85), ('reports:personal:export', '个人明细:导出', 2, 'reports:personal:view', 86), ('reports:department:view', '部门汇总:查看', 1, None, 87), ('reports:department:export', '部门汇总:导出', 2, 'reports:department:view', 88), ('reports:payments:view', '回款统计:查看', 1, None, 89), ('reports:payments:export', '回款统计:导出', 2, 'reports:payments:view', 90)] +DEFAULTS = {'admin': ['adjustments:create', 'adjustments:view', 'approvals:finance', 'approvals:leader', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'dashboard:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'system:authorization:update', 'system:authorization:view', 'system:business_roles:create', 'system:business_roles:delete', 'system:business_roles:status', 'system:business_roles:update', 'system:business_roles:view', 'system:logs:view', 'system:params:export', 'system:params:preview', 'system:params:update', 'system:params:view', 'system:params:withdraw', 'system:roles:create', 'system:roles:delete', 'system:roles:status', 'system:roles:update', 'system:roles:view', 'system:settings:update', 'system:settings:view', 'system:users:create', 'system:users:delete', 'system:users:password', 'system:users:status', 'system:users:update', 'system:users:view', 'tplus:collect', 'tplus:export', 'tplus:facts', 'tplus:import', 'tplus:preview', 'tplus:retry', 'tplus:view'], 'user': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'marketing_ops': ['base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view'], 'finance': ['adjustments:view', 'approvals:finance', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'leader': ['adjustments:view', 'approvals:leader', 'approvals:view', 'base:customers:view', 'base:personnel:view', 'base:products:view', 'business:businesses:view', 'business:ratings:view', 'business:receipts:view', 'business:shipments:view', 'dashboard:view', 'payroll:view', 'reports:business:export', 'reports:business:view', 'reports:department:export', 'reports:department:view', 'reports:payments:export', 'reports:payments:view', 'reports:personal:export', 'reports:personal:view', 'settlements:view', 'system:params:preview', 'system:params:view'], 'payroll_maker': ['adjustments:create', 'adjustments:view', 'approvals:view', 'base:customers:create', 'base:customers:delete', 'base:customers:ownership', 'base:customers:rating', 'base:customers:update', 'base:customers:view', 'base:personnel:create', 'base:personnel:delete', 'base:personnel:plans', 'base:personnel:regions', 'base:personnel:status', 'base:personnel:update', 'base:personnel:view', 'base:products:create', 'base:products:delete', 'base:products:status', 'base:products:update', 'base:products:view', 'business:businesses:create', 'business:businesses:delete', 'business:businesses:ownership', 'business:businesses:status', 'business:businesses:update', 'business:businesses:view', 'business:ratings:create', 'business:ratings:delete', 'business:ratings:update', 'business:ratings:view', 'business:receipts:create', 'business:receipts:delete', 'business:receipts:update', 'business:receipts:view', 'business:shipments:create', 'business:shipments:delete', 'business:shipments:update', 'business:shipments:view', 'payroll:allocation', 'payroll:calculate', 'payroll:prepare', 'payroll:view', 'reports:business:view', 'reports:department:view', 'reports:payments:view', 'reports:personal:view', 'settlements:generate', 'settlements:submit', 'settlements:view', 'tplus:collect', 'tplus:export', 'tplus:facts', 'tplus:import', 'tplus:preview', 'tplus:retry', 'tplus:view']} + +def upgrade(): + bind=op.get_bind() + for sql in DDL: + op.execute(sql) + for code,name,kind,parent,order in PERMISSIONS: + bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),dict(code=code,name=name,kind=kind,order=order)) + for code,_,_,parent,_ in PERMISSIONS: + if parent: + bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),dict(parent=parent,code=code)) + bind.execute(sa.text("INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1)")) + for role,codes in DEFAULTS.items(): + for code in codes: + 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=:role"),dict(code=code,role=role)) + from app.core.config import get_settings + maker=get_settings().payroll_maker_user_id + if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),dict(id=maker)).scalar(): + raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移") + bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),dict(maker=maker)) + if maker is not None: + bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),dict(maker=maker)) + +def downgrade(): + bind=op.get_bind() + if bind.execute(sa.text("SELECT revision FROM authorization_settings WHERE id=1")).scalar()!=1 or bind.execute(sa.text("SELECT COUNT(*) FROM user_permission_overrides")).scalar(): + raise RuntimeError("已发生授权修改,拒绝删除授权依据;请保留数据并回退应用版本") + op.drop_table('user_permission_overrides') + op.drop_table('authorization_settings') + codes=[row[0] for row in PERMISSIONS] + for code in codes: + bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),dict(code=code)) + bind.execute(sa.text("DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE r.role_code='payroll_maker'")) + bind.execute(sa.text("DELETE FROM roles WHERE role_code='payroll_maker'")) + for code in reversed(codes): + bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),dict(code=code)) diff --git a/backend/alembic/versions/23b4c5d6e7f8_system_monitor.py b/backend/alembic/versions/23b4c5d6e7f8_system_monitor.py new file mode 100644 index 0000000..ae06404 --- /dev/null +++ b/backend/alembic/versions/23b4c5d6e7f8_system_monitor.py @@ -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'")) diff --git a/backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py b/backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py new file mode 100644 index 0000000..3502748 --- /dev/null +++ b/backend/alembic/versions/24c5d6e7f8a9_requirement_completion.py @@ -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") diff --git a/backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py b/backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py new file mode 100644 index 0000000..7a51fe6 --- /dev/null +++ b/backend/alembic/versions/25d6e7f8a9b0_monitor_permissions.py @@ -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 diff --git a/backend/alembic/versions/26e7f8a9b0c1_role_authorization.py b/backend/alembic/versions/26e7f8a9b0c1_role_authorization.py new file mode 100644 index 0000000..df032ed --- /dev/null +++ b/backend/alembic/versions/26e7f8a9b0c1_role_authorization.py @@ -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")) diff --git a/backend/alembic/versions/27f8a9b0c1d2_tplus_automation_config.py b/backend/alembic/versions/27f8a9b0c1d2_tplus_automation_config.py new file mode 100644 index 0000000..414a490 --- /dev/null +++ b/backend/alembic/versions/27f8a9b0c1d2_tplus_automation_config.py @@ -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") diff --git a/backend/alembic/versions/28a9b0c1d2e3_calc_template_definitions.py b/backend/alembic/versions/28a9b0c1d2e3_calc_template_definitions.py new file mode 100644 index 0000000..1a9a885 --- /dev/null +++ b/backend/alembic/versions/28a9b0c1d2e3_calc_template_definitions.py @@ -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") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6dd4254..9bc38d3 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 1d63411..1efce7d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index bfc289a..a99f1fd 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, ) diff --git a/backend/app/api/v1/base/business_roles.py b/backend/app/api/v1/base/business_roles.py index ce3d0df..0405f90 100644 --- a/backend/app/api/v1/base/business_roles.py +++ b/backend/app/api/v1/base/business_roles.py @@ -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")]) diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py index d5ae808..a6d9bc6 100644 --- a/backend/app/api/v1/base/personnel.py +++ b/backend/app/api/v1/base/personnel.py @@ -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 "已设为离职") diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py index 20b22da..9dc1094 100644 --- a/backend/app/api/v1/base/products.py +++ b/backend/app/api/v1/base/products.py @@ -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, diff --git a/backend/app/api/v1/metadata.py b/backend/app/api/v1/metadata.py new file mode 100644 index 0000000..a11b429 --- /dev/null +++ b/backend/app/api/v1/metadata.py @@ -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)) diff --git a/backend/app/api/v1/payroll.py b/backend/app/api/v1/payroll.py index b00c972..4c474f7 100644 --- a/backend/app/api/v1/payroll.py +++ b/backend/app/api/v1/payroll.py @@ -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])$")], diff --git a/backend/app/api/v1/system/authorization.py b/backend/app/api/v1/system/authorization.py new file mode 100644 index 0000000..451ac5b --- /dev/null +++ b/backend/app/api/v1/system/authorization.py @@ -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)) diff --git a/backend/app/api/v1/system/business_roles.py b/backend/app/api/v1/system/business_roles.py index 62e9b96..a9a809c 100644 --- a/backend/app/api/v1/system/business_roles.py +++ b/backend/app/api/v1/system/business_roles.py @@ -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="岗位详情") diff --git a/backend/app/api/v1/system/calc_templates.py b/backend/app/api/v1/system/calc_templates.py new file mode 100644 index 0000000..fd01d1f --- /dev/null +++ b/backend/app/api/v1/system/calc_templates.py @@ -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="模板已删除") diff --git a/backend/app/api/v1/system/coefficients.py b/backend/app/api/v1/system/coefficients.py new file mode 100644 index 0000000..41aa54d --- /dev/null +++ b/backend/app/api/v1/system/coefficients.py @@ -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))) diff --git a/backend/app/api/v1/system/monitor.py b/backend/app/api/v1/system/monitor.py new file mode 100644 index 0000000..083a507 --- /dev/null +++ b/backend/app/api/v1/system/monitor.py @@ -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", + }, + ) diff --git a/backend/app/api/v1/system/tplus.py b/backend/app/api/v1/system/tplus.py index a609d97..8b7fad8 100644 --- a/backend/app/api/v1/system/tplus.py +++ b/backend/app/api/v1/system/tplus.py @@ -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()]): """分页查询批次元数据。""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7776ab8..70232be 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py index 0436def..1cda705 100644 --- a/backend/app/core/exception_handlers.py +++ b/backend/app/core/exception_handlers.py @@ -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: diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index cf353e7..24b26f4 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -52,3 +52,10 @@ class NotFoundError(AppException): code = CODE_NOT_FOUND message = "资源不存在" + + +class ConflictError(AppException): + """授权版本已变化,客户端应刷新后重新核对。""" + + code = 409 + message = "配置已被其他人修改,请刷新后重新核对" diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index 65c1e63..64d4dfe 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -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", ) diff --git a/backend/app/core/monitoring.py b/backend/app/core/monitoring.py new file mode 100644 index 0000000..24c61f4 --- /dev/null +++ b/backend/app/core/monitoring.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 847c15a..ca59797 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 52533b2..2314617 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/authorization.py b/backend/app/models/authorization.py new file mode 100644 index 0000000..b54db36 --- /dev/null +++ b/backend/app/models/authorization.py @@ -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") diff --git a/backend/app/models/calc_template.py b/backend/app/models/calc_template.py new file mode 100644 index 0000000..3269b08 --- /dev/null +++ b/backend/app/models/calc_template.py @@ -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自定义", + ) diff --git a/backend/app/models/monitor.py b/backend/app/models/monitor.py new file mode 100644 index 0000000..b2b3c69 --- /dev/null +++ b/backend/app/models/monitor.py @@ -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="恢复时间,仅用于健康异常" + ) diff --git a/backend/app/models/personnel.py b/backend/app/models/personnel.py index 6b7e89d..ce18c01 100644 --- a/backend/app/models/personnel.py +++ b/backend/app/models/personnel.py @@ -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, diff --git a/backend/app/models/probation.py b/backend/app/models/probation.py new file mode 100644 index 0000000..b63bcbb --- /dev/null +++ b/backend/app/models/probation.py @@ -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="创建时间" + ) diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 31f7aba..c36d60a 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -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随源更新") diff --git a/backend/app/models/settlement.py b/backend/app/models/settlement.py index 387c20d..c0bc9e0 100644 --- a/backend/app/models/settlement.py +++ b/backend/app/models/settlement.py @@ -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="个人明细快照(台账行引用列表)" ) diff --git a/backend/app/models/tplus_automation.py b/backend/app/models/tplus_automation.py index f5b452b..aa1650b 100644 --- a/backend/app/models/tplus_automation.py +++ b/backend/app/models/tplus_automation.py @@ -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): diff --git a/backend/app/repositories/calc_template_repository.py b/backend/app/repositories/calc_template_repository.py new file mode 100644 index 0000000..4f22bc0 --- /dev/null +++ b/backend/app/repositories/calc_template_repository.py @@ -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() diff --git a/backend/app/repositories/monitor_repository.py b/backend/app/repositories/monitor_repository.py new file mode 100644 index 0000000..b822690 --- /dev/null +++ b/backend/app/repositories/monitor_repository.py @@ -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) diff --git a/backend/app/repositories/tplus_repository.py b/backend/app/repositories/tplus_repository.py index 789f6df..e5b5d1e 100644 --- a/backend/app/repositories/tplus_repository.py +++ b/backend/app/repositories/tplus_repository.py @@ -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( diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 7aba47b..7594510 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -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: diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index adddc2e..94e9cef 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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): diff --git a/backend/app/schemas/authorization.py b/backend/app/schemas/authorization.py new file mode 100644 index 0000000..c03b72a --- /dev/null +++ b/backend/app/schemas/authorization.py @@ -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__) diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index 63dbb77..f8739ff 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -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。""" diff --git a/backend/app/schemas/calc_template.py b/backend/app/schemas/calc_template.py new file mode 100644 index 0000000..8cfd3ba --- /dev/null +++ b/backend/app/schemas/calc_template.py @@ -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) diff --git a/backend/app/schemas/coefficient.py b/backend/app/schemas/coefficient.py new file mode 100644 index 0000000..5379919 --- /dev/null +++ b/backend/app/schemas/coefficient.py @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 118d88b..55abde6 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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) diff --git a/backend/app/schemas/monitor.py b/backend/app/schemas/monitor.py new file mode 100644 index 0000000..c11a91f --- /dev/null +++ b/backend/app/schemas/monitor.py @@ -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 diff --git a/backend/app/schemas/param.py b/backend/app/schemas/param.py index 4e963dd..63faa60 100644 --- a/backend/app/schemas/param.py +++ b/backend/app/schemas/param.py @@ -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 diff --git a/backend/app/schemas/personnel.py b/backend/app/schemas/personnel.py index b2cfa3f..a98a6f9 100644 --- a/backend/app/schemas/personnel.py +++ b/backend/app/schemas/personnel.py @@ -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): diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index cf44256..c43b0ba 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -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 diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index b457f4b..92caafe 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -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( diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py index e31ad33..f7c0be5 100644 --- a/backend/app/schemas/role.py +++ b/backend/app/schemas/role.py @@ -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 diff --git a/backend/app/schemas/settlement.py b/backend/app/schemas/settlement.py index d620070..5519dc1 100644 --- a/backend/app/schemas/settlement.py +++ b/backend/app/schemas/settlement.py @@ -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): diff --git a/backend/app/schemas/tplus.py b/backend/app/schemas/tplus.py index b79780f..0cfa250 100644 --- a/backend/app/schemas/tplus.py +++ b/backend/app/schemas/tplus.py @@ -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 diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py index bc784db..a4855d3 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index b1d0576..2fca0aa 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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, ), ) diff --git a/backend/app/services/authorization_management.py b/backend/app/services/authorization_management.py new file mode 100644 index 0000000..b939933 --- /dev/null +++ b/backend/app/services/authorization_management.py @@ -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("已取消单独的制作人绑定,请在角色管理配置权限,再在用户管理分配角色") diff --git a/backend/app/services/authorization_metadata.py b/backend/app/services/authorization_metadata.py new file mode 100644 index 0000000..1f0914b --- /dev/null +++ b/backend/app/services/authorization_metadata.py @@ -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] diff --git a/backend/app/services/authorization_policy.py b/backend/app/services/authorization_policy.py new file mode 100644 index 0000000..913c882 --- /dev/null +++ b/backend/app/services/authorization_policy.py @@ -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 + diff --git a/backend/app/services/authorization_service.py b/backend/app/services/authorization_service.py new file mode 100644 index 0000000..66274e4 --- /dev/null +++ b/backend/app/services/authorization_service.py @@ -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 diff --git a/backend/app/services/business_role_service.py b/backend/app/services/business_role_service.py index 43ae38b..1d60907 100644 --- a/backend/app/services/business_role_service.py +++ b/backend/app/services/business_role_service.py @@ -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, diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 7abb11f..68ae676 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -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( diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index f94416c..9e9d5d0 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -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}"} ) diff --git a/backend/app/services/calc_template_service.py b/backend/app/services/calc_template_service.py new file mode 100644 index 0000000..74b4de7 --- /dev/null +++ b/backend/app/services/calc_template_service.py @@ -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 diff --git a/backend/app/services/calc_templates.py b/backend/app/services/calc_templates.py index 9f22044..0798c0f 100644 --- a/backend/app/services/calc_templates.py +++ b/backend/app/services/calc_templates.py @@ -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 diff --git a/backend/app/services/coefficient_service.py b/backend/app/services/coefficient_service.py new file mode 100644 index 0000000..ca210f0 --- /dev/null +++ b/backend/app/services/coefficient_service.py @@ -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() diff --git a/backend/app/services/cost_rules.py b/backend/app/services/cost_rules.py new file mode 100644 index 0000000..c078011 --- /dev/null +++ b/backend/app/services/cost_rules.py @@ -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 diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index eff98b2..e59d60c 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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"], } diff --git a/backend/app/services/monitor_collector.py b/backend/app/services/monitor_collector.py new file mode 100644 index 0000000..e577dfc --- /dev/null +++ b/backend/app/services/monitor_collector.py @@ -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) diff --git a/backend/app/services/monitor_service.py b/backend/app/services/monitor_service.py new file mode 100644 index 0000000..d8f59ad --- /dev/null +++ b/backend/app/services/monitor_service.py @@ -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": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。", + } diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py index 14a747b..423e88a 100644 --- a/backend/app/services/param_registry.py +++ b/backend/app/services/param_registry.py @@ -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) diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index 66fd2ae..fca9dae 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -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: diff --git a/backend/app/services/payroll_access.py b/backend/app/services/payroll_access.py index 2037576..afa2beb 100644 --- a/backend/app/services/payroll_access.py +++ b/backend/app/services/payroll_access.py @@ -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 diff --git a/backend/app/services/payroll_calculation.py b/backend/app/services/payroll_calculation.py index e46f398..94588d7 100644 --- a/backend/app/services/payroll_calculation.py +++ b/backend/app/services/payroll_calculation.py @@ -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: diff --git a/backend/app/services/payroll_months.py b/backend/app/services/payroll_months.py index 73e1b7e..dcfb32b 100644 --- a/backend/app/services/payroll_months.py +++ b/backend/app/services/payroll_months.py @@ -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) diff --git a/backend/app/services/permission_catalog.py b/backend/app/services/permission_catalog.py new file mode 100644 index 0000000..b3bd44b --- /dev/null +++ b/backend/app/services/permission_catalog.py @@ -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, +} diff --git a/backend/app/services/personnel_salary.py b/backend/app/services/personnel_salary.py new file mode 100644 index 0000000..03f4090 --- /dev/null +++ b/backend/app/services/personnel_salary.py @@ -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 diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py index a5809b6..f67f13f 100644 --- a/backend/app/services/personnel_service.py +++ b/backend/app/services/personnel_service.py @@ -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 diff --git a/backend/app/services/probation_service.py b/backend/app/services/probation_service.py new file mode 100644 index 0000000..997a821 --- /dev/null +++ b/backend/app/services/probation_service.py @@ -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 diff --git a/backend/app/services/product_completion.py b/backend/app/services/product_completion.py new file mode 100644 index 0000000..1ed6cad --- /dev/null +++ b/backend/app/services/product_completion.py @@ -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} diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 7b9fa71..4ea91e2 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -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 diff --git a/backend/app/services/rate_rules.py b/backend/app/services/rate_rules.py new file mode 100644 index 0000000..a1689c8 --- /dev/null +++ b/backend/app/services/rate_rules.py @@ -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", "个人户按产品分类及回款阶段计提" diff --git a/backend/app/services/report_export.py b/backend/app/services/report_export.py index b6059cf..cc2d3b1 100644 --- a/backend/app/services/report_export.py +++ b/backend/app/services/report_export.py @@ -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() diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index c2d6f78..e371287 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -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, diff --git a/backend/app/services/salary_completion.py b/backend/app/services/salary_completion.py new file mode 100644 index 0000000..549b612 --- /dev/null +++ b/backend/app/services/salary_completion.py @@ -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() diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index a9db1ba..1990ed2 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -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), diff --git a/backend/app/services/tplus/automation.py b/backend/app/services/tplus/automation.py index c720f77..930528f 100644 --- a/backend/app/services/tplus/automation.py +++ b/backend/app/services/tplus/automation.py @@ -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": diff --git a/backend/app/services/tplus/automation_config.py b/backend/app/services/tplus/automation_config.py new file mode 100644 index 0000000..8da6531 --- /dev/null +++ b/backend/app/services/tplus/automation_config.py @@ -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) diff --git a/backend/app/services/tplus/business_import.py b/backend/app/services/tplus/business_import.py index 9909790..24a315c 100644 --- a/backend/app/services/tplus/business_import.py +++ b/backend/app/services/tplus/business_import.py @@ -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") diff --git a/backend/app/services/tplus/calculation_preview.py b/backend/app/services/tplus/calculation_preview.py index 8430520..faf9c59 100644 --- a/backend/app/services/tplus/calculation_preview.py +++ b/backend/app/services/tplus/calculation_preview.py @@ -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") diff --git a/backend/app/services/tplus/errors.py b/backend/app/services/tplus/errors.py index 150f1fe..042bfe2 100644 --- a/backend/app/services/tplus/errors.py +++ b/backend/app/services/tplus/errors.py @@ -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): diff --git a/backend/app/services/tplus/fifo.py b/backend/app/services/tplus/fifo.py index 9207729..20ce13d 100644 --- a/backend/app/services/tplus/fifo.py +++ b/backend/app/services/tplus/fifo.py @@ -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", } diff --git a/backend/app/services/tplus/master_import.py b/backend/app/services/tplus/master_import.py index 43577ae..c7cb5be 100644 --- a/backend/app/services/tplus/master_import.py +++ b/backend/app/services/tplus/master_import.py @@ -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()) diff --git a/backend/app/services/tplus/progress.py b/backend/app/services/tplus/progress.py new file mode 100644 index 0000000..d867dd5 --- /dev/null +++ b/backend/app/services/tplus/progress.py @@ -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} diff --git a/backend/app/services/tplus/query_service.py b/backend/app/services/tplus/query_service.py index c231483..c206928 100644 --- a/backend/app/services/tplus/query_service.py +++ b/backend/app/services/tplus/query_service.py @@ -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()), ) diff --git a/backend/app/services/tplus/reader.py b/backend/app/services/tplus/reader.py index 08b1305..9b102dd 100644 --- a/backend/app/services/tplus/reader.py +++ b/backend/app/services/tplus/reader.py @@ -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, diff --git a/backend/app/services/tplus/sync_service.py b/backend/app/services/tplus/sync_service.py index 325c46e..f7e08f1 100644 --- a/backend/app/services/tplus/sync_service.py +++ b/backend/app/services/tplus/sync_service.py @@ -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( diff --git a/backend/app/services/tplus/task_service.py b/backend/app/services/tplus/task_service.py index c5e6f48..3567559 100644 --- a/backend/app/services/tplus/task_service.py +++ b/backend/app/services/tplus/task_service.py @@ -5,13 +5,21 @@ from contextlib import nullcontext from dataclasses import dataclass from datetime import datetime +from sqlalchemy import select, union from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.exceptions import BadRequestError, NotFoundError from app.core.tplus_db import resolve_tplus_settings -from app.models import TPlusSyncBatch, TPlusSyncError, User +from app.models import ( + TPlusFact, + TPlusSourceHead, + TPlusSyncBatch, + TPlusSyncError, + TPlusWorkRun, + User, +) from app.repositories import tplus_repository as repository from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn from app.services import audit_service @@ -50,6 +58,26 @@ def _source_database() -> str: raise BadRequestError(str(exc)) from None +def history_source_database(session: Session) -> str | None: + """查询已存资料不要求开启源连接;未配置账套时仅接受唯一历史来源。""" + settings = get_settings() + if settings.t_plus_database.strip(): + return settings.t_plus_database.strip() + if settings.t_plus_enabled and settings.t_plus_connection_file: + return _source_database() + # 不读取私有连接文件,不建源库连接,也不将不同账套的资料混在一起。 + statement = union(*( + select(model.source_database).where( + model.source_database.is_not(None), model.source_database != "" + ).distinct() + for model in (TPlusSourceHead, TPlusFact, TPlusWorkRun, TPlusSyncBatch) + )).limit(2) + sources = list(session.scalars(statement)) + if len(sources) > 1: + raise BadRequestError("已有多个账套,请由管理员明确配置要查看的源账套") + return sources[0] if sources else None + + def _same_intent(batch, mode, entities, parent): if ( batch.sync_mode != mode diff --git a/backend/app/services/tplus/workbench.py b/backend/app/services/tplus/workbench.py index 9068c85..3a73f4a 100644 --- a/backend/app/services/tplus/workbench.py +++ b/backend/app/services/tplus/workbench.py @@ -9,13 +9,13 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import defer -from app.core.exceptions import BadRequestError, NotFoundError +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.response import PageResult from app.core.tplus_lock import TPlusBusyError, sync_lock from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun from app.schemas.tplus_workbench import WorkCreateIn from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value -from app.services.tplus.task_service import _source_database, new_batch_no +from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no RULE_VERSION = "2026-10-30.2" logger = logging.getLogger(__name__) @@ -30,6 +30,17 @@ def digest(data): ).hexdigest() +def _work_source_database(session): + """工作区使用本地已存资料;SQL Server 读取开关只约束真正的源采集。""" + try: + return _source_database() + except BadRequestError: + source = history_source_database(session) + if source is None: + raise BadRequestError("尚无可用的源账套,请先配置账套并完成源数据采集") from None + return source + + def latest_facts(session, source): """每个事实键只读取最近版本,历史行保持可追溯。""" ids = ( @@ -47,7 +58,7 @@ def latest_facts(session, source): def save_fact(session, payload, actor_id): """与导入共用锁,乐观版本不匹配时拒绝覆盖。""" - source = _source_database() + source = _work_source_database(session) try: with sync_lock(session): session.rollback() @@ -88,7 +99,7 @@ def fact_out(fact): def list_facts(session, query): - source = _source_database() + source = history_source_database(session) ids = ( select(func.max(TPlusFact.id)) .where(TPlusFact.source_database == source) @@ -115,8 +126,13 @@ def list_facts(session, query): def queue_work(session, payload: WorkCreateIn, actor_id): """HTTP 只排队;同幂等键的不同意图必须被拒绝。""" + from app.models import User + from app.services.authorization_service import require_permission + + require_permission(session, session.get(User, actor_id), + "tplus:import" if payload.kind.endswith("_APPLY") else "tplus:preview") values = payload.model_dump(mode="json", exclude={"request_key"}) - source = _source_database() + source = _work_source_database(session) key = str(payload.request_key) found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) if found: @@ -131,15 +147,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id): preview = get_run(session, payload.preview_no, source) if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): raise BadRequestError("必须选择已完成的基础导入预览") - if payload.kind.startswith("BUSINESS_"): - from app.models import User - from app.services.payroll_access import ensure_payroll_writer - - ensure_payroll_writer(session.get(User, actor_id)) - if payload.kind == "BUSINESS_APPLY": - preview = get_run(session, payload.preview_no, source) - if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): - raise BadRequestError("必须选择已完成的正式业务导入预览") + if payload.kind == "BUSINESS_APPLY": + preview = get_run(session, payload.preview_no, source) + if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): + raise BadRequestError("必须选择已完成的正式业务导入预览") if payload.replay_no: previous = get_run(session, payload.replay_no, source) if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"): @@ -165,7 +176,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id): def get_run(session, number, source=None): - source = source or _source_database() + source = source or history_source_database(session) run = session.scalar( select(TPlusWorkRun) .where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source) @@ -194,7 +205,7 @@ def work_out(run): def list_work(session, query): - conditions = [TPlusWorkRun.source_database == _source_database()] + conditions = [TPlusWorkRun.source_database == history_source_database(session)] if query.kind: conditions.append(TPlusWorkRun.kind == query.kind) total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions)) @@ -248,7 +259,7 @@ def list_lines(session, number, query): def fact_history(session, fact_id, query): """按事实身份分页查看修改历史,不允许跨账套读取。""" fact = session.get(TPlusFact, fact_id) - if fact is None or fact.source_database != _source_database(): + if fact is None or fact.source_database != history_source_database(session): raise NotFoundError("补充资料不存在") conditions = [ TPlusFact.source_database == fact.source_database, @@ -276,7 +287,7 @@ def source_choices(session, query): from app.services.tplus.snapshots import source_statement try: - statement = source_statement(session, _source_database(), query.entity) + statement = source_statement(session, history_source_database(session), query.entity) except WorkbenchError: raise BadRequestError("请先完整读取相关源数据") from None if query.search: @@ -409,8 +420,13 @@ def execute_next(session): run.status, run.started_at = "RUNNING", datetime.now() session.commit() try: - if run.source_database != _source_database(): + if run.source_database != _work_source_database(session): raise WorkbenchError("SOURCE_CHANGED") + from app.models import User + from app.services.authorization_service import require_permission + + require_permission(session, session.get(User, run.requested_by), + "tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview") if run.kind.startswith("BUSINESS_"): from app.services.tplus.business_import import execute_business @@ -430,7 +446,7 @@ def execute_next(session): run = get_run(session, run.run_no, run.source_database) run.status, run.finished_at = "FAILED", datetime.now() # 不让驱动异常、SQL 或原始数据进入响应。 - run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR" + run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR" logger.error( "工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__ ) diff --git a/backend/app/services/tplus/worker.py b/backend/app/services/tplus/worker.py index e375690..b1a8b6d 100644 --- a/backend/app/services/tplus/worker.py +++ b/backend/app/services/tplus/worker.py @@ -43,6 +43,12 @@ def run_once(session, source_factory=None, *, worker_owner=None): def _execute(session, batch, source_factory): configured = False try: + if batch.requested_by is not None: + from app.models import User + from app.services.authorization_service import require_permission + + require_permission(session, session.get(User, batch.requested_by), + "tplus:retry" if batch.parent_batch_no else "tplus:collect") settings = resolve_tplus_settings(task_service.get_settings()) if batch.source_database and batch.source_database != settings.t_plus_database: raise RuntimeError("任务排队后的源账套配置已改变") diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index e0405b5..c162a24 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -42,6 +42,7 @@ from app.repositories import role_repository, user_repository from app.schemas.role import RoleOut from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn from app.services import audit_service +from app.services import authorization_management as auth_management from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) @@ -115,6 +116,7 @@ def create_user( if user_repository.get_by_username(session, payload.username) is not None: raise BadRequestError(f"用户名 {payload.username!r} 已存在") + policy = auth_management.lock_policy(session, operator) roles = _resolve_roles(session, payload.roles) user = User( @@ -129,6 +131,7 @@ def create_user( try: user_repository.create(session, user) user_repository.replace_roles(session, user, roles) + auth_management.bump(policy, operator) out = _to_out(user) session.commit() except IntegrityError as exc: @@ -192,8 +195,11 @@ def update_user( session.flush() if payload.roles is not None: + policy = auth_management.lock_policy(session, operator) + auth_management.guard_user_change(session, user, operator, roles=payload.roles) roles = _resolve_roles(session, payload.roles) user_repository.replace_roles(session, user, roles) + auth_management.bump(policy, operator) out = _to_out(user) session.commit() @@ -273,7 +279,10 @@ def set_status( if status == 0 and user_id == operator.id: raise ForbiddenError("不能禁用自己") + policy = auth_management.lock_policy(session, operator) + auth_management.guard_user_change(session, user, operator, status=status) user.status = status + auth_management.bump(policy, operator) if status == 0: user.token_version += 1 out = _to_out(user) @@ -313,6 +322,9 @@ def delete_user( if user_id == operator.id: raise ForbiddenError("不能删除自己") + policy = auth_management.lock_policy(session, operator) + auth_management.guard_user_change(session, user, operator, deleting=True) + auth_management.bump(policy, operator) username = user.username # 固化:对象随删除失效 user_repository.delete(session, user) session.commit() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fcc4065..9ffb32f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "pillow>=11.0", "openpyxl>=3.1.5", "python-multipart>=0.0.32", + "psutil>=7.2,<8", ] [project.optional-dependencies] diff --git a/backend/tests/README.md b/backend/tests/README.md index ac174e5..7fcd738 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -1,5 +1,55 @@ # 后端测试 + +## 角色授权与工资权限(2026-11-02) + +- `tests/unit/test_authorization.py`:验证角色并集、查看依赖、多角色合并、即时撤权、管理员固定权限、旧个人覆盖拒绝、旧全局绑定不影响工资判定和已生成月份锁。 +- `tests/integration/test_authorization.py`:隔离 MySQL 覆盖角色创建/授权、用户分配、旧会话即时生效、授权版本冲突、并发分配、排队任务撤权、工资角色免绑定和元数据脱敏。 +- 最终复核:完整单元 916 项通过;隔离定点授权、结构同步与工资准入 41 项通过。V26 本地迁移前已备份,迁移后核验旧入口停用、全局制作人引用为空、个人覆盖为 0。 + +## 人员经理候选与产品体验回归(2026-11-02) + +- `test_requirement_completion.py` 与 `test_authorization_policy.py` 相关单元 13 项通过;`test_personnel.py` 与 `test_requirement_completion.py` 隔离 MySQL 集成 29 项通过。 +- 新增经理候选测试:普通在职岗位不能新选、经理参照只返回最小字段、经理转岗后旧参考可保留但不能新增引用。已有成功夹具使用真正经理岗位,未放宽校验或跳过用例。 +- Ruff、前端类型/构建及 6 项本地真实接口浏览器检查通过;单元和集成日志 `.ai/product-personnel-*.log`,页面结果 `.ai/product-personnel-browser/result.json`。类型检查遇到本机 Node 分配失败后,以较小年轻代配置重跑通过,未降低标准。 +- 用户明确要求的本地演示数据通过真实 API 新增并审计,清单 `.ai/product-demo-manifest.json`;未用集成清理夹具操作原开发库,也未核算或生成工资。 + +## 新增免填原因与确认框宽度(2026-11-02,当前行为) + +- 用户要求新增不填写修改原因:创建请求可省略 `reason`,后台审计自动写入“创建角色并配置初始权限”,包含初始权限明细;修改既有权限仍要求原因。 +- 相关单元 31 项、隔离 MySQL 初始授权 6 项通过,含自动审计及事务回滚。前端类型/构建、Ruff 通过;8 项真实浏览器检查通过,确认框在 2560/1440/768/375/320 宽度居中并保留默认最大宽度,新增无需原因可保存,修改保护继续生效。 +- 证据 `.ai/role-create-no-reason-integration.log`、`.ai/role-create-no-reason/result.json`。下方上一轮“初始原因必填”仅是历史记录,已由本条更新。 + +## 新建角色同时授权与遮罩关闭修复(2026-11-02) + +- `tests/unit/test_role_schemas.py tests/unit/test_authorization.py`:31 项通过,覆盖兼容旧创建载荷、初始权限原因必填及统一授权规则。 +- `tests/integration/test_role_management.py tests/integration/test_authorization.py`:31 项隔离 MySQL 回归通过,覆盖创建即授权、绑定后旧令牌生效、未知/重复/管理员专用/缺查看权限拒绝,以及权限写入失败时角色和授权版本一起回滚。 +- 前端类型、生产构建和相关 Ruff 通过;开发模式及固定生产构建分别完成 6 项无模拟接口浏览器检查,验证灰色遮罩关闭、未保存提醒、同页创建与权限落库、失败保留输入、768 宽度。 +- 日志 `.ai/role-create-permissions-integration.log`;页面结果 `.ai/role-create-ui-dev/result.json`、`.ai/role-create-ui/result.json`。不改写上一轮全量测试数量,不操作生产或真实业务账号。 + +## 角色授权切换回归(2026-11-02) + +- 按用户后续选择切换为“角色配置权限 → 用户分配角色”,取消个人覆盖和全局制作人绑定;月份锁、实际经办人及审批节点保护继续生效。 +- 最新完整单元 **893 passed**;角色授权/参数/核算准入/结构同步 **31 passed**;正式工资/审批/月结/T+ 工作区 **52 passed**,共 83 项隔离 MySQL 专项通过。 +- 修正旧测试约定:人员必须提供计提岗位,参数查看通过角色授予,未授权角色不能核算,管理员无需单独绑定。未放宽产品校验或跳过测试。 +- 日志:`.ai/authorization-role-unit-final.log`、`.ai/authorization-current-recheck.log`、`.ai/authorization-role-sensitive.log`。先前角色切换期间的全量 420 passed / 4 failed 保留原记录,不写成一次性全量全绿。 +- Ruff、前端类型及生产构建通过;真实无模拟接口浏览器 6 项通过,见 `.ai/authorization-role-real/result.json`。全部写入测试使用隔离库,未对本地业务库运行清理夹具。 + +## 客户页面参数说明核验(2026-11-02) + +- 参数目录改为使用当前注册表的业务说明,历史参数值与生效日期仍从数据库记录读取;新增回归防止旧数据库说明继续误导页面,且不改写历史数值。 +- 命令:`.venv-workbench/Scripts/python.exe -m pytest tests/unit/test_param_registry.py tests/unit/test_formula.py -q --tb=short -p no:cacheprovider`,**59 passed**;相关 Ruff 检查通过。 +- 本次说明修订依据最新客户回执:系统审核默认关闭且不伪造通过痕迹,预收款实际发货后计提,低于毛利阈值的负结果仍按原规则计算。未修改公式或参数默认值,未运行业务数据写入测试。 + +## 系统监控(2026-11-02) + +- `tests/unit/test_monitoring.py`、`tests/unit/test_monitor_service.py` 共 18 项通过:路由模板、4xx/5xx、近似 P95、有界缓冲、编号及日志关联、权限、脱敏导出、落库与重启历史、故障恢复、采集未知及后台错误去重。 +- 本轮完整单元回归 884 项通过;新增监控 Python 文件 Ruff 检查通过。 +- 修复验证发现的问题:遥测失败不得覆盖业务响应;重启首次 CPU 未知和 T+ 状态不可读时,不误标历史故障为恢复;迁移链检测兼容 Alembic 的无类型注解版本声明。 +- 真实 MySQL/Redis 与迁移验证仅在显式创建的隔离测试库执行,不对原开发业务库运行集成清理夹具。页面浏览器验证不代替真实依赖验证。 +- 真实隔离验证已通过:V23 升级/降级/再升级,原健康接口 7 项,三张新表 ORM/迁移 SQL/初始化 SQL 一致,管理员与普通用户真实鉴权、异常编号唯一关联、无人访问期间持续采集落库、重启后历史查询及业务表未写入。 +- 真实页面无拦截联调通过,四页签脚本错误与监控 API 失败均为 0。修复 HTTP 查询参数 `hours` 的字符串转换,补测全部三个时间范围的趋势、接口和事件查询,18 项专项复验通过。 + ## 审查问题修复核验(2026-11-02) - 修复正式核算起付比例的日期范围:2026-03-01 前的发货由期初应收承接,不再计入新业务销售额分母;与 FIFO 的启用边界保持一致。 @@ -336,3 +386,10 @@ uv run pytest tests/integration -q # 仅集成 **禁止直接对原业务库执行集成测试。** 运行 `python scripts/run_isolated_tests.py` 创建受限测试库;夹具在执行前及清理前核验显式标记、名称及真实引擎目标。运行器保留隔离库便于复查,不打印临时凭据。 最新完整集成补验:全部29个测试文件分两套独立数据库覆盖,215+184=399项通过,失败0、跳过0。上述旧夹具失败已经修复并关闭;最终结果以本条和开发记录末节为准。完整单元749通过,V18/V19往返及结构9通过。 +\n\n## T+ 详情分项空值与去重回归(2026-11-02)\n\n- T+ 任务/工作台/监控相关单元 66 项通过;Ruff、前端类型和构建通过。模拟接口浏览器覆盖创建不弹详情、详情去重、轮询停止、异常与响应式、旧任务空分项说明。真实任务只读验证通过。证据 `.ai/tplus-detail-null-progress-unit.log`、`.ai/tplus-detail-browser/result.json`、`.ai/tplus-detail-live/result.json`。未操作生产或真实写入。\n + +## 计提公式模板入库(2026-11-02) + +- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。 +- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。 +- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 748e203..4dd6e31 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -41,9 +41,16 @@ def client() -> TestClient: 注意:测试环境未必有 MySQL/Redis 在跑,健康检查会如实返回 false, 因此断言应聚焦于响应结构而非依赖的连通性。 """ + from unittest.mock import patch + from app.main import app - with TestClient(app) as test_client: + # 测试客户端不启动后台采集,避免单测通过环境配置写入现有业务库。 + with ( + patch.object(app.state.monitor, "start"), + patch.object(app.state.monitor, "stop"), + TestClient(app) as test_client, + ): yield test_client diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 4f1ead5..407eb57 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -89,6 +89,12 @@ def _purge_test_data() -> None: factory = get_session_factory() ensure_test_database(factory.kw["bind"].url.database, os.environ.get("PAYROLL_TEST_DATABASE")) with factory() as session: + from app.models import AuthorizationSetting, ProbationDeferral, ProbationPayment + + session.execute(delete(ProbationPayment)) + session.execute(delete(ProbationDeferral)) + + session.execute(update(AuthorizationSetting).values(maker_user_id=None, updated_by=None)) users = list( session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() ) @@ -218,7 +224,10 @@ def _purge_test_data() -> None: .values(company_name="衍水集团", logo_path=None) ) session.commit() - logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo" + logo_dir = get_settings().upload_dir / "logo" + expected_uploads = Path(__file__).resolve().parents[3] / ".ai" / "isolated-uploads" / get_settings().mysql_database + if not logo_dir.resolve().is_relative_to(expected_uploads.resolve()): + raise RuntimeError("测试文件清理目标不在本次隔离目录,拒绝清理") if logo_dir.is_dir(): for f in logo_dir.iterdir(): f.unlink(missing_ok=True) @@ -330,8 +339,10 @@ def make_role(): def admin(make_user, auth_headers, monkeypatch) -> dict: """管理员账号(含请求头)。""" user = make_user([ROLE_ADMIN]) - from app.core.config import get_settings - monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user["id"]) + from app.models import AuthorizationSetting + with get_session_factory()() as session: + session.get(AuthorizationSetting, 1).maker_user_id = user["id"] + session.commit() user["headers"] = auth_headers(user) return user @@ -387,6 +398,10 @@ def legacy_calculation(monkeypatch): @pytest.fixture def writer(normal, admin, monkeypatch): """指定一个普通角色账号为工资制作人,证明身份授权与管理员角色无关。""" - from app.core.config import get_settings - monkeypatch.setattr(get_settings(), "payroll_maker_user_id", normal["id"]) + from app.models import AuthorizationSetting + with get_session_factory()() as session: + session.get(AuthorizationSetting, 1).maker_user_id = normal["id"] + role = session.scalar(select(Role).where(Role.role_code == "payroll_maker")) + session.add(UserRole(user_id=normal["id"], role_id=role.id)) + session.commit() return normal diff --git a/backend/tests/integration/test_approval_flow.py b/backend/tests/integration/test_approval_flow.py index 6eb0d3a..2dc7d24 100644 --- a/backend/tests/integration/test_approval_flow.py +++ b/backend/tests/integration/test_approval_flow.py @@ -228,21 +228,25 @@ class TestSwitchOn: ) assert resp.status_code == 400 - def test_节点准入_领导不能审财务节点_admin不代办( + def test_节点准入_按独立审核权限而非角色名称判定( self, client: TestClient, admin: dict, finance, leader, switched_on ) -> None: p = _person(client, admin["headers"]) data = _create_with_plan(client, admin["headers"], p["id"]) order = _latest_order(client, leader["headers"], data["business_no"]) - # status=1 是财务节点:领导不行,admin 也不代办 - for headers in (leader["headers"], admin["headers"]): + # 领导模板不含财务复核;管理员固定拥有两项审核权限,但仍遵守节点。 + for headers, expected in ((leader["headers"], 403), (admin["headers"], 200)): resp = client.post( f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=headers, ) - assert resp.status_code == 403 + assert resp.status_code == expected + response = client.post( + f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"] + ) + assert response.status_code == 403 # 已进入领导节点,财务模板无领导审核权限。 def test_重复操作已通过单被拒( self, client: TestClient, admin: dict, finance, leader, switched_on diff --git a/backend/tests/integration/test_authorization.py b/backend/tests/integration/test_authorization.py new file mode 100644 index 0000000..20e45aa --- /dev/null +++ b/backend/tests/integration/test_authorization.py @@ -0,0 +1,255 @@ +"""隔离 MySQL 验证角色配置、用户分配、旧会话即时生效与工资操作解耦。""" + +from uuid import uuid4 + +import pytest + +from tests.integration.test_tplus_workbench import workspace as workspace + +BASE = "/api/v1/system" +pytestmark = pytest.mark.integration + + +def current(client, headers): + response = client.get("/api/v1/auth/me", headers=headers) + assert response.status_code == 200, response.text + return response.json()["data"] + + +def authorization(client, admin, user_id): + response = client.get(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"]) + assert response.status_code == 200, response.text + return response.json()["data"] + + +def save(client, admin, user_id, **changes): + before = authorization(client, admin, user_id) + data = { + "role_codes": before["role_codes"], + "expected_revision": before["revision"], + "reason": "隔离角色分配验收", + } | changes + return client.put(f"{BASE}/users/{user_id}/authorization", headers=admin["headers"], json=data) + + +def role(client, admin, codes): + response = client.post( + BASE + "/roles", + headers=admin["headers"], + json={"role_code": "t_rbac_" + uuid4().hex[:8], "role_name": "t_权限验收角色"}, + ) + assert response.status_code == 200, response.text + item = response.json()["data"] + before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[ + "data" + ] + response = client.put( + f"{BASE}/roles/{item['id']}/permissions", + headers=admin["headers"], + json={ + "permission_codes": codes, + "expected_revision": before["revision"], + "reason": "配置测试角色", + }, + ) + assert response.status_code == 200, response.text + return item + + +def test_管理员无需绑定制作人即可维护资料和获得工资权限(client, admin): + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import AuthorizationSetting, BusinessRole, User + from app.services.authorization_service import require_permission + + with get_session_factory()() as session: + session.get(AuthorizationSetting, 1).maker_user_id = None + session.commit() + require_permission(session, session.get(User, admin["id"]), "settlements:generate") + business_role_id = session.scalar( + select(BusinessRole.id).where(BusinessRole.role_code == "salesperson") + ) + assert business_role_id is not None + info = current(client, admin["headers"]) + assert info["can_write_payroll"] and info["maker_required_permissions"] == [] + response = client.post( + "/api/v1/base/personnel", + headers=admin["headers"], + json={"personnel_name": "t_角色授权人员", "business_role_id": business_role_id}, + ) + assert response.status_code == 200, response.text + person = response.json()["data"]["id"] + for path, data in (("regions", {"regions": []}), ("plans", {"plans": []})): + response = client.put( + f"/api/v1/base/personnel/{person}/{path}", headers=admin["headers"], json=data + ) + assert response.status_code == 200, response.text + + +def test_角色配置后分配给用户并在原会话即时生效(client, admin, normal): + assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"] + item = role(client, admin, ["base:personnel:view", "base:personnel:regions"]) + assert ( + save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200 + ) + assert "base:personnel:regions" in current(client, normal["headers"])["permissions"] + assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200 + assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"] + + +def test_旧个人覆盖写入拒绝且不影响既有角色(client, admin, normal): + response = save( + client, + admin, + normal["id"], + overrides=[{"permission_code": "base:personnel:regions", "effect": "ALLOW"}], + ) + assert response.status_code == 400 + assert current(client, normal["headers"])["roles"] == ["user"] + assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"] + + +def test_普通用户不能配置角色或读取权限目录(client, normal): + assert client.get(BASE + "/permissions/catalog", headers=normal["headers"]).status_code == 403 + assert client.get(BASE + "/roles", headers=normal["headers"]).status_code == 403 + + +def test_过期角色分配版本返回409且不覆盖前次结果(client, admin, normal): + before = authorization(client, admin, normal["id"]) + assert save(client, admin, normal["id"]).status_code == 200 + response = save( + client, admin, normal["id"], expected_revision=before["revision"], role_codes=[] + ) + assert response.status_code == 409 + assert "user" in current(client, normal["headers"])["roles"] + + +def test_工资角色不再需要绑定且旧绑定接口不再写入(client, admin, normal): + assert save(client, admin, normal["id"], role_codes=["payroll_maker"]).status_code == 200 + info = current(client, normal["headers"]) + assert info["can_write_payroll"] and "payroll:calculate" in info["permissions"] + settings = client.get(BASE + "/authorization-settings", headers=admin["headers"]).json()["data"] + assert settings["mode"] == "roles" and settings["maker_user_id"] is None + response = client.put( + BASE + "/authorization-settings", + headers=admin["headers"], + json={ + "maker_user_id": normal["id"], + "expected_revision": settings["revision"], + "reason": "旧客户端兼容验收", + }, + ) + assert response.status_code == 400 and "角色" in response.json()["message"] + + +def test_不能撤销当前管理员自身管理身份(client, admin): + assert save(client, admin, admin["id"], role_codes=["user"]).status_code == 403 + assert "admin" in current(client, admin["headers"])["roles"] + + +def test_角色停用撤销旧会话授权且恢复后重新生效(client, admin, normal): + item = role(client, admin, ["base:personnel:view", "base:personnel:regions"]) + assert ( + save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200 + ) + for status in (0, 1): + response = client.put( + f"{BASE}/roles/{item['id']}/status", headers=admin["headers"], json={"status": status} + ) + assert response.status_code == 200, response.text + assert ( + "base:personnel:regions" in current(client, normal["headers"])["permissions"] + ) == bool(status) + + +def test_角色权限编辑自动影响全部绑定用户(client, admin, normal, make_user): + other = make_user(["user"]) + item = role(client, admin, ["base:personnel:view"]) + for user_id in (normal["id"], other["id"]): + assert save(client, admin, user_id, role_codes=[item["role_code"]]).status_code == 200 + before = client.get(f"{BASE}/roles/{item['id']}/permissions", headers=admin["headers"]).json()[ + "data" + ] + response = client.put( + f"{BASE}/roles/{item['id']}/permissions", + headers=admin["headers"], + json={ + "permission_codes": ["base:personnel:view", "base:personnel:regions"], + "expected_revision": before["revision"], + "reason": "角色同步授权验收", + }, + ) + assert response.status_code == 200 + from app.core.db import get_session_factory + from app.models import User + from app.services.authorization_service import calculate + + with get_session_factory()() as session: + for user_id in (normal["id"], other["id"]): + assert ( + "base:personnel:regions" + in calculate(session, session.get(User, user_id))["permissions"] + ) + + +def test_表单参照可使用但不能借此读取完整资料(client, admin, normal): + item = role(client, admin, ["business:businesses:view"]) + assert save(client, admin, normal["id"], role_codes=[item["role_code"]]).status_code == 200 + response = client.get("/api/v1/meta/options?resource=personnel", headers=normal["headers"]) + assert response.status_code == 200, response.text + assert all( + not {"salary_standard", "phone", "password"} & row.keys() for row in response.json()["data"] + ) + assert client.get("/api/v1/base/personnel", headers=normal["headers"]).status_code == 403 + + +def test_排队后撤销角色任务执行前重新检查(client, admin, normal, workspace): + from sqlalchemy import select + + from app.models import TPlusWorkRun + from app.services.tplus import workbench + + session, _ = workspace + item = role(client, admin, ["tplus:view", "tplus:preview"]) + assert ( + save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200 + ) + response = client.post( + BASE + "/tplus/work-runs", + headers=normal["headers"], + json={"kind": "MASTER_PREVIEW", "request_key": str(uuid4()), "entity_types": ["personnel"]}, + ) + assert response.status_code == 200, response.text + number = response.json()["data"]["run_no"] + assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200 + assert workbench.execute_next(session) == number + run = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == number)) + assert run.status == "FAILED" and run.error_code == "TASK_AUTHORIZATION_REVOKED" + + +def test_并发角色分配仅一个版本成功(client, admin, normal): + from concurrent.futures import ThreadPoolExecutor + + choices = [ + role(client, admin, ["base:personnel:view", code]) + for code in ("base:personnel:regions", "base:personnel:plans") + ] + before = authorization(client, admin, normal["id"]) + + def change(item): + return save( + client, + admin, + normal["id"], + role_codes=["user", item["role_code"]], + expected_revision=before["revision"], + ).status_code + + with ThreadPoolExecutor(max_workers=2) as pool: + result = list(pool.map(change, choices)) + assert sorted(result) == [200, 409] + effective = current(client, normal["headers"])["permissions"] + assert ( + sum(code in effective for code in ("base:personnel:regions", "base:personnel:plans")) == 1 + ) diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py index a6f3e9a..a060c67 100644 --- a/backend/tests/integration/test_calc.py +++ b/backend/tests/integration/test_calc.py @@ -11,7 +11,7 @@ 守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、 已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、 -准入(normal 403 / marketing_ops 可用)。 +准入(普通账号和仅营销后勤角色拒绝,分配工资角色后可用)。 """ import uuid @@ -338,8 +338,8 @@ class TestGuards: assert float(ledger["converted_profit"]) == -2000.0 assert float(ledger["performance_amount"]) < 0 - def test_准入_未指定账号拒绝_指定制作人可用( - self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers, monkeypatch + def test_准入_未授权账号拒绝_分配工资角色后可用且无需绑定( + self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers ) -> None: _biz, receipt = _create_full_chain( client, @@ -354,9 +354,13 @@ class TestGuards: ops = make_user(["marketing_ops"]) ops["headers"] = auth_headers(ops) - from app.core.config import get_settings assert _calc(client, ops["headers"], receipt["id"]).status_code == 403 - monkeypatch.setattr(get_settings(), "payroll_maker_user_id", ops["id"]) + from app.models import AuthorizationSetting, Role, UserRole + with get_session_factory()() as session: + session.get(AuthorizationSetting, 1).maker_user_id = None + role = session.scalar(select(Role).where(Role.role_code == "payroll_maker")) + session.add(UserRole(user_id=ops["id"], role_id=role.id)) + session.commit() resp = _calc(client, ops["headers"], receipt["id"]) assert resp.status_code == 200, resp.text diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index 496fd61..52aed90 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -3,7 +3,7 @@ 守的口径: - 权限:普通用户 403 且文案「无权访问工作台」 - 总览:绩效合计/人数与台账一致;月份参数非法 400 -- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级 +- 待办:月结提交后出现待审批;回款未指定批次可命中;同步状态取真实记录 - 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效 - 最近动态:来源操作日志(写操作可见) @@ -128,7 +128,7 @@ def _activities(client: TestClient, headers: dict, **params) -> dict: def test_权限_普通用户403文案(client: TestClient, normal: dict): resp = client.get(_DASHBOARD, headers=normal["headers"]) assert resp.status_code == 403 - assert resp.json()["message"] == "无权访问工作台" + assert resp.json()["message"] == "当前账号没有「首页:查看」权限,请联系管理员授权" # 最近动态分页端点同属工作台,权限一致 resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"]) assert resp.status_code == 403 @@ -182,8 +182,53 @@ def test_待办_待审批与未指定批次与同步降级(client: TestClient, a assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"]) assert data["todos"]["unassigned_receipt_total"] >= 1 assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"] - # ⑥ 未建:显式降级 - assert data["todos"]["sync_available"] is False + assert data["todos"]["sync_available"] is True + assert data["sync"]["available"] is True + + +def test_首页关闭读取仍展示真实采集成功记录(client: TestClient, admin: dict, monkeypatch): + from types import SimpleNamespace + + from sqlalchemy import delete, func, select + + from app.core.db import get_session_factory + from app.models import TPlusSyncBatch + from app.services import dashboard_service + + monkeypatch.setattr(dashboard_service, "get_settings", lambda: SimpleNamespace( + t_plus_enabled=False, t_plus_automation_enabled=False + )) + prefix = "dashboard-" + uuid.uuid4().hex[:12] + finished = datetime(2026, 10, 30, 16, 52, 3) + factory = get_session_factory() + with factory() as session: + baseline = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where( + TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL")), + TPlusSyncBatch.status.in_(("FAILED", "PARTIAL")), + )) + for index, (mode, status) in enumerate([ + ("FULL", "FAILED"), ("FULL", "SUCCEEDED"), ("AUDIT", "FAILED") + ]): + session.add(TPlusSyncBatch( + batch_no=f"{prefix}-{index}", sync_mode=mode, status=status, + source_database="dashboard-test", finished_at=finished, + )) + session.commit() + try: + data = _dashboard(client, admin["headers"]) + assert data["sync"]["latest_status"] == "SUCCEEDED" + assert data["sync"]["last_success_at"] is not None + assert data["sync"]["enabled"] is False + assert data["sync"]["automation_enabled"] is False + assert data["todos"]["sync_available"] is True + assert data["warnings"]["sync_available"] is True + assert data["todos"]["sync_failure_total"] == baseline + 1 + finally: + with factory() as session: + session.execute(delete(TPlusSyncBatch).where( + TPlusSyncBatch.batch_no.in_([f"{prefix}-{index}" for index in range(3)]) + )) + session.commit() def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict): diff --git a/backend/tests/integration/test_formal_payroll.py b/backend/tests/integration/test_formal_payroll.py index 81b7d1a..451bd85 100644 --- a/backend/tests/integration/test_formal_payroll.py +++ b/backend/tests/integration/test_formal_payroll.py @@ -155,12 +155,13 @@ def test_正式核算生成工资锁定及报表一致(client, admin, formal_cha assert Decimal(data["totals"]["payable_total"]) == Decimal(generated["total_payable"]) -def test_非制作人包括其他管理员均不能写( +def test_未授权角色不能核算而管理员无需单独绑定( client, admin, normal, make_user, auth_headers, formal_chain ): business, _, setup, _, _ = formal_chain other_admin = make_user(["admin"]) - for headers in (normal["headers"], auth_headers(other_admin)): + marketing = make_user(["marketing_ops"]) + for headers in (normal["headers"], auth_headers(marketing)): assert ( client.post( f"{BASE}/businesses/{business['id']}/calculate", @@ -175,17 +176,24 @@ def test_非制作人包括其他管理员均不能写( ).status_code == 403 ) - assert ( - client.post("/api/v1/business/businesses", headers=headers, json={}).status_code == 403 - ) + assert client.post("/api/v1/business/businesses", headers=normal["headers"], json={}).status_code == 403 + assert client.post("/api/v1/business/businesses", headers=auth_headers(other_admin), json={}).status_code == 400 assert ( client.get( f"{BASE}/businesses/{business['id']}/input", headers=normal["headers"] ).status_code - == 200 + == 403 ) + assert client.get(f"{BASE}/businesses/{business['id']}/input", headers=auth_headers(other_admin)).status_code == 200 + response = client.post( + f"{BASE}/businesses/{business['id']}/calculate", + headers=auth_headers(other_admin), + json={"month": "2026-03"}, + ) + assert response.status_code == 200, response.text + def test_并发核算仅一份正式结果(client, admin, formal_chain): business, _, _, _, _ = formal_chain diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index f7d5070..473576d 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -13,6 +13,7 @@ import uuid from datetime import date, timedelta +from decimal import Decimal from fractions import Fraction import pytest @@ -86,13 +87,14 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: class TestCatalog: """参数目录:种子完整性与时点化回溯。""" - def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None: + def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None: """V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。""" items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] - assert len(items) == 22 + assert len(items) == 27 groups = {item["param_group"] for item in items} assert groups == { "业务员个人户", + "产品系数表", "经理分成", "企业户", "信息员", @@ -358,7 +360,7 @@ class TestPreview: "probation.annual.threshold", ] - def test_经理聚合口径_预览拒绝(self, client: TestClient, admin: dict) -> None: + def test_经理个人业务_试算排除且指向月结(self, client: TestClient, admin: dict) -> None: payload = { "business_role": "sales_manager", "customer_type": "personal", @@ -370,14 +372,13 @@ class TestPreview: "received_amount": "0", } resp = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) - assert resp.status_code == 400 - # 守两件事:① 聚合口径不允许单笔试算(拒绝而不是算出个错数); - # ② 提示要能指导下一步(点名是谁的口径 + 去哪儿看),不是一句「不支持」。 - message = resp.json()["message"] - assert "营销经理" in message - assert "月度结算" in message + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["rate"] == "0" + assert Decimal(data["performance_amount"]) == 0 + assert "团队分成" in data["rate_text"] - def test_回款日早于发货日_400(self, client: TestClient, admin: dict) -> None: + def test_预收先款后货_按最高比例预览(self, client: TestClient, admin: dict) -> None: payload = { "business_role": "salesperson", "customer_type": "personal", @@ -388,10 +389,10 @@ class TestPreview: "profit": "45000", "received_amount": "0", } - assert ( - client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).status_code - == 400 - ) + response = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]) + assert response.status_code == 200 + assert response.json()["data"]["rate"] == "1/100" + assert response.json()["data"]["payment_days"] == -8 def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None: """企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。 @@ -476,12 +477,25 @@ class TestPreview: class TestPermission: """权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。""" - def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None: + def test_普通用户目录须角色授权后可读(self, client: TestClient, make_user, auth_headers) -> None: user = make_user([ROLE_USER]) headers = auth_headers(user) resp = client.get(_PARAMS, headers=headers) + assert resp.status_code == 403 + from sqlalchemy import select + + from app.models import Permission, Role, RolePermission, UserRole + with get_session_factory()() as session: + permission = session.scalar(select(Permission).where(Permission.perm_code == "system:params:view")) + role = Role(role_code="t_param_view_" + uuid.uuid4().hex[:8], role_name="参数查看测试角色", status=1) + session.add(role) + session.flush() + session.add(RolePermission(role_id=role.id, permission_id=permission.id)) + session.add(UserRole(user_id=user["id"], role_id=role.id)) + session.commit() + resp = client.get(_PARAMS, headers=headers) assert resp.status_code == 200 - assert len(resp.json()["data"]) == 22 + assert len(resp.json()["data"]) == 27 def test_未登录访问目录_403(self, client: TestClient) -> None: # LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401 diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py index f064653..014abf3 100644 --- a/backend/tests/integration/test_personnel.py +++ b/backend/tests/integration/test_personnel.py @@ -50,6 +50,12 @@ def _role_id() -> int: ) +def _manager(client, headers): + with get_session_factory()() as session: + role_id = session.scalar(select(BusinessRole.id).where(BusinessRole.calc_template == "TPL_MANAGER_SHARE", BusinessRole.status == 1)) + return _create(client, headers, business_role_id=role_id) + + def _payload(name: str, **overrides: object) -> dict: body: dict = { "personnel_name": name, @@ -106,11 +112,11 @@ class TestCrudFlow: def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, writer: dict) -> None: headers = writer["headers"] - manager = _create(client, headers) + manager = _manager(client, headers) member = _create(client, headers, manager_id=manager["id"]) assert member["manager_id"] == manager["id"] assert member["manager_name"] == manager["personnel_name"] - assert member["role_name"] == manager["role_name"] + assert member["business_role_id"] == _role_id() def test_列表按姓名关键词可查到新增人员(self, client: TestClient, writer: dict) -> None: data = _create(client, writer["headers"]) @@ -176,7 +182,7 @@ class TestRefRules: def test_直属经理已离职_400(self, client: TestClient, writer: dict) -> None: headers = writer["headers"] - manager = _create(client, headers) + manager = _manager(client, headers) quit_resp = client.put( f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers ) @@ -226,12 +232,45 @@ class TestRefRules: assert "岗位不存在或已停用" in resp.json()["message"] +class TestManagerEligibility: + def test_经理参照只列在职经理岗位且不泄露薪资(self, client, writer): + headers = writer["headers"] + manager = _manager(client, headers) + ordinary = _create(client, headers) + response = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers) + assert response.status_code == 200, response.text + rows = response.json()["data"] + assert manager["id"] in {row["id"] for row in rows} + assert ordinary["id"] not in {row["id"] for row in rows} + assert all(set(row) == {"id", "personnel_name", "business_role_id", "status"} for row in rows) + + def test_普通岗位即使在职也不能被新选为经理(self, client, writer): + headers = writer["headers"] + ordinary = _create(client, headers) + response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=ordinary["id"])) + assert response.status_code == 400 + assert "经理计提模板" in response.json()["message"] + + def test_经理转岗后历史参考保留但不能新增引用(self, client, writer): + headers = writer["headers"] + manager = _manager(client, headers) + member = _create(client, headers, manager_id=manager["id"]) + response = client.put(f"{_PERSONNEL}/{manager['id']}", headers=headers, json=_payload(manager["personnel_name"])) + assert response.status_code == 200, response.text + response = client.put(f"{_PERSONNEL}/{member['id']}", headers=headers, json=_payload(member["personnel_name"], manager_id=manager["id"])) + assert response.status_code == 200, response.text + response = client.post(_PERSONNEL, headers=headers, json=_payload(_uname(), manager_id=manager["id"])) + assert response.status_code == 400 + rows = client.get("/api/v1/meta/options", params={"resource": "managers"}, headers=headers).json()["data"] + assert manager["id"] not in {row["id"] for row in rows} + + class TestDeleteProtection: """删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。""" def test_被团队成员引用禁删_解除后可删(self, client: TestClient, writer: dict) -> None: headers = writer["headers"] - manager = _create(client, headers) + manager = _manager(client, headers) member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) assert blocked.status_code == 400, blocked.text diff --git a/backend/tests/integration/test_reports.py b/backend/tests/integration/test_reports.py index 1154905..c72859c 100644 --- a/backend/tests/integration/test_reports.py +++ b/backend/tests/integration/test_reports.py @@ -316,7 +316,7 @@ def test_权限_普通用户403文案(admin, normal, client: TestClient): """报表限管理员/财务/领导;普通用户被后端拦截且文案正确。""" resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"]) assert resp.status_code == 403 - assert resp.json()["message"] == "无权访问报表统计" + assert resp.json()["message"] == "当前账号没有「个人明细:查看」权限,请联系管理员授权" # ==== 审查回归:归属、调整单、经理个人户、导出审计 ==== diff --git a/backend/tests/integration/test_requirement_completion.py b/backend/tests/integration/test_requirement_completion.py new file mode 100644 index 0000000..944b50d --- /dev/null +++ b/backend/tests/integration/test_requirement_completion.py @@ -0,0 +1,225 @@ +"""隔离 MySQL 验证需求补齐的页面接口、版本和工资原子提交。""" + +from datetime import date, timedelta +from decimal import Decimal +from io import BytesIO + +import pytest +from openpyxl import load_workbook +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.core.db import get_session_factory +from app.models import ParamChangeBatch, ProbationPayment +from tests.integration.test_formal_payroll import formal_chain as formal_chain + +pytestmark = pytest.mark.integration + + +def test_系数表未来版本与工作簿往返(client, admin): + headers = admin["headers"] + base = "/api/v1/system/coefficients" + data = client.get(base, headers=headers).json()["data"] + assert len(data["rows"]) == 10 + rows = [ + { + k: row.get(k) + for k in ("product_category", "customer_type", "stage", "value", "slope", "boundary") + } + for row in data["rows"] + if not row.get("readonly") + ] + rows[-1]["value"] = "5" + payload = { + "effective_date": str(date.today() + timedelta(days=2)), + "base_date": str(date.today()), + "fingerprint": data["fingerprint"], + "rows": rows, + } + response = client.put(base, headers=headers, json=payload) + assert response.status_code == 200, response.text + assert client.put(base, headers=headers, json=payload).status_code == 400 + future = client.get(base, headers=headers, params={"as_of": payload["effective_date"]}).json()[ + "data" + ] + assert future["rows"][-2]["value"] == "5" + export = client.get(base + "/export", headers=headers) + assert export.status_code == 200 + preview = client.post( + base + "/import-preview", + headers=headers, + files={ + "file": ( + "coefficients.xlsx", + export.content, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + assert preview.status_code == 200, preview.text + assert preview.json()["data"]["can_apply"] + book = load_workbook(BytesIO(export.content)) + book["系数参数"].cell(2, 4, "bad-value") + output = BytesIO() + book.save(output) + with get_session_factory()() as session: + before = session.scalar(select(func.count()).select_from(ParamChangeBatch)) + bad = client.post( + base + "/import-preview", headers=headers, files={"file": ("bad.xlsx", output.getvalue())} + ) + assert bad.status_code == 200, bad.text + assert not bad.json()["data"]["can_apply"] + with get_session_factory()() as session: + assert session.scalar(select(func.count()).select_from(ParamChangeBatch)) == before + + +def test_业务提取产品及重复预览不重复建档(client, admin, formal_chain): + headers = admin["headers"] + business, _, _, _, _ = formal_chain + detail = client.get(f"/api/v1/business/businesses/{business['id']}", headers=headers).json()[ + "data" + ] + ids = [row["id"] for row in detail["items"]] + base = "/api/v1/base/products/extract" + plan = client.post(base + "/preview", headers=headers, json={"item_ids": ids}) + assert plan.status_code == 200, plan.text + data = plan.json()["data"] + applied = client.post( + base, headers=headers, json={"item_ids": ids, "fingerprint": data["fingerprint"]} + ) + assert applied.status_code == 200, applied.text + target = applied.json()["data"]["rows"][0]["target_id"] + assert target + again = client.post(base + "/preview", headers=headers, json={"item_ids": ids}).json()["data"] + assert again["rows"][0]["action"] == "LINK" + assert again["rows"][0]["target_id"] == target + + +def test_无绩效试用期工资及冻结分项导出(client, admin, monkeypatch): + monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", False) + headers = admin["headers"] + roles = client.get("/api/v1/system/business-roles", headers=headers).json()["data"] + if isinstance(roles, dict): + roles = roles.get("list", []) + role = next(row for row in roles if row["role_code"] == "salesperson") + person = client.post( + "/api/v1/base/personnel", + headers=headers, + json={ + "personnel_name": "t_无绩效基本工资", + "business_role_id": role["id"], + "salary_standard": "3000", + "hire_date": "2026-01-01", + "probation_start_date": "2026-01-01", + "regularization_date": "2027-01-01", + "is_probation": 1, + }, + ) + assert person.status_code == 200, person.text + generated = client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2026-11"} + ) + assert generated.status_code == 200, generated.text + item = next( + row + for row in generated.json()["data"]["items"] + if row["personnel_id"] == person.json()["data"]["id"] + ) + assert Decimal(item["net_amount"]) == Decimal("3000") + assert Decimal(item["normal_payable_sum"]) == 0 + assert ( + client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2026-11"} + ).status_code + == 400 + ) + export = client.get("/api/v1/payroll/salary-export/2026-11", headers=headers) + assert export.status_code == 200 + assert "工资分项" in load_workbook(BytesIO(export.content)).sheetnames + with get_session_factory()() as session: + assert session.scalar(select(func.count()).select_from(ProbationPayment)) == 0 + + +def test_只读账号不能维护系数或执行提取(client, normal): + headers = normal["headers"] + response = client.post("/api/v1/base/products/extract", headers=headers, json={"item_ids": [1]}) + assert response.status_code == 403 + response = client.put("/api/v1/system/coefficients", headers=headers, json={}) + assert response.status_code == 403 + + +def test_已生成工资的年度续付走当月调整且防重(client, admin, formal_chain, monkeypatch): + from app.models import PerformanceLedger + from app.services import payroll_months + + monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", False) + monkeypatch.setattr(get_settings(), "payroll_approval_enabled", False) + monkeypatch.setattr(payroll_months, "current_month", lambda: "2027-01") + headers = admin["headers"] + business, _, _, owner, _ = formal_chain + response = client.post( + f"/api/v1/payroll/businesses/{business['id']}/calculate", + headers=headers, + json={"month": "2026-03"}, + ) + assert response.status_code == 200, response.text + # 隔离合成台账:年度计提20000、年底仅1000符合业务支付条件。 + with get_session_factory()() as session: + row = session.scalar( + select(PerformanceLedger).where(PerformanceLedger.business_id == business["id"]) + ) + row.is_probation = 1 + row.performance_amount = Decimal("20000") + row.payable_amount = Decimal("1000") + session.commit() + december = client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2026-12"} + ) + assert december.status_code == 200, december.text + first = next( + row for row in december.json()["data"]["items"] if row["personnel_id"] == owner["id"] + ) + assert Decimal(first["probation_payment_sum"]) == Decimal("1000") + january = client.post( + "/api/v1/settlements/generate", headers=headers, json={"month": "2027-01"} + ) + assert january.status_code == 200, january.text + # 工资生成后确认额外满足支付条件的1000,原年度权益只剩1000。 + with get_session_factory()() as session: + row = session.scalar( + select(PerformanceLedger).where( + PerformanceLedger.business_id == business["id"], PerformanceLedger.calc_type == 1 + ) + ) + row.payable_amount = Decimal("3000") + session.commit() + preview = client.get( + "/api/v1/payroll/probation", + headers=headers, + params={"month": "2027-01", "personnel_id": owner["id"]}, + ) + assert preview.status_code == 200, preview.text + balance = preview.json()["data"]["rows"][0] + assert Decimal(balance["delta"]) == Decimal("1000") + payload = { + "personnel_id": owner["id"], + "year": "2026", + "fingerprint": balance["fingerprint"], + "reason": "隔离样本:原年度后续支付条件满足", + } + adjustment = client.post("/api/v1/payroll/probation/adjustments", headers=headers, json=payload) + assert adjustment.status_code == 200, adjustment.text + assert Decimal(adjustment.json()["data"]["amount"]) == Decimal("1000") + assert ( + client.post( + "/api/v1/payroll/probation/adjustments", headers=headers, json=payload + ).status_code + == 400 + ) + final = client.get( + "/api/v1/payroll/probation", + headers=headers, + params={"month": "2027-01", "personnel_id": owner["id"]}, + ).json()["data"]["rows"][0] + assert Decimal(final["paid"]) == Decimal("2000") + assert Decimal(final["delta"]) == 0 diff --git a/backend/tests/integration/test_role_management.py b/backend/tests/integration/test_role_management.py index 64b9a21..82442d3 100644 --- a/backend/tests/integration/test_role_management.py +++ b/backend/tests/integration/test_role_management.py @@ -114,6 +114,79 @@ class TestCrudFlow: assert resp.json()["data"]["total"] == 1 +class TestInitialPermissions: + def test_角色和权限一起创建且绑定后原令牌即时继承(self, client, admin, normal): + import json + + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import OperationLog + + codes = ["base:personnel:view", "base:personnel:regions"] + code = _new_code() + response = client.post(_ROLES, headers=admin["headers"], json={ + "role_code": code, "role_name": "区域维护", "permission_codes": codes, + }) + assert response.status_code == 200, response.text + role = response.json()["data"] + assert role["permission_count"] == 2 + with get_session_factory()() as session: + log = session.scalar(select(OperationLog).where( + OperationLog.user_id == admin["id"], OperationLog.summary.contains(code), + OperationLog.success == 1, + )) + assert log is not None + request_data = json.loads(log.request_data) + assert request_data["reason"] == "创建角色并配置初始权限" + assert request_data["permission_codes"] == codes + stored = client.get(f"{_ROLES}/{role['id']}/permissions", headers=admin["headers"]).json()["data"] + assert set(stored["permission_codes"]) == set(codes) + before = client.get(f"/api/v1/system/users/{normal['id']}/authorization", headers=admin["headers"]).json()["data"] + response = client.put(f"/api/v1/system/users/{normal['id']}/authorization", headers=admin["headers"], json={ + "role_codes": [role["role_code"]], "expected_revision": before["revision"], "reason": "分配新角色", + }) + assert response.status_code == 200, response.text + info = client.get("/api/v1/auth/me", headers=normal["headers"]).json()["data"] + assert set(info["permissions"]) == set(codes) + + @pytest.mark.parametrize("codes", [ + ["unknown:permission"], ["base:personnel:view", "base:personnel:view"], + ["system:users:view"], ["base:personnel:regions"], + ]) + def test_非法初始权限不创建角色(self, client, admin, codes): + code = _new_code() + response = client.post(_ROLES, headers=admin["headers"], json={ + "role_code": code, "role_name": "不得部分创建", "permission_codes": codes, "reason": "校验授权边界", + }) + assert response.status_code == 400 + result = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"] + assert result["total"] == 0 + + def test_权限落库失败时角色和版本一起回滚(self, client, admin): + from sqlalchemy import event + + from app.models import RolePermission + + def fail_insert(*_): + raise RuntimeError("隔离测试模拟初始授权落库失败") + + code = _new_code() + before = client.get("/api/v1/auth/me", headers=admin["headers"]).json()["data"]["authorization_revision"] + event.listen(RolePermission, "before_insert", fail_insert) + try: + with pytest.raises(RuntimeError, match="隔离测试模拟初始授权落库失败"): + client.post(_ROLES, headers=admin["headers"], json={ + "role_code": code, "role_name": "回滚验证", "permission_codes": ["base:personnel:view"], "reason": "验证事务", + }) + finally: + event.remove(RolePermission, "before_insert", fail_insert) + result = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"] + assert result["total"] == 0 + after = client.get("/api/v1/auth/me", headers=admin["headers"]).json()["data"]["authorization_revision"] + assert after == before + + class TestProtection: """保护规则:内置角色与有绑定的角色。""" diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py index 5ac2d72..7378a47 100644 --- a/backend/tests/integration/test_schema_sync.py +++ b/backend/tests/integration/test_schema_sync.py @@ -25,7 +25,7 @@ INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions" _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) -_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE) +_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE) # 这些开头的是约束定义,不是列定义 _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"} # ALTER TABLE 的列级变更(语义重放用):ADD COLUMN / DROP COLUMN; @@ -81,9 +81,9 @@ def _alembic_head() -> str: referenced: set[str] = set() for path in ALEMBIC_VERSIONS_DIR.glob("*.py"): text = path.read_text(encoding="utf-8") - if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE): + if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): revisions.add(found.group(1)) - if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE): + if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): referenced.add(found.group(1)) heads = revisions - referenced diff --git a/backend/tests/integration/test_settlements.py b/backend/tests/integration/test_settlements.py index d5f1ccd..fc7fb91 100644 --- a/backend/tests/integration/test_settlements.py +++ b/backend/tests/integration/test_settlements.py @@ -539,12 +539,18 @@ class TestManagerAggregate: assert row.rate_text == "聚合:20%+5%" assert str(row.receipt_date) == "2026-10-31" - def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None: + def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict, monkeypatch) -> None: """#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。 直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的 结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。 """ + class ScenarioDate(date): + @classmethod + def today(cls): + return cls(2026, 10, 20) + monkeypatch.setattr("app.services.param_service.date", ScenarioDate) + manager, _receipt = self._scene(client, admin["headers"]) factory = get_session_factory() with factory() as session: diff --git a/backend/tests/integration/test_tplus_automation.py b/backend/tests/integration/test_tplus_automation.py index aecea39..d31502a 100644 --- a/backend/tests/integration/test_tplus_automation.py +++ b/backend/tests/integration/test_tplus_automation.py @@ -59,6 +59,8 @@ def test_周期任务经原worker读取九类且正式工资不变(auto_source, def values(_reader, entity, **_args): return iter([{'id': 123456789, 'code': 'AUTO-' + entity}]) monkeypatch.setattr('app.services.tplus.reader.TPlusReader.iter_entity', values) + monkeypatch.setattr('app.services.tplus.reader.TPlusReader.count_entities', + lambda _reader, entities: dict.fromkeys(entities, 1)) with get_session_factory()() as s: result = automation.tick(s, settings=auto_source, now=NOW) number = worker.run_once(s, source_factory=lambda: nullcontext(None), worker_owner='integration-owner') @@ -66,6 +68,7 @@ def test_周期任务经原worker读取九类且正式工资不变(auto_source, batch = s.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number)) assert batch.status == 'SUCCEEDED' and batch.source_record_count == 9 assert batch.trigger_type == 'SCHEDULED' + assert batch.audit_result_json['progress']['total_records'] == 9 assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 diff --git a/backend/tests/integration/test_tplus_sync_console.py b/backend/tests/integration/test_tplus_sync_console.py new file mode 100644 index 0000000..5359325 --- /dev/null +++ b/backend/tests/integration/test_tplus_sync_console.py @@ -0,0 +1,78 @@ +"""同步控制台的真实鉴权、配置保存、审计与进度契约。仅隔离库执行。""" +from pathlib import Path +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models import OperationLog, TPlusSyncBatch +from app.services.tplus import task_service + +pytestmark = pytest.mark.integration +BASE = "/api/v1/system/tplus" + + +def test_新增配置表迁移可往返且不修改同步批次(): + from alembic import command + from alembic.config import Config + from sqlalchemy import inspect, text + + from app.core.db import get_engine + + engine = get_engine() + assert engine.url.database.startswith("payroll_test_") + with engine.connect() as connection: + before = connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one() + config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini")) + command.downgrade(config, "26e7f8a9b0c1") + assert not inspect(engine).has_table("tplus_automation_configs") + command.upgrade(config, "head") + assert inspect(engine).has_table("tplus_automation_configs") + with engine.connect() as connection: + assert connection.execute(text('SELECT version_num FROM alembic_version')).scalar_one() == '28a9b0c1d2e3' + assert connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one() == before + + +def test_开关保存后刷新生效且拒绝过期版本(client, admin, normal, tplus_tasks, monkeypatch): + settings = task_service.get_settings().model_copy(update={ + "t_plus_database": "unit_auto_" + uuid4().hex[:8], "t_plus_automation_enabled": True}) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + initial = client.get(BASE + "/overview", headers=admin["headers"]).json()["data"]["automation"] + assert initial["enabled"] and initial["editable"] and initial["version"] == 0 + payload = {"enabled": False, "expected_version": 0} + assert client.put(BASE + "/automation", json=payload).status_code == 401 + assert client.put(BASE + "/automation", json=payload, headers=normal["headers"]).status_code == 403 + saved = client.put(BASE + "/automation", json=payload, headers=admin["headers"]) + assert saved.status_code == 200 and not saved.json()["data"]["enabled"] + assert saved.json()["data"]["version"] == 1 + refreshed = client.get(BASE + "/overview", headers=admin["headers"]).json()["data"]["automation"] + assert refreshed["enabled"] is False and refreshed["configuration_origin"] == "SAVED" + assert client.put(BASE + "/automation", json={"enabled": True, "expected_version": 0}, headers=admin["headers"]).status_code == 409 + assert client.put(BASE + "/automation", json={"enabled": "false", "expected_version": 1}, headers=admin["headers"]).status_code == 400 + with get_session_factory()() as session: + assert session.scalar(select(OperationLog.id).where( + OperationLog.user_id == admin["id"], OperationLog.action == "修改自动采集", + OperationLog.success == 1, + ).limit(1)) + + +def test_列表详情概况的进度一致且不透传原始报告(client, admin, tplus_tasks): + number = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()["data"]["batch_no"] + with get_session_factory()() as session: + batch = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number)) + batch.status, batch.source_record_count, batch.skipped_record_count = "RUNNING", 50, 50 + batch.audit_result_json = {"password": "private-string-not-exposed", "progress": { + "phase": "READING", "total_records": 200, "active_entity": "personnel", + "entities": {"personnel": {"total": 200, "processed": 50, "status": "READING"}}, + }} + session.commit() + for path in (f"/batches/{number}", "/batches", "/overview"): + response = client.get(BASE + path, headers=admin["headers"]) + assert response.status_code == 200 + assert "private-string-not-exposed" not in response.text + data = response.json()["data"] + batch = data["current_batch"] if path == "/overview" else next(b for b in data["list"] if b["batch_no"] == number) if path == "/batches" else data + assert batch["progress"]["percentage"] == 25 + assert batch["progress"]["processed_records"] == 50 + diff --git a/backend/tests/integration/test_tplus_workbench.py b/backend/tests/integration/test_tplus_workbench.py index f86b94f..9eec3b0 100644 --- a/backend/tests/integration/test_tplus_workbench.py +++ b/backend/tests/integration/test_tplus_workbench.py @@ -83,6 +83,42 @@ def test_工作区全部入口沿用管理员权限(client, normal): ) +def test_关闭源读取仍可查看工作区历史但不能新建采集(client, admin, workspace, monkeypatch): + from app.core.config import Settings + from app.services.tplus import task_service + + db, source = workspace + fact = TPlusFact( + source_database=source, kind="PRODUCT", fact_key="9", version=1, + data_json={"category": "municipal_water", "unit": "件"}, + evidence="只读历史查询测试", requested_by=admin["id"], + ) + run = TPlusWorkRun( + run_no="HISTORY" + uuid4().hex[:18], request_key=str(uuid4()), + source_database=source, kind="MASTER_PREVIEW", status="SUCCEEDED", + requested_by=admin["id"], input_json={"entity_types": ["products"]}, + ) + db.add_all([fact, run]) + db.commit() + settings = Settings( + _env_file=None, app_secret_key="x" * 32, t_plus_database=source, + t_plus_enabled=False, + ) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + for path in ( + "/overview", "/facts", f"/facts/{fact.id}/history", "/work-runs", + f"/work-runs/{run.run_no}", f"/work-runs/{run.run_no}/lines", + f"/work-runs/{run.run_no}/report", + ): + response = client.get(BASE + path, headers=admin["headers"]) + assert response.status_code == 200, (path, response.text) + response = client.post(BASE + "/batches", headers=admin["headers"], json={ + "entity_types": ["personnel"], "request_key": str(uuid4()), + }) + assert response.status_code == 400 + assert "未启用" in response.json()["message"] + + def test_资料乐观锁和历史在MySQL生效(client, admin, workspace): db, _ = workspace payload = { diff --git a/backend/tests/unit/test_authorization.py b/backend/tests/unit/test_authorization.py new file mode 100644 index 0000000..fe56260 --- /dev/null +++ b/backend/tests/unit/test_authorization.py @@ -0,0 +1,165 @@ +"""角色授权并集、即时撤权与独立工资月份锁,不再使用个人覆盖或全局绑定。""" + +import pytest +from pydantic import ValidationError +from sqlalchemy import select + +from app.core.exceptions import BadRequestError, ForbiddenError +from app.models import Permission, Role, RolePermission, User, UserRole +from app.schemas.authorization import AuthorizationPreviewIn +from app.services import authorization_service as service +from app.services.permission_catalog import CATALOG +from tests.unit.test_tplus_workbench import work_session as work_session + + +@pytest.fixture +def auth_case(work_session): + session = work_session + user = User(username="auth_test", password="test_hash", status=1) + role = Role(role_code="auth_role", role_name="测试权限", status=1) + session.add_all([user, role]) + session.flush() + session.add(UserRole(user_id=user.id, role_id=role.id)) + for code in ( + "base:personnel:view", + "base:personnel:regions", + "payroll:view", + "payroll:calculate", + ): + permission = session.scalar(select(Permission).where(Permission.perm_code == code)) + session.add(RolePermission(role_id=role.id, permission_id=permission.id)) + session.commit() + return session, user, role + + +def test_用户权限仅继承角色且来源清晰(auth_case): + session, user, _ = auth_case + result = service.calculate(session, user) + assert set(result["permissions"]) == { + "base:personnel:view", + "base:personnel:regions", + "payroll:view", + "payroll:calculate", + } + assert result["sources"]["payroll:calculate"] == { + "roles": ["auth_role"], + "override": None, + "effective": True, + } + + +def test_操作缺少模块查看权限时不生效(auth_case): + session, user, role = auth_case + permission = session.scalar( + select(Permission).where(Permission.perm_code == "base:personnel:view") + ) + link = session.scalar( + select(RolePermission).where( + RolePermission.role_id == role.id, RolePermission.permission_id == permission.id + ) + ) + session.delete(link) + session.flush() + assert "base:personnel:regions" not in service.calculate(session, user)["permissions"] + + +def test_多个角色权限取并集且无需指定制作人(auth_case): + session, user, role = auth_case + other = Role(role_code="auth_extra", role_name="额外职责", status=1) + session.add(other) + session.flush() + permission = session.scalar( + select(Permission).where(Permission.perm_code == "payroll:allocation") + ) + session.add(RolePermission(role_id=other.id, permission_id=permission.id)) + session.add(UserRole(user_id=user.id, role_id=other.id)) + session.flush() + assert service.setting(session).maker_user_id is None + assert "payroll:allocation" in service.calculate(session, user)["permissions"] + service.require_permission(session, user, "payroll:calculate") + service.require_permission(session, user, "payroll:allocation") + role.status = 0 + session.flush() + assert "payroll:allocation" not in service.calculate(session, user)["permissions"] + + +def test_角色停用立即撤销旧会话权限(auth_case): + session, user, role = auth_case + role.status = 0 + session.flush() + assert not service.calculate(session, user)["permissions"] + with pytest.raises(ForbiddenError): + service.require_permission(session, user, "payroll:calculate") + + +def test_管理员无需绑定即可使用已配置工资功能(auth_case): + session, user, _ = auth_case + role = session.scalar(select(Role).where(Role.role_code == "admin")) + user.user_roles.append(UserRole(role_id=role.id)) + session.flush() + assert set(service.calculate(session, user)["permissions"]) == set(CATALOG) + assert service.setting(session).maker_user_id is None + service.require_permission(session, user, "payroll:calculate") + + +def test_多个获授权账号都能核算且旧绑定不影响判定(auth_case): + session, user, role = auth_case + other = User(username="another_operator", password="test_hash", status=1) + session.add(other) + session.flush() + session.add(UserRole(user_id=other.id, role_id=role.id)) + service.setting(session).maker_user_id = user.id + session.flush() + for actor in (user, other): + service.require_permission(session, actor, "payroll:calculate") + assert service.session_info(session, actor)["can_write_payroll"] + assert service.session_info(session, actor)["maker_required_permissions"] == [] + with pytest.raises(ForbiddenError): + service.require_permission(session, other, "settlements:generate") + + +def test_旧个人覆盖不再接受且无法另开授权入口(auth_case): + session, user, _ = auth_case + with pytest.raises(ValidationError): + AuthorizationPreviewIn( + role_codes=["auth_role"], + overrides=[{"permission_code": "payroll:calculate", "effect": "DENY"}], + ) + with pytest.raises(BadRequestError, match="角色授权"): + service.calculate(session, user, overrides={"payroll:calculate": "DENY"}) + + +def test_普通角色不能获得管理员专用权限(auth_case): + session, user, role = auth_case + permission = session.scalar( + select(Permission).where(Permission.perm_code == "system:roles:update") + ) + session.add(RolePermission(role_id=role.id, permission_id=permission.id)) + session.flush() + assert "system:roles:update" not in service.calculate(session, user)["permissions"] + + +def test_停用账号不保留角色权限(auth_case): + session, user, _ = auth_case + user.status = 0 + assert not service.calculate(session, user)["permissions"] + + +def test_取消全局绑定仍保留月份实际经办人与生成锁(auth_case): + from datetime import datetime + + from app.models import PayrollMonth + from app.services import payroll_months + + session, user, role = auth_case + other = User(username="second_month_operator", password="test_hash", status=1) + session.add(other) + session.flush() + session.add(UserRole(user_id=other.id, role_id=role.id)) + session.add(PayrollMonth(month="2091-11", maker_id=user.id, maker_name=user.username, generated_at=datetime(2091, 11, 30))) + session.flush() + assert service.setting(session).maker_user_id is None + with pytest.raises(ForbiddenError, match="其他制作人"): + payroll_months.lock_month(session, "2091-11", other) + with pytest.raises(BadRequestError, match="已锁定"): + payroll_months.ensure_allocation_editable(session, "2091-11", user) diff --git a/backend/tests/unit/test_authorization_policy.py b/backend/tests/unit/test_authorization_policy.py new file mode 100644 index 0000000..18cdb71 --- /dev/null +++ b/backend/tests/unit/test_authorization_policy.py @@ -0,0 +1,34 @@ +"""权限覆盖守卫:新接口未登记时必须明确失败。""" +from fastapi.routing import APIRoute + +from app.services.authorization_policy import permission_for +from app.services.permission_catalog import CATALOG + + +def test_全部受保护接口都有权限映射(): + from app.main import app + + missing=[] + checked = 0 + def routes(items): + for item in items: + if isinstance(item, APIRoute): + yield item + elif hasattr(item, 'original_router'): + yield from routes(item.original_router.routes) + for route in routes(app.routes): + if not route.endpoint.__module__.startswith('app.api.v1.'): + continue + checked += 1 + module=route.endpoint.__module__.removeprefix('app.api.v1.') + for method in route.methods: + code=permission_for(module,route.endpoint.__name__,method, + params={'code':'personal-detail'},body={'kind':'MASTER_PREVIEW'}) + if code is None or (code and code not in CATALOG): + missing.append((method,route.path,code)) + assert checked >= 100, '路由枚举未覆盖全部业务接口' + assert not missing, missing + + +def test_未知接口没有默认放行(): + assert permission_for('future_module','new_action','GET') is None diff --git a/backend/tests/unit/test_dashboard_sync.py b/backend/tests/unit/test_dashboard_sync.py new file mode 100644 index 0000000..76bbfe1 --- /dev/null +++ b/backend/tests/unit/test_dashboard_sync.py @@ -0,0 +1,74 @@ +"""首页采集状态来自真实批次,关闭读取不抹掉历史成功记录。""" + +from datetime import datetime + +import pytest +from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table, create_engine +from sqlalchemy.orm import Session + +from app.core.config import Settings +from app.services import dashboard_service + + +@pytest.fixture +def sync_database(monkeypatch): + engine = create_engine("sqlite://") + metadata = MetaData() + # 只建 V14 已有列,证明读取状态不要求业务库先升级 V20。 + table = Table( + "tplus_sync_batches", metadata, + Column("id", Integer, primary_key=True), + Column("sync_mode", String(16)), + Column("status", String(16)), + Column("finished_at", DateTime), + ) + metadata.create_all(engine) + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(dashboard_service, "get_settings", lambda: settings, raising=False) + with Session(engine) as session: + yield session, table, settings + engine.dispose() + + +def test_关闭采集仍显示历史成功且不将旧失败冒充当前失败(sync_database): + session, table, _ = sync_database + finished = datetime(2026, 10, 30, 16, 52, 3) + session.execute(table.insert(), [ + {"id": 1, "sync_mode": "FULL", "status": "FAILED", "finished_at": finished}, + {"id": 2, "sync_mode": "FULL", "status": "SUCCEEDED", "finished_at": finished}, + {"id": 3, "sync_mode": "AUDIT", "status": "FAILED", "finished_at": finished}, + ]) + result = dashboard_service._sync_status(session) + assert result["available"] is True + assert result["enabled"] is False and result["automation_enabled"] is False + assert result["latest_status"] == "SUCCEEDED" + assert result["last_success_at"] == finished + assert result["historical_failure_total"] == 1 + + +@pytest.mark.parametrize("status", ["FAILED", "PARTIAL", "QUEUED", "RUNNING"]) +def test_首页如实返回最近采集状态(sync_database, status): + session, table, settings = sync_database + settings.t_plus_enabled = True + session.execute(table.insert(), {"id": 1, "sync_mode": "FULL", "status": status}) + result = dashboard_service._sync_status(session) + assert result["latest_status"] == status and result["enabled"] is True + assert result["last_success_at"] is None + + +def test_无记录不显示已采集(sync_database): + session, _, _ = sync_database + result = dashboard_service._sync_status(session) + assert result["available"] is True + assert result["latest_status"] is None and result["last_success_at"] is None + + +def test_未建同步表时明确返回不可查询(): + engine = create_engine("sqlite://") + try: + with Session(engine) as session: + result = dashboard_service._sync_status(session) + assert result["available"] is False + assert result["last_success_at"] is None + finally: + engine.dispose() diff --git a/backend/tests/unit/test_formal_payroll.py b/backend/tests/unit/test_formal_payroll.py index 01f65ab..75a9167 100644 --- a/backend/tests/unit/test_formal_payroll.py +++ b/backend/tests/unit/test_formal_payroll.py @@ -9,7 +9,6 @@ from uuid import uuid4 import pytest from sqlalchemy import func, select -from app.core.config import get_settings from app.core.exceptions import BadRequestError, ForbiddenError from app.models import ( Business, @@ -55,7 +54,11 @@ def payroll_case(work_session, monkeypatch): ) s.add(manager) s.commit() - monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id) + from app.models import Role, UserRole + + maker_role = s.scalar(select(Role).where(Role.role_code == "payroll_maker")) + user.user_roles.append(UserRole(role_id=maker_role.id)) + s.commit() prepare_calc(s) apply(s, preview(s, ["personnel"])) product = Product( @@ -148,7 +151,7 @@ def imported(case): return s.scalar(select(Business)) -def test_指定账号而非管理员或岗位放行(payroll_case): +def test_工资角色可写且未授权对象不能冒用身份(payroll_case): _, user, _ = payroll_case assert can_write_payroll(user) assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1)) @@ -222,7 +225,7 @@ def test_生成失败不留下月份锁(payroll_case): s, user, _ = payroll_case from app.services.settlement_service import generate - with pytest.raises(BadRequestError, match="无可结算"): + with pytest.raises(BadRequestError, match=r"无可(?:结算|发薪)"): generate(s, "2026-03", operator=user) assert s.scalar(select(PayrollMonth)) is None diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index e396439..ca4f307 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -12,7 +12,11 @@ from sqlalchemy.dialects import mysql from app.models import Base EXPECTED_TABLES = { + "probation_deferrals", "probation_payments", + "monitor_snapshots", "monitor_request_minutes", "monitor_events", + "authorization_settings", "user_permission_overrides", "tplus_automation_states", + "tplus_automation_configs", "payroll_months", "payroll_inputs", "payroll_allocations", "tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines", "users", @@ -22,7 +26,7 @@ EXPECTED_TABLES = { "role_permissions", "login_logs", "operation_logs", - "business_roles", + "business_roles", "calc_template_definitions", "system_settings", "approval_orders", "approval_steps", diff --git a/backend/tests/unit/test_monitor_service.py b/backend/tests/unit/test_monitor_service.py new file mode 100644 index 0000000..2c52155 --- /dev/null +++ b/backend/tests/unit/test_monitor_service.py @@ -0,0 +1,297 @@ +"""使用隔离 SQLite 事务验证监控落库、恢复、权限及脱敏,不接触开发业务库。""" + +import json +from datetime import timedelta +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.testclient import TestClient +from sqlalchemy import Integer, MetaData, create_engine, func, select +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_current_user, get_db +from app.api.v1.system.monitor import router +from app.core.config import Settings +from app.core.exception_handlers import register_exception_handlers +from app.core.monitoring import MonitorMiddleware, Recorder, minute_of, now_local +from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot +from app.schemas.monitor import MonitorWindow +from app.services import monitor_service as service +from app.services.monitor_collector import MonitorCollector, RuntimeErrorHandler + + +@pytest.fixture +def monitor_case(monkeypatch): + engine = create_engine( + "sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False} + ) + metadata = MetaData() + for model in (MonitorSnapshot, MonitorRequestMinute, MonitorEvent): + table = model.__table__.to_metadata(metadata) + table.c.id.type = Integer() + metadata.create_all(engine) + factory = sessionmaker(engine, expire_on_commit=False) + settings = Settings(_env_file=None, app_secret_key="x" * 32, monitor_enabled=False) + collector = MonitorCollector(settings, Recorder()) + monkeypatch.setattr(collector, "_factory", lambda: factory) + yield collector, factory + engine.dispose() + + +def seed(collector, at): + collector.recorder.record("GET", "/api/items/{id}", 500, 2200, "a" * 32, at=at) + collector.recorder.record("GET", "/api/items/{id}", 400, 10, "b" * 32, at=at) + collector.samples[minute_of(at)] = [ + {"memory_percent": 40, "cpu_percent": 10, "disk_percent": 25} + ] + + +def test_历史跨采集器重启保留且当前分钟不重复统计(monitor_case): + collector, factory = monitor_case + at = minute_of(now_local()) - timedelta(minutes=2) + seed(collector, at) + collector._persist(now_local()) + collector._persist(now_local()) + with factory() as session: + assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 1 + assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 1 + assert service.overview(session, collector).requests["count"] == 2 + restarted = MonitorCollector(collector.settings, Recorder()) + trend = service.trends(session, restarted, MonitorWindow()) + assert sum(point["request_count"] or 0 for point in trend["points"]) == 2 + row = service.interfaces(session, restarted, MonitorWindow())["list"][0] + assert (row["count"], row["errors_5xx"], row["errors_4xx"]) == (2, 1, 1) + assert row["p95_ms"] == 2200 + + +def test_持久化失败保留事件恢复后写入且不影响记录(monitor_case, monkeypatch): + collector, factory = monitor_case + at = minute_of(now_local()) - timedelta(minutes=1) + seed(collector, at) + + def fail(): + raise RuntimeError("synthetic-db-failure") + + monkeypatch.setattr(collector, "_factory", fail) + collector._persist(now_local()) + assert len(collector.recorder.events) == 1 + assert collector.current()["collection_status"] == "warning" + monkeypatch.setattr(collector, "_factory", lambda: factory) + collector._persist(now_local()) + with factory() as session: + assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1 + assert not collector.recorder.events + + +def test_同类健康异常合并恢复并跨重启复用首次时间(monitor_case): + collector, factory = monitor_case + at = now_local() - timedelta(minutes=3) + bad = [{"name": "MySQL", "status": "error", "message": "连接检查失败。"}] + collector._evaluate(at, {}, bad, {}) + collector._persist(at) + restarted = MonitorCollector(collector.settings, Recorder()) + restarted._factory = lambda: factory + restarted._evaluate(at + timedelta(minutes=1), {}, bad, {}) + restarted._persist(at + timedelta(minutes=1)) + with factory() as session: + row = session.scalar(select(MonitorEvent)) + assert row.first_seen == at and row.occurrences == 2 and row.resolved_at is None + assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1 + restarted._evaluate(at + timedelta(minutes=2), {}, [], {}) + restarted._persist(at + timedelta(minutes=2)) + with factory() as session: + assert session.scalar(select(MonitorEvent)).resolved_at == at + timedelta(minutes=2) + + +def test_资源持续五分钟才告警且未知不算恢复(monitor_case): + collector, _ = monitor_case + at = now_local() - timedelta(minutes=6) + collector._evaluate(at, {"cpu_percent": 90}, [], {}) + assert not collector.conditions + collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 90}, [], {}) + assert "CPU_HIGH" in collector.conditions + collector._evaluate(at + timedelta(minutes=6), {}, [], {}) + assert "CPU_HIGH" in collector.conditions + collector._evaluate(at + timedelta(minutes=7), {"cpu_percent": 30}, [], {}) + assert "CPU_HIGH" not in collector.conditions + + +def test_低请求量不报错误率且四百不触发系统故障(monitor_case): + collector, _ = monitor_case + at = now_local() + for i in range(19): + collector.recorder.record("GET", "/items", 500, 1, str(i), at=at) + collector._evaluate(at, {}, [], {}) + assert "HTTP_ERROR_RATE" not in collector.conditions + collector.recorder.record("GET", "/items", 400, 1, "x", at=at) + collector._evaluate(at, {}, [], {}) + assert "HTTP_ERROR_RATE" in collector.conditions + + +def test_过期清理仅限监控表且保留活动异常(monitor_case): + collector, factory = monitor_case + old = now_local() - timedelta(days=8) + seed(collector, old) + collector._evaluate(old, {"disk_percent": 99}, [], {}) + collector._persist(now_local()) + with factory() as session: + assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 0 + assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 0 + assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1 + assert session.scalar(select(MonitorEvent)).code == "DISK_HIGH" + + +def make_api(collector, factory): + app = FastAPI() + app.state.monitor = collector + app.include_router(router, prefix="/api/v1/system") + register_exception_handlers(app) + app.add_middleware(MonitorMiddleware, recorder=collector.recorder) + + def database(): + with factory() as session: + yield session + + app.dependency_overrides[get_db] = database + return app + + +def test_未登录与普通用户均不能读监控(monitor_case, monkeypatch): + collector, factory = monitor_case + app = make_api(collector, factory) + client = TestClient(app) + assert client.get("/api/v1/system/monitor/overview").status_code == 401 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1) + monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["user"]) + for path in ("overview", "trends", "interfaces", "events", "events/1", "diagnostic"): + assert client.get(f"/api/v1/system/monitor/{path}").status_code == 403 + + +def test_管理员契约查询约束与脱敏导出(monitor_case, monkeypatch): + collector, factory = monitor_case + seed(collector, now_local() - timedelta(minutes=1)) + collector._persist(now_local()) + app = make_api(collector, factory) + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1) + monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["admin"]) + client = TestClient(app) + for path in ("overview", "trends", "interfaces", "events", "events/1"): + response = client.get(f"/api/v1/system/monitor/{path}") + assert response.status_code == 200, response.text + assert response.json()["code"] == 200 + assert client.get("/api/v1/system/monitor/trends?hours=999").status_code == 400 + for hours in (1, 24, 168): + for endpoint in ("trends", "interfaces", "events"): + assert client.get(f"/api/v1/system/monitor/{endpoint}?hours={hours}").status_code == 200 + assert client.get("/api/v1/system/monitor/events?request_id=invalid").status_code == 400 + assert client.get("/api/v1/system/monitor/events?pageSize=201").status_code == 400 + result = client.get("/api/v1/system/monitor/events?request_id=" + "a" * 32).json()["data"] + assert result["total"] == 1 + export = client.get("/api/v1/system/monitor/diagnostic") + assert "attachment" in export.headers["content-disposition"] + assert not any( + field in export.text + for field in ("password", "secret", "token", "traceback", "request_data", "payload_json") + ) + + +def test_未处理异常编号匹配日志且只记录一次(monitor_case, caplog): + collector, factory = monitor_case + app = make_api(collector, factory) + + @app.get("/explode") + def explode(): + raise RuntimeError("synthetic-private-details") + + response = TestClient(app, raise_server_exceptions=False).get("/explode") + request_id = response.headers["x-request-id"] + assert response.status_code == 500 + assert "synthetic-private-details" not in response.text + assert len(collector.recorder.events) == 1 + assert collector.recorder.events[0]["request_id"] == request_id + assert any(getattr(record, "request_id", None) == request_id for record in caplog.records) + assert "synthetic-private-details" not in json.dumps( + jsonable_encoder(list(collector.recorder.events)) + ) + + +def test_后台日志摘要不读取异常原文且请求日志不重复(monitor_case): + import logging + + from app.core.monitoring import REQUEST_ID + + collector, _ = monitor_case + handler = RuntimeErrorHandler(collector.recorder) + record = logging.LogRecord("app.jobs", logging.ERROR, "", 0, "password=synthetic", (), None) + handler.emit(record) + token = REQUEST_ID.set("a" * 32) + try: + handler.emit(record) + finally: + REQUEST_ID.reset(token) + assert len(collector.recorder.events) == 1 + assert "synthetic" not in str(collector.recorder.events) + + +def test_过期采集不继续显示依赖正常(monitor_case): + collector, factory = monitor_case + collector.state.update( + collected_at=now_local() - timedelta(minutes=2), + dependencies=[ + {"name": "MySQL", "status": "normal", "message": "正常", "checked_at": now_local()} + ], + ) + with factory() as session: + result = service.overview(session, collector) + assert result.status == "unknown" + assert result.dependencies[0].status == "unknown" + + +def test_重启首次CPU未知不恢复已有告警且健康后恢复(monitor_case): + collector, factory = monitor_case + at = now_local() - timedelta(minutes=7) + collector._evaluate(at, {"cpu_percent": 99}, [], {}) + collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 99}, [], {}) + collector._persist(at + timedelta(minutes=5)) + restarted = MonitorCollector(collector.settings, Recorder()) + restarted._factory = lambda: factory + restarted._evaluate(at + timedelta(minutes=6), {}, [], {}) + restarted._persist(at + timedelta(minutes=6)) + with factory() as session: + assert session.scalar(select(MonitorEvent)).resolved_at is None + restarted._evaluate(now_local(), {"cpu_percent": 30}, [], {}) + restarted._persist(now_local()) + with factory() as session: + assert session.scalar(select(MonitorEvent)).resolved_at is not None + + +def test_T加状态未知不误报恢复(monitor_case): + collector, factory = monitor_case + at = now_local() - timedelta(minutes=1) + collector._evaluate( + at, + {}, + [], + {"enabled": True, "alerts": [{"code": "WORKER_OFFLINE", "message": "采集进程心跳中断"}]}, + ) + collector._persist(at) + collector._evaluate(now_local(), {}, [], {"status": "unknown", "alerts": []}) + collector._persist(now_local()) + with factory() as session: + assert session.scalar(select(MonitorEvent)).resolved_at is None + + +def test_监控权限登记且不能授予普通角色(monitor_case): + from app.services.authorization_policy import permission_for + from app.services.authorization_service import calculate + from app.services.permission_catalog import CATALOG + + _, factory = monitor_case + assert CATALOG["system:monitor:view"]["admin_only"] + assert permission_for("system.monitor", "diagnostic", "GET") == "system:monitor:view" + with factory() as session: + user = SimpleNamespace(id=1, status=1) + assert "system:monitor:view" in calculate(session, user, roles=["admin"])["permissions"] diff --git a/backend/tests/unit/test_monitoring.py b/backend/tests/unit/test_monitoring.py new file mode 100644 index 0000000..0a6edc6 --- /dev/null +++ b/backend/tests/unit/test_monitoring.py @@ -0,0 +1,71 @@ +"""验证遥测口径、有界内存和请求关联,防止监控干扰业务及泄漏原始地址。""" + +from datetime import datetime, timedelta + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core.monitoring import REQUEST_ID, MonitorMiddleware, Recorder, merge_metrics + + +def test_合并直方图计算P95且四百不计系统错误(): + recorder = Recorder() + at = datetime(2026, 11, 2, 10) + for status, elapsed in [(200, 10), (400, 100), (403, 200), (500, 750)]: + recorder.record("GET", "/items/{id}", status, elapsed, "r", at=at) + result = merge_metrics(recorder.metrics()[at].values()) + assert (result["count"], result["errors_4xx"], result["errors_5xx"]) == (4, 2, 1) + assert result["error_rate"] == 25 + assert result["p95_ms"] == 750 + assert merge_metrics([])["p95_ms"] is None + + +def test_中间件生成编号使用模板排除自身且不读取正文(): + app, recorder = FastAPI(), Recorder() + app.add_middleware(MonitorMiddleware, recorder=recorder) + + @app.get("/items/{id}") + def item(id: str): + return {"request_id": REQUEST_ID.get()} + + @app.get("/api/v1/system/monitor/overview") + def monitor(): + return {} + + client = TestClient(app) + response = client.get("/items/private-id?token=never-record") + assert response.headers["x-request-id"] == response.json()["request_id"] + client.get("/api/v1/system/monitor/overview") + client.get("/private-route") + values = next(iter(recorder.metrics().values())) + assert set(values) == {("GET", "/items/{id}"), ("GET", "/__unmatched__")} + assert "private" not in str(recorder.metrics()) + assert REQUEST_ID.get() == "-" + + +def test_内存分钟路由和事件队列均有界(): + recorder = Recorder() + at = datetime(2026, 11, 2, 10) + for i in range(2100): + recorder.record("GET", f"/route/{i}", 500, 1, str(i), at=at) + assert len(recorder.events) == 2000 and recorder.dropped_events == 100 + assert len(recorder.metrics()[at]) == 201 + for i in range(11): + recorder.record("GET", "/ok", 200, 1, "r", at=at + timedelta(minutes=i)) + assert len(recorder.metrics()) == 10 + + +def test_遥测内部失败不改变业务响应(monkeypatch): + app, recorder = FastAPI(), Recorder() + app.add_middleware(MonitorMiddleware, recorder=recorder) + + @app.get("/ok") + def ok(): + return {"ok": True} + + def broken(*args, **kwargs): + raise RuntimeError("synthetic-recorder-failure") + + monkeypatch.setattr(recorder, "record", broken) + response = TestClient(app).get("/ok") + assert response.status_code == 200 and response.json() == {"ok": True} diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py index f8e9a9d..ac585ad 100644 --- a/backend/tests/unit/test_param_registry.py +++ b/backend/tests/unit/test_param_registry.py @@ -6,11 +6,12 @@ """ from fractions import Fraction +from types import SimpleNamespace import pytest from app.core.exceptions import BadRequestError -from app.services.calc_templates import TEMPLATES, get_template, required_params +from app.services.calc_templates import FORMULAS, TEMPLATES, get_formula from app.services.param_registry import ( PARAM_REGISTRY, get_spec, @@ -22,6 +23,8 @@ from app.services.param_registry import ( # + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12; # V10 增工作台预警规则三项 #10) EXPECTED_CODES = { + "coefficient.personal.first", "coefficient.personal.start", "coefficient.personal.slope", + "coefficient.personal.floor", "coefficient.personal.boundary", "salesperson.rate.start", "salesperson.rate.slope", "salesperson.rate.floor", @@ -47,11 +50,44 @@ EXPECTED_CODES = { } +def test_目录说明使用当前规则且不改写已有参数值(monkeypatch): + """旧数据库的说明可更新,历史生效值和日期必须保持原样。""" + from datetime import date + + from app.services import param_service + + spec = get_spec("approval.monthly_settlement.enabled") + definition = SimpleNamespace( + param_code=spec.code, + param_name=spec.name, + param_group=spec.group, + value_type=spec.value_type, + unit=spec.unit, + dimension_type=spec.dimension_type, + default_value=spec.default_value, + value_min=spec.value_min, + value_max=spec.value_max, + description="旧说明:关闭后自动记录通过痕迹", + ) + monkeypatch.setattr(param_service.param_repository, "list_definitions", lambda *a, **k: [definition]) + monkeypatch.setattr( + param_service.param_repository, + "resolve_values", + lambda *a: [(spec.code, "", "1", date(2026, 1, 1))], + ) + + item = param_service.get_catalog(None, as_of=date(2026, 11, 2))[0] + + assert item.description == spec.description + assert item.values[0].value_text == "1" + assert item.values[0].effective_date == date(2026, 1, 1) + + class TestRegistry: """注册表元数据完整性(与迁移种子同源)。""" - def test_注册表共22项(self) -> None: - assert len(PARAM_REGISTRY) == 22 + def test_注册表共27项(self) -> None: + assert len(PARAM_REGISTRY) == 27 def test_编码集合与迁移种子一致(self) -> None: assert set(PARAM_REGISTRY) == EXPECTED_CODES @@ -161,20 +197,23 @@ class TestValidateAndNormalize: class TestTemplates: - """公式模板注册表:有限集合,声明参数必须真实存在。""" + """公式模板:骨架受控,内置注册表的参数绑定必须真实存在。""" - def test_共五个模板(self) -> None: + def test_共五个内置模板且五个骨架(self) -> None: assert len(TEMPLATES) == 5 + assert len(FORMULAS) == 5 - def test_模板声明参数均在注册表内(self) -> None: + def test_内置模板参数绑定均在注册表内且覆盖骨架角色(self) -> None: for tpl in TEMPLATES.values(): - for code in tpl.params: + spec = get_formula(tpl.formula_type) + assert set(tpl.params) == set(spec.roles), tpl.code + for code in tpl.params.values(): assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}" def test_取模板声明参数(self) -> None: - params = required_params("TPL_SALESPERSON_DECAY") - assert "salesperson.rate.slope" in params + params = TEMPLATES["TPL_SALESPERSON_DECAY"].params + assert params["slope"] == "salesperson.rate.slope" - def test_未知模板抛ValueError_属代码缺陷(self) -> None: + def test_未知骨架抛ValueError(self) -> None: with pytest.raises(ValueError): - get_template("TPL_NOT_EXIST") + get_formula("NOT_EXIST") diff --git a/backend/tests/unit/test_payroll_workbench_api.py b/backend/tests/unit/test_payroll_workbench_api.py index 97fff64..8ccbdc6 100644 --- a/backend/tests/unit/test_payroll_workbench_api.py +++ b/backend/tests/unit/test_payroll_workbench_api.py @@ -10,7 +10,7 @@ import pytest from app.api.v1 import payroll as payroll_api from app.api.v1.system import tplus_workbench as workbench_api -from app.core.config import get_settings +from app.core.config import Settings, get_settings from app.core.exceptions import NotFoundError from app.core.response import PageQuery from app.models import ( @@ -67,6 +67,10 @@ def test_月份状态兼容未生成和历史工资(work_session, state): @pytest.fixture def source_config(monkeypatch): + settings = Settings( + _env_file=None, app_secret_key="x" * 32, t_plus_database="test_source" + ) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) monkeypatch.setattr( task_service, "resolve_tplus_settings", @@ -172,7 +176,7 @@ def test_正式输入及分配调整接口保留原台账与当月归属(payroll assert s.get(MonthlySettlement, settlement.id).total_payable == settlement.total_payable -def test_报告保留完整字段前导零与公式防护(work_session): +def test_报告保留完整字段前导零与公式防护(work_session, source_config): s = work_session s.add( TPlusWorkRun( diff --git a/backend/tests/unit/test_requirement_completion.py b/backend/tests/unit/test_requirement_completion.py new file mode 100644 index 0000000..aba258d --- /dev/null +++ b/backend/tests/unit/test_requirement_completion.py @@ -0,0 +1,320 @@ +"""需求补齐的业务金额回归,覆盖配置实际消费而非页面存在性。""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction +from io import BytesIO + +import pytest +from openpyxl import load_workbook + +from app.core.exceptions import BadRequestError +from app.schemas.business import BusinessItemIn +from app.schemas.coefficient import CoefficientRowIn +from app.services import coefficient_service +from app.services.param_service import ParamSnapshot +from app.services.rate_rules import decide +from app.services.tplus.calculation_preview import _parameters +from tests.unit.test_formal_payroll import imported +from tests.unit.test_formal_payroll import payroll_case as payroll_case +from tests.unit.test_tplus_fifo import inputs, run +from tests.unit.test_tplus_workbench import work_session as work_session + + +def test_企业户分类及信息员金额(monkeypatch): + snapshot = ParamSnapshot.from_rows( + date(2026, 11, 2), + [ + ("enterprise.rate", "", "3", date(2026, 8, 13)), + ("enterprise.rate", "municipal_water", "2", date(2026, 8, 13)), + ("enterprise.rate", "rail_transit", "5", date(2026, 8, 13)), + ], + ) + monkeypatch.setattr( + "app.services.tplus.calculation_preview.param_service.resolve_snapshot", + lambda *args: snapshot, + ) + params = _parameters(None, {"2026-11-02"})["2026-11-02"] + assert decide( + params, + category="municipal_water", + customer_type="enterprise", + role_code="salesperson", + days=1, + )[0] == Fraction(2, 1000) + assert decide( + params, category="rail_transit", customer_type="enterprise", role_code="salesperson", days=1 + )[0] == Fraction(5, 1000) + group, ship, cash, facts, values = inputs() + ship.update(amount="10000", cost_total="5000", customer_type="enterprise") + cash["amount"] = "10000" + facts["personnel"]["001"]["role_code"] = "info_officer" + values["2026-03-10"]["info"] = "1" + result = run(group, [ship], [cash], facts, values) + assert result["lines"][0]["performance"] == "10.00" + assert result["lines"][0]["calculation_base"] == "10000.00" + + +def test_指导单位成本自动换算且冲突拒绝(): + data = { + "product_name": "测试", + "product_category": "municipal_water", + "quantity": "10", + "sale_price": "100", + "guided_price": "50", + } + assert BusinessItemIn(**data).cost_amount == "500.00" + with pytest.raises(ValueError, match="不一致"): + BusinessItemIn(**data, cost_amount="501") + + +def test_系数工作簿往返与派生冲突(monkeypatch): + snapshot = ParamSnapshot.from_rows(date.today(), []) + monkeypatch.setattr( + coefficient_service.param_service, "resolve_snapshot", lambda *args: snapshot + ) + content = coefficient_service.workbook(None) + preview = coefficient_service.import_preview(None, content) + assert preview["can_apply"], preview["errors"] + assert len(preview["rows"]) == 8 + book = load_workbook(BytesIO(content)) + book["系数参数"].cell(2, 7, "999999") + output = BytesIO() + book.save(output) + assert not coefficient_service.import_preview(None, output.getvalue())["can_apply"] + + +def test_非法系数整批拒绝(): + row = CoefficientRowIn( + product_category="municipal_water", + customer_type="personal", + stage="linear", + value="1", + slope="1", + boundary=360, + ) + with pytest.raises(BadRequestError): + coefficient_service.items_for([row]) + + +def test_分界整数小数原文及预收优先级(): + params = {"start": "10", "first": "11", "slope": "4/360", "floor": "4", "boundary": "360.0", "enterprise": "3"} + rate, _, _ = decide(params, category="municipal_water", customer_type="personal", role_code="salesperson", days=360) + assert rate == Fraction(6, 1000) + rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="salesperson", days=-8, prepayment=True) + assert rate == Fraction(11, 1000) + rate, _, _ = decide(params, category="municipal_water", customer_type="enterprise", role_code="sales_manager", days=1) + assert rate == 0 + + +def test_基本工资仅计一次及试用期年度结清(payroll_case): + from sqlalchemy import select + + from app.models import MonthlySettlement, PerformanceLedger, Personnel + from app.services import payroll_calculation, probation_service, salary_completion + + session, user, _ = payroll_case + business = imported(payroll_case) + payroll_calculation.calculate(session, business.id, "2026-03", operator=user) + row = session.scalar(select(PerformanceLedger)) + person = session.get(Personnel, row.personnel_id) + person.salary_standard = Decimal("3000") + person.probation_start_date = date(2026, 3, 1) + person.regularization_date = date(2027, 1, 1) + row.is_probation = 1 + row.performance_amount = Decimal("20000") + row.payable_amount = Decimal("1000") + session.flush() + before, _ = salary_completion.build_items(session, [row], "2026-11") + item = next(i for i in before if i.personnel_id == person.id) + assert item.basic_amount == Decimal("3000") + assert item.net_amount == Decimal("3000") + assert item.payable_sum == 0 + assert item.deferred_sum == Decimal("1000") + december, _ = salary_completion.build_items(session, [row], "2026-12") + item = next(i for i in december if i.personnel_id == person.id) + assert item.probation_payment_sum == Decimal("1000") + assert item.net_amount == Decimal("4000") + settlement = MonthlySettlement( + settlement_month="2026-12", status=3, submitter_id=user.id, submitter_name=user.username + ) + session.add(settlement) + session.flush() + probation_service.persist_payments(session, settlement, december) + session.flush() + assert probation_service.preview(session, "2027-01")["rows"][0]["delta"] == "0.00" + row.payable_amount = Decimal("3000") + session.flush() + assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal( + "1000" + ) + row.performance_amount = Decimal("18000") + session.flush() + assert Decimal(probation_service.preview(session, "2027-01")["rows"][0]["delta"]) == Decimal( + "-1000" + ) + + +def test_无绩效在职人员有基本工资(payroll_case): + from sqlalchemy import select + + from app.models import Personnel + from app.services import salary_completion + + session, _, _ = payroll_case + person = session.scalar(select(Personnel).where(Personnel.source_no == "001")) + if person is None: + person = session.scalar(select(Personnel)) + person.salary_standard = Decimal("3000") + person.is_probation = 1 + session.flush() + items, ids = salary_completion.build_items(session, [], "2026-11") + item = next(i for i in items if i.personnel_id == person.id) + assert item.net_amount == Decimal("3000") + assert ids == [] + + +def test_最近有效发货顺序与人工保护(work_session): + from app.models import Product + from app.services.product_completion import recent_trades + from app.services.product_service import _to_out + from tests.unit.test_tplus_workbench import source + + source( + work_session, + "shipments", + [ + {"id": 20, "code": "SH-20", "voucherdate": "2026-11-01", "voucherState": 189}, + {"id": 21, "code": "SH-21", "voucherdate": "2026-11-01", "voucherState": 189}, + { + "id": 22, + "code": "SH-22", + "voucherdate": "2026-11-02", + "voucherState": 189, + "isCounteractDelivery": 1, + }, + ], + ) + source( + work_session, + "shipment_items", + [ + { + "id": 1, + "source_parent_id": 20, + "idinventory": 9, + "quantity": "2", + "taxPrice": "12.1234", + }, + { + "id": 2, + "source_parent_id": 21, + "idinventory": 9, + "quantity": "3", + "taxPrice": "22.1234", + }, + {"id": 3, "source_parent_id": 22, "idinventory": 9, "quantity": "4", "taxPrice": "99"}, + ], + ) + trade = recent_trades(work_session, "test_source", ["9"])["9"] + assert trade["source_no"] == "SH-21" + assert trade["quantity"] == "3" + product = Product( + product_name="测试来源", + spec_model="", + product_category="municipal_water", + source_trade_json=trade, + stock_quantity=Decimal("7"), + sale_price=Decimal("88"), + quantity_price_manual=1, + status=1, + ) + work_session.add(product) + work_session.flush() + assert _to_out(product).stock_quantity == "7" + product.quantity_price_manual = 0 + assert _to_out(product).sale_price == "22.1234" + + +def test_产品提取冲突不覆盖已有档案(payroll_case): + from sqlalchemy import select + + from app.models import BusinessItem, Product + from app.services.product_completion import extraction_plan + + session, _, _ = payroll_case + business = imported(payroll_case) + item = session.scalar(select(BusinessItem).where(BusinessItem.business_id == business.id)) + target = session.get(Product, item.product_id) + item.product_id = None + target.unit = "不同单位" + session.flush() + plan = extraction_plan(session, [item.id]) + assert plan["rows"][0]["action"] == "CONFLICT" + assert target.unit == "不同单位" + + +def test_历史工资标准及离职按目标日期解析(work_session): + from types import SimpleNamespace + + from app.services import personnel_salary + + person = SimpleNamespace( + salary_standard=Decimal("9000"), + business_role_id=1, + hire_date=date(2026, 1, 1), + resignation_date=date(2026, 10, 1), + probation_start_date=date(2026, 1, 1), + regularization_date=date(2026, 4, 1), + is_probation=0, + status=0, + salary_history_json=[ + { + "effective_date": "2026-01-01", + "salary_standard": "3000", + "business_role_id": "1", + "hire_date": "2026-01-01", + "resignation_date": "2026-10-01", + "probation_start_date": "2026-01-01", + "regularization_date": "2026-04-01", + "is_probation": "1", + } + ], + ) + profile = personnel_salary.resolve(person, date(2026, 3, 31)) + assert profile.salary_standard == Decimal("3000") + assert personnel_salary.employed(person, profile, date(2026, 3, 31)) + assert not personnel_salary.employed(person, profile, date(2026, 10, 31)) + + +def test_驳回的后续结清不阻塞早月且待审占用分列(payroll_case): + from sqlalchemy import select + + from app.models import AdjustmentOrder, PerformanceLedger, ProbationPayment + from app.services import payroll_calculation, probation_service, salary_completion + + session, user, _ = payroll_case + business = imported(payroll_case) + payroll_calculation.calculate(session, business.id, "2026-03", operator=user) + ledger = session.scalar(select(PerformanceLedger)) + ledger.is_probation = 1 + ledger.performance_amount = Decimal("20000") + ledger.payable_amount = Decimal("3000") + order = AdjustmentOrder(order_no="TZ-test-probation", personnel_id=ledger.personnel_id, + personnel_name=ledger.personnel_name, settlement_month="2027-02", amount=Decimal("1000"), + direction=1, reason="测试驳回释放", status=3, submitter_id=user.id, submitter_name=user.username) + session.add(order) + session.flush() + session.add(ProbationPayment(adjustment_order_id=order.id, personnel_id=ledger.personnel_id, + year="2026", month="2027-02", amount=Decimal("1000"), snapshot_json={})) + session.flush() + items, _ = salary_completion.build_items(session, [], "2027-01") + assert next(i for i in items if i.personnel_id == ledger.personnel_id).probation_payment_sum == Decimal("2000") + order.status = 1 + session.flush() + data = probation_service.preview(session, "2027-02")["rows"][0] + assert Decimal(data["paid"]) == 0 + assert Decimal(data["reserved"]) == Decimal("1000") + assert Decimal(data["delta"]) == Decimal("1000") + with pytest.raises(BadRequestError, match="后续月份"): + salary_completion.build_items(session, [], "2027-01") diff --git a/backend/tests/unit/test_role_schemas.py b/backend/tests/unit/test_role_schemas.py index 0345558..81034dc 100644 --- a/backend/tests/unit/test_role_schemas.py +++ b/backend/tests/unit/test_role_schemas.py @@ -54,6 +54,25 @@ class TestRoleCodePattern: assert _create(role_code=" finance ").role_code == "finance" +class TestInitialPermissions: + def test_旧创建请求兼容且默认不授权(self): + assert _create().permission_codes is None + + def test_初始授权无需手动填写修改原因(self): + payload = _create(permission_codes=["base:personnel:view"]) + assert payload.reason is None + + @pytest.mark.parametrize("reason", ["", " "]) + def test_显式提供的原因不能是空白(self, reason): + with pytest.raises(ValidationError): + _create(permission_codes=["base:personnel:view"], reason=reason) + + def test_初始权限和原因保留(self): + payload = _create(permission_codes=["base:personnel:view"], reason=" 增加资料查看职责 ") + assert payload.permission_codes == ["base:personnel:view"] + assert payload.reason == "增加资料查看职责" + + class TestRoleUpdate: """编辑模型:不含 role_code。""" diff --git a/backend/tests/unit/test_tplus_automation_config.py b/backend/tests/unit/test_tplus_automation_config.py new file mode 100644 index 0000000..3e43b7d --- /dev/null +++ b/backend/tests/unit/test_tplus_automation_config.py @@ -0,0 +1,94 @@ +"""自动采集保存值由独立维护读取,关闭不破坏正在运行的任务。""" +from datetime import datetime + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import Settings +from app.core.exceptions import ConflictError +from app.models import OperationLog, TPlusAutomationConfig, TPlusSyncBatch, User +from app.schemas.tplus import TPlusAutomationUpdate +from app.services.tplus import automation, automation_config, task_service +from tests.unit.test_tplus_workbench import work_session as work_session + + +@pytest.fixture +def source_settings(monkeypatch): + settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True, + t_plus_database="test_source", t_plus_user="reader", t_plus_odbc_driver="unit", + t_plus_automation_enabled=True) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + return settings + + +def update(s, enabled, version=0): + actor = task_service.TaskActor(s.get(User, 1)) + return automation_config.save(s, TPlusAutomationUpdate( + enabled=enabled, expected_version=version), actor) + + +def test_初次沿用环境且页面关闭后独立维护不再排队(work_session, source_settings): + s = work_session + initial = automation_config.configuration(s, source_settings) + assert initial == {"enabled": True, "version": 0, "editable": True, + "configuration_origin": "ENVIRONMENT"} + result = update(s, False) + assert result["enabled"] is False and result["version"] == 1 + with Session(s.get_bind()) as other: + assert automation.tick(other, settings=source_settings) == {"status": "DISABLED", "queued": False} + assert s.scalar(select(TPlusSyncBatch)) is None + assert s.scalar(select(OperationLog).where(OperationLog.action == "修改自动采集")) is not None + + +def test_保存开启覆盖环境关闭且同周不重复(work_session, source_settings): + settings = source_settings.model_copy(update={"t_plus_automation_enabled": False}) + update(work_session, True) + with Session(work_session.get_bind()) as other: + first = automation.tick(other, settings=settings, now=datetime(2026, 11, 2, 3)) + assert first["queued"] + assert not automation.tick(other, settings=settings, now=datetime(2026, 11, 2, 4))["queued"] + + +def test_关闭不终止活动批次且仍可手动排队(work_session, source_settings): + s = work_session + s.add(TPlusSyncBatch(batch_no="running", source_database="test_source", sync_mode="FULL", + status="RUNNING", entity_types_json=["personnel"])) + s.commit() + update(s, False) + assert s.scalar(select(TPlusSyncBatch.status)) == "RUNNING" + assert automation.tick(s, settings=source_settings)["status"] == "DISABLED" + from uuid import uuid4 + + from app.schemas.tplus import TPlusCreateIn + + queued = task_service.create_task(s, TPlusCreateIn(request_key=uuid4(), entity_types=["personnel"])) + assert queued.status == "QUEUED" + + +def test_旧版本不能覆盖新的开关(work_session, source_settings): + s = work_session + update(s, False) + with pytest.raises(ConflictError): + update(s, True, 0) + assert not bool(s.scalar(select(TPlusAutomationConfig.enabled))) + result = update(s, True, 1) + assert result["enabled"] and result["version"] == 2 + + +def test_不同源的开关不互相覆盖(work_session, source_settings): + update(work_session, False) + other = source_settings.model_copy(update={"t_plus_database": "other_source"}) + assert automation_config.configuration(work_session, other)["enabled"] is True + + +def test_没有采集权限不能保存(work_session, source_settings, monkeypatch): + from app.core.exceptions import ForbiddenError + + def denied(*args): + raise ForbiddenError() + + monkeypatch.setattr(automation_config, "require_permission", denied) + with pytest.raises(ForbiddenError): + update(work_session, False) + assert work_session.scalar(select(TPlusAutomationConfig)) is None diff --git a/backend/tests/unit/test_tplus_progress.py b/backend/tests/unit/test_tplus_progress.py new file mode 100644 index 0000000..b9ef264 --- /dev/null +++ b/backend/tests/unit/test_tplus_progress.py @@ -0,0 +1,80 @@ +"""真实分页进度在每页提交后可读;总量未知或失败时不伪造成功。""" +from datetime import datetime +from unittest.mock import Mock + +from sqlalchemy import select + +from app.models import TPlusSyncBatch +from app.services.tplus import progress +from app.services.tplus.sync_service import TPlusSyncService +from tests.unit.test_tplus_sync import target as target + + +def test_每页提交真实已读数及类别(target): + service = TPlusSyncService(Mock(), target) + service.reader = Mock() + service.reader.count_entities.return_value = {"products": 2} + + def rows(*args, **kwargs): + yield {"id": 1} + batch = target.scalar(select(TPlusSyncBatch)) + item = progress.public(batch) + assert item["processed_records"] == 1 and item["total_records"] == 2 + assert item["percentage"] == 50.0 and item["active_entity"] == "products" + yield {"id": 2} + + service.reader.iter_entity.side_effect = rows + batch = service.run(entity_types=["products"], batch_size=1) + item = progress.public(batch) + assert batch.status == "SUCCEEDED" and item["percentage"] == 100.0 + assert item["entities"][0]["status"] == "COMPLETED" + + +def test_总量统计失败仍可读且执行中总量未知(target): + source = Mock() + service = TPlusSyncService(source, target) + service.reader = Mock() + service.reader.count_entities.side_effect = RuntimeError("不得公开的数据库详情") + + def rows(*args, **kwargs): + batch = target.scalar(select(TPlusSyncBatch)) + assert progress.public(batch)["percentage"] is None + yield {"id": 1} + + service.reader.iter_entity.side_effect = rows + batch = service.run(entity_types=["products"]) + assert batch.status == "SUCCEEDED" + source.rollback.assert_called_once() + + +def test_读取中断保留进度及错误终态(target): + service = TPlusSyncService(Mock(), target) + service.reader = Mock() + service.reader.count_entities.return_value = {"products": 4} + + def rows(*args, **kwargs): + yield {"id": 1} + raise TimeoutError("敏感详情") + + service.reader.iter_entity.side_effect = rows + batch = service.run(entity_types=["products"], batch_size=1) + item = progress.public(batch) + assert batch.status == "FAILED" and item["percentage"] == 25.0 + assert item["phase"] == "FAILED" and item["entities"][0]["status"] == "FAILED" + + +def test_旧批次未知总量不显示编造百分比(): + batch = TPlusSyncBatch(batch_no="old", sync_mode="FULL", status="RUNNING", + source_record_count=1000, entity_types_json=["personnel"], started_at=datetime.now()) + item = progress.public(batch) + assert item["percentage"] is None and item["total_records"] is None + assert item["processed_records"] == 1000 + + +def test_读到预统计总量仍等终态才显示百分之百(): + batch = TPlusSyncBatch(batch_no="b", sync_mode="FULL", status="RUNNING", + source_record_count=10, entity_types_json=["products"], started_at=datetime.now(), + audit_result_json={"progress": {"total_records": 10}}) + assert progress.public(batch)["percentage"] == 99.9 + batch.status = "SUCCEEDED" + assert progress.public(batch)["percentage"] == 100.0 diff --git a/backend/tests/unit/test_tplus_task_service.py b/backend/tests/unit/test_tplus_task_service.py index d4836d9..1e95970 100644 --- a/backend/tests/unit/test_tplus_task_service.py +++ b/backend/tests/unit/test_tplus_task_service.py @@ -15,6 +15,16 @@ def _create(**kwargs): return TPlusCreateIn(request_key=uuid4(), entity_types=["products"], **kwargs) +def test_关闭源读取仍禁止新建采集任务(tplus_session, monkeypatch): + from app.core.config import Settings + + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + with pytest.raises(BadRequestError, match="未启用"): + task_service.create_task(tplus_session, _create()) + assert tplus_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0 + + def test_同键同意图复用同一批次(tplus_session): request = _create() first = task_service.create_task(tplus_session, request) diff --git a/backend/tests/unit/test_tplus_workbench.py b/backend/tests/unit/test_tplus_workbench.py index 7f67438..560817b 100644 --- a/backend/tests/unit/test_tplus_workbench.py +++ b/backend/tests/unit/test_tplus_workbench.py @@ -24,7 +24,7 @@ from app.models import ( TPlusSyncRecord, TPlusWorkRun, ) -from app.schemas.tplus_workbench import FactIn, WorkCreateIn +from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery from app.services.tplus import workbench from app.services.tplus.mapping import get_mapping from app.services.tplus.master_import import execute_master @@ -47,6 +47,30 @@ def work_session(monkeypatch): monkeypatch.setattr(workbench, "_source_database", lambda: "test_source") monkeypatch.setattr(workbench, "sync_lock", lambda s: nullcontext()) with Session(engine, expire_on_commit=False) as session: + from app.models import ( + AuthorizationSetting, + Permission, + Role, + RolePermission, + User, + UserRole, + ) + from app.services.permission_catalog import CATALOG, ROLE_DEFAULTS + + session.add(AuthorizationSetting(id=1, revision=1)) + actor = User(id=1, username="test_operator", password="test_hash_only", status=1) + session.add(actor) + permission_rows = {code: Permission(perm_code=code, perm_name=code, perm_type=2, status=1) + for code in CATALOG} + session.add_all(permission_rows.values()) + session.flush() + for code, grants in ROLE_DEFAULTS.items(): + role = Role(role_code=code, role_name=code, status=1) + session.add(role) + session.flush() + session.add_all([RolePermission(role_id=role.id, permission_id=permission_rows[p].id) for p in grants]) + if code == "admin": + actor.user_roles.append(UserRole(role_id=role.id)) session.add( BusinessRole( role_name="营销员", @@ -139,6 +163,89 @@ def prepare_person(session): ) +def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch): + from app.core.config import Settings + from app.services.tplus import task_service + + s = work_session + prepare_person(s) + run = preview(s, ["personnel"]) + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + + def disabled_source(): + raise RuntimeError("T+ 数据源未启用") + + monkeypatch.setattr(workbench, "_source_database", disabled_source) + facts = workbench.list_facts(s, WorkQuery()) + assert facts.total == 1 + assert workbench.list_work(s, WorkQuery()).total == 1 + assert workbench.get_run(s, run.run_no).run_no == run.run_no + assert workbench.fact_history(s, facts.records[0]["id"], WorkQuery()).total == 1 + assert workbench.list_lines(s, run.run_no, WorkQuery()).total == 1 + assert "仅供核对" in "".join(workbench.report_csv(s, run.run_no)) + + +def test_关闭源读取有多个账套时拒绝猜测(work_session, monkeypatch): + from app.core.config import Settings + from app.core.exceptions import BadRequestError + from app.services.tplus import task_service + + prepare_person(work_session) + work_session.add(TPlusFact( + source_database="another_source", kind="PRODUCT", fact_key="9", version=1, + data_json={"category": "municipal_water", "unit": "件"}, + evidence="其他账套测试", requested_by=1, + )) + work_session.commit() + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + with pytest.raises(BadRequestError, match="多个账套"): + workbench.list_facts(work_session, WorkQuery()) + + +def test_历史查询显式账套范围不能串账(work_session, monkeypatch): + from app.core.config import Settings + from app.services.tplus import task_service + + prepare_person(work_session) + settings = Settings( + _env_file=None, app_secret_key="x" * 32, t_plus_database="another_source" + ) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + assert workbench.list_facts(work_session, WorkQuery()).total == 0 + + +def test_关闭读取时概况仍显示最近成功采集(work_session, monkeypatch): + from app.core.config import Settings + from app.services.tplus import query_service, task_service + + prepare_person(work_session) + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + overview = query_service.overview(work_session) + assert overview.is_configured is False + assert "仍可查看" in overview.configuration_message + assert overview.automation.enabled is False + assert overview.automation.last_success_at is not None + assert overview.total_staged_records >= 0 + + +def test_关闭SQL源读取仍可维护已存账套补充资料(work_session, monkeypatch): + from app.core.config import Settings + from app.core.exceptions import BadRequestError + from app.services.tplus import task_service + + prepare_person(work_session) + settings = Settings(_env_file=None, app_secret_key="x" * 32) + monkeypatch.setattr(task_service, "get_settings", lambda: settings) + def disabled_source(): + raise BadRequestError("T+ 数据源未启用") + monkeypatch.setattr(workbench, "_source_database", disabled_source) + data = fact(work_session, "PRODUCT", "9", {"category":"municipal_water", "unit":"件"}) + assert data["version"] == 1 + + def test_人员真实导入且重跑不重复_保留人工工资与岗位(work_session): s = work_session prepare_person(s) diff --git a/backend/uv.lock b/backend/uv.lock index e9b0231..b71151f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -430,6 +430,7 @@ dependencies = [ { name = "fastapi" }, { name = "openpyxl" }, { name = "pillow" }, + { name = "psutil" }, { name = "pwdlib", extra = ["bcrypt"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -463,6 +464,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=11.0" }, + { name = "psutil", specifier = ">=7.2,<8" }, { name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, @@ -512,6 +514,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pwdlib" version = "0.3.1" diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 652478a..5eaf7a5 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -22,6 +22,8 @@ DROP TABLE IF EXISTS `tplus_sync_errors`; DROP TABLE IF EXISTS `tplus_sync_batches`; DROP TABLE IF EXISTS `performance_ledgers`; DROP TABLE IF EXISTS `settlement_items`; +DROP TABLE IF EXISTS `user_permission_overrides`; +DROP TABLE IF EXISTS `authorization_settings`; DROP TABLE IF EXISTS `adjustment_orders`; DROP TABLE IF EXISTS `monthly_settlements`; DROP TABLE IF EXISTS `approval_steps`; @@ -270,6 +272,10 @@ CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_v -- 营销人员表(V4 迁移;T+ 同步不覆盖岗位与工资标准) -- --------------------------------------------------------------------------- CREATE TABLE personnel ( + salary_history_json JSON COMMENT '工资标准、岗位及任职资料生效版本', + resignation_date DATE COMMENT '离职生效日(当日起不计基本工资)', + regularization_date DATE COMMENT '转正生效日(当日不再属于试用期)', + probation_start_date DATE COMMENT '试用期开始日(含当日)', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, personnel_name VARCHAR(64) NOT NULL COMMENT '姓名', business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)', @@ -336,6 +342,8 @@ CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id); -- 产品表(V4 迁移;类别锚定折算阈值参数,T+ 同步不覆盖产品类别) -- --------------------------------------------------------------------------- CREATE TABLE products ( + quantity_price_manual SMALLINT NOT NULL COMMENT '数量单价人工保护:1人工维护 0随源更新' DEFAULT '0', + source_trade_json JSON COMMENT '最近有效发货数量、含税成交价及源单据快照', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, product_name VARCHAR(128) NOT NULL COMMENT '产品名称', spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '', @@ -804,6 +812,9 @@ CREATE TABLE monthly_settlements ( )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci; CREATE TABLE settlement_items ( + probation_payment_sum NUMERIC(14, 2) NOT NULL COMMENT '本月年度补发或扣回' DEFAULT '0', + deferred_sum NUMERIC(14, 2) NOT NULL COMMENT '当月试用期缓发变化' DEFAULT '0', + normal_payable_sum NUMERIC(14, 2) NOT NULL COMMENT '当月正常实付绩效' DEFAULT '0', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)', personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)', @@ -1074,7 +1085,7 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('21f2a3b4c5d6'); + -- 正式工资输入、FIFO过程及生成锁定;新增表,不改写历史结果。 @@ -1142,3 +1153,563 @@ CREATE TABLE tplus_automation_states ( CONSTRAINT pk_tplus_automation_states PRIMARY KEY (id), CONSTRAINT uk_tplus_automation_states_source_key UNIQUE (source_key) )ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集周期与进程状态(不自动导入正式业务)' COLLATE utf8mb4_general_ci; + +-- V22 完整权限管理;原有账号与工资数据保留。 +CREATE TABLE authorization_settings ( + id BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, + maker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', + revision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', + updated_by BIGINT UNSIGNED COMMENT '最近修改人ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_authorization_settings PRIMARY KEY (id), + CONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci; + +CREATE TABLE user_permission_overrides ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', + permission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', + effect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), + CONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), + CONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('dashboard:view','首页:查看',1,1,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:view','用户管理:查看',1,2,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:create','用户管理:新增',2,3,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:update','用户管理:编辑',2,4,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:delete','用户管理:删除',2,5,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:status','用户管理:变更状态',2,6,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:password','用户管理:重置密码',2,7,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:view','角色管理:查看',1,8,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:create','角色管理:新增',2,9,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:update','角色管理:编辑',2,10,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:delete','角色管理:删除',2,11,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:status','角色管理:变更状态',2,12,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:authorization:view','权限与工资制作人:查看',1,13,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:authorization:update','权限与工资制作人:编辑',2,14,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:view','岗位角色:查看',1,15,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:create','岗位角色:新增',2,16,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:update','岗位角色:编辑',2,17,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:delete','岗位角色:删除',2,18,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:status','岗位角色:变更状态',2,19,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:view','计提参数:查看',1,20,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:update','计提参数:编辑',2,21,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:withdraw','计提参数:撤回参数',2,22,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:export','计提参数:导出',2,23,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:preview','计提参数:预览',2,24,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:settings:view','公司信息:查看',1,25,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:settings:update','公司信息:编辑',2,26,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:logs:view','日志查询:查看',1,27,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:view','人员管理:查看',1,28,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:create','人员管理:新增',2,29,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:update','人员管理:编辑',2,30,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:delete','人员管理:删除',2,31,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:status','人员管理:变更状态',2,32,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:regions','人员管理:维护区域',2,33,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:plans','人员管理:维护计划',2,34,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:view','产品管理:查看',1,35,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:create','产品管理:新增',2,36,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:update','产品管理:编辑',2,37,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:delete','产品管理:删除',2,38,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:status','产品管理:变更状态',2,39,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:view','客户管理:查看',1,40,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:create','客户管理:新增',2,41,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:update','客户管理:编辑',2,42,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:delete','客户管理:删除',2,43,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:rating','客户管理:客户评级',2,44,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:ownership','客户管理:变更归属',2,45,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:view','业务管理:查看',1,46,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:create','业务管理:新增',2,47,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:update','业务管理:编辑',2,48,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:delete','业务管理:删除',2,49,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:status','业务管理:变更状态',2,50,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:ownership','业务管理:变更归属',2,51,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:view','发货管理:查看',1,52,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:create','发货管理:新增',2,53,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:update','发货管理:编辑',2,54,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:delete','发货管理:删除',2,55,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:view','回款管理:查看',1,56,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:create','回款管理:新增',2,57,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:update','回款管理:编辑',2,58,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:delete','回款管理:删除',2,59,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:view','评级标准:查看',1,60,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:create','评级标准:新增',2,61,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:update','评级标准:编辑',2,62,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:delete','评级标准:删除',2,63,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:view','采集与导入:查看',1,64,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:collect','采集与导入:创建采集',2,65,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:retry','采集与导入:重试采集',2,66,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:preview','采集与导入:预览',2,67,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:import','采集与导入:执行导入',2,68,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:facts','采集与导入:维护补充资料',2,69,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:export','采集与导入:导出',2,70,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:view','正式核算与分配:查看',1,71,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:prepare','正式核算与分配:准备核算资料',2,72,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:calculate','正式核算与分配:核算与重算',2,73,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:allocation','正式核算与分配:修改工资分配',2,74,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:view','月度工资:查看',1,75,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:generate','月度工资:生成工资',2,76,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:submit','月度工资:提交审核',2,77,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('adjustments:view','工资调整:查看',1,78,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('adjustments:create','工资调整:新增',2,79,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:view','审核记录:查看',1,80,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:finance','审核记录:财务复核',2,81,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:leader','审核记录:领导审核',2,82,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:business:view','业务统计:查看',1,83,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:business:export','业务统计:导出',2,84,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:personal:view','个人明细:查看',1,85,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:personal:export','个人明细:导出',2,86,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:department:view','部门汇总:查看',1,87,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:department:export','部门汇总:导出',2,88,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:payments:view','回款统计:查看',1,89,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:payments:export','回款统计:导出',2,90,1); +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:password'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:authorization:view' SET p.parent_id=parent.id WHERE p.perm_code='system:authorization:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:withdraw'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:preview'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:settings:view' SET p.parent_id=parent.id WHERE p.perm_code='system:settings:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:regions'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:plans'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:rating'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:ownership'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:ownership'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:collect'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:retry'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:preview'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:import'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:facts'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:prepare'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:calculate'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:allocation'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='settlements:view' SET p.parent_id=parent.id WHERE p.perm_code='settlements:generate'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='settlements:view' SET p.parent_id=parent.id WHERE p.perm_code='settlements:submit'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='adjustments:view' SET p.parent_id=parent.id WHERE p.perm_code='adjustments:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='approvals:view' SET p.parent_id=parent.id WHERE p.perm_code='approvals:finance'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='approvals:view' SET p.parent_id=parent.id WHERE p.perm_code='approvals:leader'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:business:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:business:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:personal:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:personal:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:department:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:department:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:payments:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:payments:export'; +INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1); +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:finance' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:leader' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:ownership' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:rating' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:plans' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:regions' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:ownership' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:allocation' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:calculate' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:prepare' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:generate' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:submit' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:authorization:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:authorization:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:logs:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:withdraw' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:settings:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:settings:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:password' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:collect' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:facts' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:import' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:preview' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:retry' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:finance' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:leader' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:ownership' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:rating' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:plans' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:regions' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:ownership' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:allocation' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:calculate' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:prepare' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:generate' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:submit' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:collect' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:export' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:facts' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:import' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:preview' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:retry' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:view' WHERE r.role_code='payroll_maker'; +INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,NULL,1); + +-- V23:系统监控,独立新表,不修改业务及审计数据。 + +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1); +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'; + +CREATE TABLE monitor_snapshots ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + minute DATETIME NOT NULL COMMENT '采集分钟,北京时间', + sample_count INTEGER NOT NULL COMMENT '实际采样次数', + resources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', + requests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', + CONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), + CONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute); + +CREATE TABLE monitor_request_minutes ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + minute DATETIME NOT NULL COMMENT '请求分钟,北京时间', + method VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', + route VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', + metrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', + CONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), + CONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route) +)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 ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + kind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', + code VARCHAR(64) NOT NULL COMMENT '稳定错误码', + component VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', + level VARCHAR(16) NOT NULL COMMENT '异常级别', + message VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', + fingerprint VARCHAR(64) COMMENT '同类健康异常合并键', + request_id VARCHAR(32) COMMENT '请求关联编号', + method VARCHAR(8) COMMENT 'HTTP方法', + route VARCHAR(256) COMMENT '路由模板', + status_code INTEGER COMMENT 'HTTP状态码', + duration_ms FLOAT COMMENT '请求耗时,毫秒', + batch_no VARCHAR(32) COMMENT '相关同步批次编号', + first_seen DATETIME NOT NULL COMMENT '首次发生时间', + last_seen DATETIME NOT NULL COMMENT '最近发生时间', + occurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', + resolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', + CONSTRAINT pk_monitor_events PRIMARY KEY (id) +)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); + +-- V24 需求补齐 +-- Running upgrade 23b4c5d6e7f8 -> 24c5d6e7f8a9 + +CREATE TABLE probation_deferrals ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', + year VARCHAR(4) NOT NULL COMMENT '原计提年度', + month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', + accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', + eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), + CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), + CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year); + +CREATE TABLE probation_payments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', + adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', + year VARCHAR(4) NOT NULL COMMENT '原累计年度', + month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', + amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', + snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_probation_payments PRIMARY KEY (id), + CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), + CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), + CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, + CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.first', '个人户首日比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 52, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.start', '个人户线性阶段起始比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 53, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.slope', '个人户线性阶段日斜率', '产品系数表', 'DECIMAL', '‰/天', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 54, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.floor', '个人户超期比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 55, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.boundary', '个人户线性阶段截止天数', '产品系数表', 'INT', '天', 'PRODUCT_CATEGORY', '1', '3660', '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 56, 1); + + + +-- 新增模块只授予管理员,普通角色不会随版本扩张权限。 +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT 'system:monitor:view','系统监控:查看',1,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code='system:monitor:view'); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT 'system:monitor:export','系统监控:导出',2,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code='system:monitor:export'); +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'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code IN ('system:monitor:view','system:monitor:export') 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); + +-- V26:仅允许没有个人权限覆盖的环境应用。 +-- 执行前核对 SELECT COUNT(*) FROM user_permission_overrides 的结果为 0; +-- 存在覆盖时先整理为等效角色。Alembic 版本会强制核验,禁止静默忽略。 +-- 旧表和历史工资制作人、月份锁定数据均保留。 +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 '%审批链%'; + +-- V27:T+ 自动采集设置 +CREATE TABLE tplus_automation_configs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要', + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + enabled TINYINT NOT NULL COMMENT '自动采集是否开启:1开启0关闭', + version INTEGER NOT NULL COMMENT '设置版本号,防止并发覆盖', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最近修改账号ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_automation_configs PRIMARY KEY (id), + CONSTRAINT uk_tplus_automation_configs_source_key UNIQUE (source_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集设置(不含连接凭据)' COLLATE utf8mb4_general_ci; + +-- V28:计提公式模板定义;公式骨架受控,页面只维护名称、说明和参数绑定。 +CREATE TABLE calc_template_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + template_code VARCHAR(32) NOT NULL COMMENT '模板编码,创建后不可修改;岗位表calc_template引用', + template_name VARCHAR(64) NOT NULL COMMENT '模板名称', + formula_type VARCHAR(32) NOT NULL COMMENT '公式骨架类型:linear_decay/fixed_profit/fixed_sales/manager_share/prepay_fixed,创建后不可修改', + params_json JSON NOT NULL COMMENT '骨架参数角色到参数编码的JSON映射', + description VARCHAR(255) NULL COMMENT '模板说明', + sort_order INTEGER NOT NULL DEFAULT 0 COMMENT '显示排序', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用0停用', + is_system TINYINT NOT NULL DEFAULT 0 COMMENT '是否系统模板:1内置(禁删、骨架与编码锁定)0自定义', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_calc_template_definitions PRIMARY KEY (id), + CONSTRAINT uk_calc_template_definitions_template_code UNIQUE (template_code) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='计提公式模板定义表(页面可维护,骨架受控)' COLLATE utf8mb4_general_ci; + +INSERT INTO calc_template_definitions + (template_code, template_name, formula_type, params_json, description, sort_order, status, is_system) +VALUES + ('TPL_SALESPERSON_DECAY', '业务员个人户·天数线性递减', 'linear_decay', JSON_OBJECT('start','salesperson.rate.start','slope','salesperson.rate.slope','floor','salesperson.rate.floor','boundary','salesperson.days.boundary'), '分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限', 10, 1, 1), + ('TPL_MANAGER_SHARE', '营销经理·团队与全公司分成', 'manager_share', JSON_OBJECT('team','manager.share.team','company','manager.share.company'), '聚合:本团队业务员绩效×团队比例 + 全公司业务员绩效×公司比例', 20, 1, 1), + ('TPL_ENTERPRISE_FIXED', '企业户·固定计提', 'fixed_profit', JSON_OBJECT('rate','enterprise.rate'), '乘法:折算毛利润额 × 固定计提比例', 30, 1, 1), + ('TPL_INFO_SALES_FIXED', '信息员·销售额固定比例', 'fixed_sales', JSON_OBJECT('rate','info.rate'), '乘法:本人销售额 × 固定计提比例', 40, 1, 1), + ('TPL_PREPAY_FIXED', '预付款·固定计提', 'prepay_fixed', JSON_OBJECT('rate','prepay.rate'), '乘法:预付回款额 × 预付比例', 50, 1, 1); + +-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 +INSERT INTO alembic_version (version_num) VALUES ('28a9b0c1d2e3'); diff --git a/db/migration/V22__authorization.sql b/db/migration/V22__authorization.sql new file mode 100644 index 0000000..bcd85e1 --- /dev/null +++ b/db/migration/V22__authorization.sql @@ -0,0 +1,392 @@ +-- V22 完整权限管理;原有账号与工资数据保留。 +CREATE TABLE authorization_settings ( + id BIGINT UNSIGNED NOT NULL COMMENT '单例主键ID' AUTO_INCREMENT, + maker_user_id BIGINT UNSIGNED COMMENT '现任工资制作人账号ID', + revision INTEGER NOT NULL COMMENT '全局授权版本' DEFAULT '1', + updated_by BIGINT UNSIGNED COMMENT '最近修改人ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_authorization_settings PRIMARY KEY (id), + CONSTRAINT fk_authorization_settings_maker_user_id FOREIGN KEY(maker_user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT fk_authorization_settings_updated_by FOREIGN KEY(updated_by) REFERENCES users (id) ON DELETE SET NULL +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='授权设置表(单例与并发修改锁)' COLLATE utf8mb4_general_ci; + +CREATE TABLE user_permission_overrides ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL COMMENT '系统用户ID', + permission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID', + effect VARCHAR(8) NOT NULL COMMENT '个人覆盖:ALLOW允许 DENY禁止', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_user_permission_overrides PRIMARY KEY (id), + CONSTRAINT uk_user_permission_overrides_user_permission UNIQUE (user_id, permission_id), + CONSTRAINT fk_user_permission_overrides_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_user_permission_overrides_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='用户权限覆盖表(个人禁止优先)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_user_permission_overrides_permission_id ON user_permission_overrides (permission_id); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('dashboard:view','首页:查看',1,1,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:view','用户管理:查看',1,2,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:create','用户管理:新增',2,3,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:update','用户管理:编辑',2,4,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:delete','用户管理:删除',2,5,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:status','用户管理:变更状态',2,6,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:users:password','用户管理:重置密码',2,7,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:view','角色管理:查看',1,8,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:create','角色管理:新增',2,9,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:update','角色管理:编辑',2,10,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:delete','角色管理:删除',2,11,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:roles:status','角色管理:变更状态',2,12,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:authorization:view','权限与工资制作人:查看',1,13,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:authorization:update','权限与工资制作人:编辑',2,14,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:view','岗位角色:查看',1,15,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:create','岗位角色:新增',2,16,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:update','岗位角色:编辑',2,17,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:delete','岗位角色:删除',2,18,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:business_roles:status','岗位角色:变更状态',2,19,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:view','计提参数:查看',1,20,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:update','计提参数:编辑',2,21,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:withdraw','计提参数:撤回参数',2,22,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:export','计提参数:导出',2,23,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:params:preview','计提参数:预览',2,24,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:settings:view','公司信息:查看',1,25,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:settings:update','公司信息:编辑',2,26,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:logs:view','日志查询:查看',1,27,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:view','人员管理:查看',1,28,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:create','人员管理:新增',2,29,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:update','人员管理:编辑',2,30,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:delete','人员管理:删除',2,31,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:status','人员管理:变更状态',2,32,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:regions','人员管理:维护区域',2,33,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:personnel:plans','人员管理:维护计划',2,34,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:view','产品管理:查看',1,35,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:create','产品管理:新增',2,36,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:update','产品管理:编辑',2,37,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:delete','产品管理:删除',2,38,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:products:status','产品管理:变更状态',2,39,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:view','客户管理:查看',1,40,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:create','客户管理:新增',2,41,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:update','客户管理:编辑',2,42,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:delete','客户管理:删除',2,43,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:rating','客户管理:客户评级',2,44,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('base:customers:ownership','客户管理:变更归属',2,45,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:view','业务管理:查看',1,46,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:create','业务管理:新增',2,47,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:update','业务管理:编辑',2,48,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:delete','业务管理:删除',2,49,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:status','业务管理:变更状态',2,50,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:businesses:ownership','业务管理:变更归属',2,51,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:view','发货管理:查看',1,52,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:create','发货管理:新增',2,53,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:update','发货管理:编辑',2,54,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:shipments:delete','发货管理:删除',2,55,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:view','回款管理:查看',1,56,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:create','回款管理:新增',2,57,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:update','回款管理:编辑',2,58,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:receipts:delete','回款管理:删除',2,59,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:view','评级标准:查看',1,60,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:create','评级标准:新增',2,61,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:update','评级标准:编辑',2,62,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('business:ratings:delete','评级标准:删除',2,63,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:view','采集与导入:查看',1,64,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:collect','采集与导入:创建采集',2,65,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:retry','采集与导入:重试采集',2,66,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:preview','采集与导入:预览',2,67,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:import','采集与导入:执行导入',2,68,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:facts','采集与导入:维护补充资料',2,69,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('tplus:export','采集与导入:导出',2,70,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:view','正式核算与分配:查看',1,71,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:prepare','正式核算与分配:准备核算资料',2,72,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:calculate','正式核算与分配:核算与重算',2,73,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('payroll:allocation','正式核算与分配:修改工资分配',2,74,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:view','月度工资:查看',1,75,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:generate','月度工资:生成工资',2,76,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('settlements:submit','月度工资:提交审核',2,77,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('adjustments:view','工资调整:查看',1,78,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('adjustments:create','工资调整:新增',2,79,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:view','审核记录:查看',1,80,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:finance','审核记录:财务复核',2,81,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('approvals:leader','审核记录:领导审核',2,82,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:business:view','业务统计:查看',1,83,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:business:export','业务统计:导出',2,84,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:personal:view','个人明细:查看',1,85,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:personal:export','个人明细:导出',2,86,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:department:view','部门汇总:查看',1,87,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:department:export','部门汇总:导出',2,88,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:payments:view','回款统计:查看',1,89,1); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('reports:payments:export','回款统计:导出',2,90,1); +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:users:view' SET p.parent_id=parent.id WHERE p.perm_code='system:users:password'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:roles:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:authorization:view' SET p.parent_id=parent.id WHERE p.perm_code='system:authorization:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:business_roles:view' SET p.parent_id=parent.id WHERE p.perm_code='system:business_roles:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:withdraw'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:params:view' SET p.parent_id=parent.id WHERE p.perm_code='system:params:preview'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:settings:view' SET p.parent_id=parent.id WHERE p.perm_code='system:settings:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:regions'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:personnel:view' SET p.parent_id=parent.id WHERE p.perm_code='base:personnel:plans'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:products:view' SET p.parent_id=parent.id WHERE p.perm_code='base:products:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:rating'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='base:customers:view' SET p.parent_id=parent.id WHERE p.perm_code='base:customers:ownership'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:status'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:businesses:view' SET p.parent_id=parent.id WHERE p.perm_code='business:businesses:ownership'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:shipments:view' SET p.parent_id=parent.id WHERE p.perm_code='business:shipments:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id WHERE p.perm_code='business:receipts:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:update'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:ratings:view' SET p.parent_id=parent.id WHERE p.perm_code='business:ratings:delete'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:collect'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:retry'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:preview'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:import'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:facts'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='tplus:view' SET p.parent_id=parent.id WHERE p.perm_code='tplus:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:prepare'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:calculate'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='payroll:view' SET p.parent_id=parent.id WHERE p.perm_code='payroll:allocation'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='settlements:view' SET p.parent_id=parent.id WHERE p.perm_code='settlements:generate'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='settlements:view' SET p.parent_id=parent.id WHERE p.perm_code='settlements:submit'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='adjustments:view' SET p.parent_id=parent.id WHERE p.perm_code='adjustments:create'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='approvals:view' SET p.parent_id=parent.id WHERE p.perm_code='approvals:finance'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='approvals:view' SET p.parent_id=parent.id WHERE p.perm_code='approvals:leader'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:business:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:business:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:personal:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:personal:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:department:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:department:export'; +UPDATE permissions p JOIN permissions parent ON parent.perm_code='reports:payments:view' SET p.parent_id=parent.id WHERE p.perm_code='reports:payments:export'; +INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1); +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:finance' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:leader' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:ownership' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:rating' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:plans' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:regions' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:ownership' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:allocation' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:calculate' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:prepare' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:generate' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:submit' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:authorization:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:authorization:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:business_roles:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:logs:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:withdraw' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:roles:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:settings:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:settings:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:create' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:delete' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:password' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:status' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:update' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:users:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:collect' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:export' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:facts' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:import' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:preview' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:retry' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:view' WHERE r.role_code='admin'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='user'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='marketing_ops'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:finance' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='finance'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:leader' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='dashboard:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:export' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:preview' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:params:view' WHERE r.role_code='leader'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='adjustments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='approvals:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:ownership' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:rating' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:customers:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:plans' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:regions' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:personnel:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='base:products:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:ownership' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:status' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:businesses:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:ratings:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:create' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:delete' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:update' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:shipments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:allocation' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:calculate' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:prepare' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='payroll:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:business:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:department:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:payments:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='reports:personal:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:generate' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:submit' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='settlements:view' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:collect' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:export' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:facts' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:import' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:preview' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:retry' WHERE r.role_code='payroll_maker'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='tplus:view' WHERE r.role_code='payroll_maker'; +INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,NULL,1); diff --git a/db/migration/V23__system_monitor.sql b/db/migration/V23__system_monitor.sql new file mode 100644 index 0000000..1eb601a --- /dev/null +++ b/db/migration/V23__system_monitor.sql @@ -0,0 +1,58 @@ +-- V23:系统监控,独立新表,不修改业务及审计数据。 + +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1); +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'; + +CREATE TABLE monitor_snapshots ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + minute DATETIME NOT NULL COMMENT '采集分钟,北京时间', + sample_count INTEGER NOT NULL COMMENT '实际采样次数', + resources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', + requests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', + CONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), + CONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute); + +CREATE TABLE monitor_request_minutes ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + minute DATETIME NOT NULL COMMENT '请求分钟,北京时间', + method VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', + route VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', + metrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', + CONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), + CONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route) +)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 ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + instance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', + kind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', + code VARCHAR(64) NOT NULL COMMENT '稳定错误码', + component VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', + level VARCHAR(16) NOT NULL COMMENT '异常级别', + message VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', + fingerprint VARCHAR(64) COMMENT '同类健康异常合并键', + request_id VARCHAR(32) COMMENT '请求关联编号', + method VARCHAR(8) COMMENT 'HTTP方法', + route VARCHAR(256) COMMENT '路由模板', + status_code INTEGER COMMENT 'HTTP状态码', + duration_ms FLOAT COMMENT '请求耗时,毫秒', + batch_no VARCHAR(32) COMMENT '相关同步批次编号', + first_seen DATETIME NOT NULL COMMENT '首次发生时间', + last_seen DATETIME NOT NULL COMMENT '最近发生时间', + occurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', + resolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', + CONSTRAINT pk_monitor_events PRIMARY KEY (id) +)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); diff --git a/db/migration/V24__requirement_completion.sql b/db/migration/V24__requirement_completion.sql new file mode 100644 index 0000000..e560023 --- /dev/null +++ b/db/migration/V24__requirement_completion.sql @@ -0,0 +1,66 @@ +-- Running upgrade 23b4c5d6e7f8 -> 24c5d6e7f8a9 + +ALTER TABLE personnel ADD COLUMN probation_start_date DATE COMMENT '试用期开始日(含当日)'; + +ALTER TABLE personnel ADD COLUMN regularization_date DATE COMMENT '转正生效日(当日不再属于试用期)'; + +ALTER TABLE personnel ADD COLUMN resignation_date DATE COMMENT '离职生效日(当日起不计基本工资)'; + +ALTER TABLE personnel ADD COLUMN salary_history_json JSON COMMENT '工资标准、岗位及任职资料生效版本'; + +ALTER TABLE products ADD COLUMN source_trade_json JSON COMMENT '最近有效发货数量、含税成交价及源单据快照'; + +ALTER TABLE products ADD COLUMN quantity_price_manual SMALLINT NOT NULL COMMENT '数量单价人工保护:1人工维护 0随源更新' DEFAULT '0'; + +ALTER TABLE settlement_items ADD COLUMN normal_payable_sum NUMERIC(14, 2) NOT NULL COMMENT '当月正常实付绩效' DEFAULT '0'; + +ALTER TABLE settlement_items ADD COLUMN deferred_sum NUMERIC(14, 2) NOT NULL COMMENT '当月试用期缓发变化' DEFAULT '0'; + +ALTER TABLE settlement_items ADD COLUMN probation_payment_sum NUMERIC(14, 2) NOT NULL COMMENT '本月年度补发或扣回' DEFAULT '0'; + +CREATE TABLE probation_deferrals ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', + year VARCHAR(4) NOT NULL COMMENT '原计提年度', + month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', + accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', + eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), + CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), + CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year); + +CREATE TABLE probation_payments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', + adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', + personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', + year VARCHAR(4) NOT NULL COMMENT '原累计年度', + month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', + amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', + snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_probation_payments PRIMARY KEY (id), + CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), + CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), + CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, + CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.first', '个人户首日比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 52, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.start', '个人户线性阶段起始比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 53, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.slope', '个人户线性阶段日斜率', '产品系数表', 'DECIMAL', '‰/天', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 54, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.floor', '个人户超期比例', '产品系数表', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', '0', NULL, '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 55, 1); + +INSERT INTO param_definitions (param_code, param_name, param_group, value_type, unit, dimension_type, value_min, value_max, description, sort_order, status) VALUES ('coefficient.personal.boundary', '个人户线性阶段截止天数', '产品系数表', 'INT', '天', 'PRODUCT_CATEGORY', '1', '3660', '按产品类别维护;未配置时沿用同日期的通用业务员参数。', 56, 1); + + diff --git a/db/migration/V25__monitor_permissions.sql b/db/migration/V25__monitor_permissions.sql new file mode 100644 index 0000000..3e76a83 --- /dev/null +++ b/db/migration/V25__monitor_permissions.sql @@ -0,0 +1,5 @@ +-- 新增模块只授予管理员,普通角色不会随版本扩张权限。 +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT 'system:monitor:view','系统监控:查看',1,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code='system:monitor:view'); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT 'system:monitor:export','系统监控:导出',2,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code='system:monitor:export'); +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'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code IN ('system:monitor:view','system:monitor:export') 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); diff --git a/db/migration/V26__role_authorization.sql b/db/migration/V26__role_authorization.sql new file mode 100644 index 0000000..ae32b39 --- /dev/null +++ b/db/migration/V26__role_authorization.sql @@ -0,0 +1,8 @@ +-- V26:仅允许没有个人权限覆盖的环境应用。 +-- 执行前核对 SELECT COUNT(*) FROM user_permission_overrides 的结果为 0; +-- 存在覆盖时先整理为等效角色。Alembic 版本会强制核验,禁止静默忽略。 +-- 旧表和历史工资制作人、月份锁定数据均保留。 +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 '%审批链%'; diff --git a/db/migration/V27__tplus_automation_config.sql b/db/migration/V27__tplus_automation_config.sql new file mode 100644 index 0000000..1fc26eb --- /dev/null +++ b/db/migration/V27__tplus_automation_config.sql @@ -0,0 +1,13 @@ +-- T+ 页面自动采集开关;新增独立表,不修改活动采集数据。 +CREATE TABLE tplus_automation_configs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_key VARCHAR(64) NOT NULL COMMENT '源连接身份摘要', + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + enabled TINYINT NOT NULL COMMENT '自动采集是否开启:1开启0关闭', + version INTEGER NOT NULL COMMENT '设置版本号,防止并发覆盖', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最近修改账号ID', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_automation_configs PRIMARY KEY (id), + CONSTRAINT uk_tplus_automation_configs_source_key UNIQUE (source_key) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+自动采集设置(不含连接凭据)' COLLATE utf8mb4_general_ci; diff --git a/db/migration/V28__calc_template_definitions.sql b/db/migration/V28__calc_template_definitions.sql new file mode 100644 index 0000000000000000000000000000000000000000..7dd767f486caaeb37b5316832b7a2e9d2c549378 GIT binary patch literal 2641 zcmeH{TaVK?6vy{<`4mweA!&V&6V!)cw?k*8X$y3ii+FK-X)Bm0wyrt!Y`t2QO;a^JS*`rEh{ar6*M?D8 zTBqkt>AJXWAt*#~1ydjO!irh78}26NvRy#YoKKHqFh+LY+>UEPu8)i1?8SaLdS=VL zctuWGF{~#i^!f$fT5>yiSr~UUx#^n`A$0jSd^H&O-CViSVFr^FL&^Xrn1Cp3%cGyf z45Wxh;YvH9gcD)*{;?dVQMhTAKG$!l(YrI!Lg}XMf(j{h?R*Gd%R*z$^mC4=YV4gkjhD_1fdB;(4<^qCWtW-uy>(fw-rs-oJmv8OB)3z z_vt^pMqmI3x6KSLNu**gN-vW?|i80&X#+> zJTkSn#gARH(RX)uPkh=^`{pGGr%(!=sFV?rCXflLb47s88J7n9U%+4(DBf5`cj|Pv(e4Xtt-zK z$!@xNT^A#W^<{bT8L*6{zNv__2VfI|fdBY1uMcYaAMv`isyo%a(zZ@4m+=-p>kxh( ztOu|MqTq3h*8|YwQSh|?^gB79ZFCd>9E_{&N6uk}aE8;zoC_c&1-V4XaLF}cJU282 zIRctOC^Sg