feat(后端): 添加授权体系、系统监控与试用期计提

按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
This commit is contained in:
liuwenxin 2026-11-03 11:20:55 +08:00
parent 02fb06e0af
commit 1461a8d113
149 changed files with 8736 additions and 368 deletions

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
"""新增运行监控快照、接口聚合与诊断事件。"""
from alembic import op
from sqlalchemy import text
revision: str = "23b4c5d6e7f8"
down_revision: str | None = "22a3b4c5d6e7"
branch_labels = None
depends_on = None
DDL = [
"CREATE TABLE monitor_snapshots (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '采集分钟,北京时间', \n\tsample_count INTEGER NOT NULL COMMENT '实际采样次数', \n\tresources JSON NOT NULL COMMENT '资源均值与采集范围,不含配置', \n\trequests JSON NOT NULL COMMENT '请求汇总与固定耗时直方图', \n\tCONSTRAINT pk_monitor_snapshots PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_snapshots_instance_minute UNIQUE (instance_id, minute)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='分钟级运行监控快照' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_snapshots_minute ON monitor_snapshots (minute)",
"CREATE TABLE monitor_request_minutes (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tminute DATETIME NOT NULL COMMENT '请求分钟,北京时间', \n\tmethod VARCHAR(8) NOT NULL COMMENT 'HTTP方法,固定集合', \n\troute VARCHAR(256) NOT NULL COMMENT '路由模板,不含原始地址及参数', \n\tmetrics JSON NOT NULL COMMENT '请求数、状态分类与固定直方图', \n\tCONSTRAINT pk_monitor_request_minutes PRIMARY KEY (id), \n\tCONSTRAINT uk_monitor_request_minutes_endpoint UNIQUE (instance_id, minute, method, route)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='接口分钟级运行统计' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_request_minutes_minute ON monitor_request_minutes (minute)",
"CREATE TABLE monitor_events (\n\tid BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n\tinstance_id VARCHAR(32) NOT NULL COMMENT '应用实例标识', \n\tkind VARCHAR(24) NOT NULL COMMENT '事件类型,固定集合', \n\tcode VARCHAR(64) NOT NULL COMMENT '稳定错误码', \n\tcomponent VARCHAR(128) NOT NULL COMMENT '所属组件或日志模块', \n\tlevel VARCHAR(16) NOT NULL COMMENT '异常级别', \n\tmessage VARCHAR(256) NOT NULL COMMENT '通用说明,不保存内部异常文本', \n\tfingerprint VARCHAR(64) COMMENT '同类健康异常合并键', \n\trequest_id VARCHAR(32) COMMENT '请求关联编号', \n\tmethod VARCHAR(8) COMMENT 'HTTP方法', \n\troute VARCHAR(256) COMMENT '路由模板', \n\tstatus_code INTEGER COMMENT 'HTTP状态码', \n\tduration_ms FLOAT COMMENT '请求耗时,毫秒', \n\tbatch_no VARCHAR(32) COMMENT '相关同步批次编号', \n\tfirst_seen DATETIME NOT NULL COMMENT '首次发生时间', \n\tlast_seen DATETIME NOT NULL COMMENT '最近发生时间', \n\toccurrences INTEGER NOT NULL COMMENT '发生或持续采样次数', \n\tresolved_at DATETIME COMMENT '恢复时间,仅用于健康异常', \n\tCONSTRAINT pk_monitor_events PRIMARY KEY (id)\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='脱敏诊断事件与异常恢复记录' COLLATE utf8mb4_general_ci",
"CREATE INDEX idx_monitor_events_fingerprint ON monitor_events (fingerprint, resolved_at)",
"CREATE INDEX idx_monitor_events_last_seen ON monitor_events (last_seen)",
"CREATE INDEX idx_monitor_events_request_id ON monitor_events (request_id)",
]
def upgrade():
for statement in DDL:
op.execute(text(statement))
op.execute(text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES ('system:monitor:view','系统监控:查看',1,100,1)"))
op.execute(text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='system:monitor:view' WHERE r.role_code='admin'"))
def downgrade():
# 应用回滚可保留新增监控表;有历史数据时拒绝隐式删除。
bind = op.get_bind()
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
if bind.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在监控历史,拒绝自动删除;请回退应用并保留新增表")
for table in ("monitor_events", "monitor_request_minutes", "monitor_snapshots"):
op.drop_table(table)
bind.execute(text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code='system:monitor:view'"))
bind.execute(text("DELETE FROM permissions WHERE perm_code='system:monitor:view'"))

View File

@ -0,0 +1,152 @@
"""V24 需求补齐:试用期台账、产品来源及分产品系数参数。"""
import sqlalchemy as sa
from alembic import op
revision = "24c5d6e7f8a9"
down_revision = "23b4c5d6e7f8"
branch_labels = None
depends_on = None
PROBATION_DDL = "CREATE TABLE probation_deferrals (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n ledger_id BIGINT UNSIGNED NOT NULL COMMENT '原核算台账版本ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原计提年度', \n month VARCHAR(7) NOT NULL COMMENT '本次计提或应付变化月份', \n accrued NUMERIC(14, 2) NOT NULL COMMENT '本次试用期计提额,可负', \n eligible NUMERIC(14, 2) NOT NULL COMMENT '满足起付及权重的本次应付差额,可负', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_deferrals PRIMARY KEY (id), \n CONSTRAINT uk_probation_deferrals_ledger_id UNIQUE (ledger_id), \n CONSTRAINT fk_probation_deferrals_ledger_id FOREIGN KEY(ledger_id) REFERENCES performance_ledgers (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期缓发台账(每个正式台账版本一条)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_deferrals_personnel_id_year ON probation_deferrals (personnel_id, year);\n\nCREATE TABLE probation_payments (\n id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, \n settlement_id BIGINT UNSIGNED COMMENT '月结ID(当月调整单结清时为空)', \n adjustment_order_id BIGINT UNSIGNED COMMENT '已生成工资后的结清调整单ID', \n personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属人员ID', \n year VARCHAR(4) NOT NULL COMMENT '原累计年度', \n month VARCHAR(7) NOT NULL COMMENT '本次实际支付月份', \n amount NUMERIC(14, 2) NOT NULL COMMENT '本次补发或扣回金额', \n snapshot_json JSON NOT NULL COMMENT '计提、起付权重、门槛及历史补发依据', \n create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, \n CONSTRAINT pk_probation_payments PRIMARY KEY (id), \n CONSTRAINT uk_probation_payments_settlement_person_year UNIQUE (settlement_id, personnel_id, year), \n CONSTRAINT uk_probation_payments_adjustment_order_id UNIQUE (adjustment_order_id), \n CONSTRAINT fk_probation_payments_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE, \n CONSTRAINT fk_probation_payments_adjustment_order_id FOREIGN KEY(adjustment_order_id) REFERENCES adjustment_orders (id) ON DELETE RESTRICT\n)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='试用期年终及后续结清记录(工资生成事务内固化)' COLLATE utf8mb4_general_ci;\n\nCREATE INDEX idx_probation_payments_personnel_id_year ON probation_payments (personnel_id, year);"
def upgrade():
op.add_column(
"personnel",
sa.Column(
"probation_start_date", sa.Date(), nullable=True, comment="试用期开始日(含当日)"
),
)
op.add_column(
"personnel",
sa.Column(
"regularization_date",
sa.Date(),
nullable=True,
comment="转正生效日(当日不再属于试用期)",
),
)
op.add_column(
"personnel",
sa.Column(
"resignation_date", sa.Date(), nullable=True, comment="离职生效日(当日起不计基本工资)"
),
)
op.add_column(
"personnel",
sa.Column(
"salary_history_json",
sa.JSON(),
nullable=True,
comment="工资标准、岗位及任职资料生效版本",
),
)
op.add_column(
"products",
sa.Column(
"source_trade_json",
sa.JSON(),
nullable=True,
comment="最近有效发货数量、含税成交价及源单据快照",
),
)
op.add_column(
"products",
sa.Column(
"quantity_price_manual",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="数量单价人工保护:1人工维护 0随源更新",
),
)
for name, title in (
("normal_payable_sum", "当月正常实付绩效"),
("deferred_sum", "当月试用期缓发变化"),
("probation_payment_sum", "本月年度补发或扣回"),
):
op.add_column(
"settlement_items",
sa.Column(name, sa.Numeric(14, 2), server_default="0", nullable=False, comment=title),
)
for statement in PROBATION_DDL.split(";"):
if statement.strip():
op.execute(sa.text(statement))
# 仅建立新参数定义,不写任何历史生效值;未覆盖项继续回溯原通用参数。
fields = (
("first", "个人户首日比例", "DECIMAL", "‰"),
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
("floor", "个人户超期比例", "DECIMAL", "‰"),
("boundary", "个人户线性阶段截止天数", "INT", "天"),
)
definitions = sa.table(
"param_definitions",
*[
sa.column(name, sa.String())
for name in (
"param_code",
"param_name",
"param_group",
"value_type",
"unit",
"dimension_type",
"value_min",
"value_max",
"description",
)
],
sa.column("sort_order", sa.Integer()),
sa.column("status", sa.Integer()),
)
op.bulk_insert(
definitions,
[
dict(
param_code=f"coefficient.personal.{field}",
param_name=title,
param_group="产品系数表",
value_type=kind,
unit=unit,
dimension_type="PRODUCT_CATEGORY",
value_min="1" if kind == "INT" else "0",
value_max="3660" if kind == "INT" else None,
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。",
sort_order=52 + index,
status=1,
)
for index, (field, title, kind, unit) in enumerate(fields)
],
)
def downgrade():
bind = op.get_bind()
for table, condition in (("personnel", "salary_history_json IS NOT NULL"), ("products", "source_trade_json IS NOT NULL OR quantity_price_manual<>0")):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")).scalar():
raise RuntimeError("存在人员或产品新增资料,请回退应用并保留业务结构")
for table in ("probation_payments", "probation_deferrals"):
if bind.execute(sa.text(f"SELECT COUNT(*) FROM {table}")).scalar():
raise RuntimeError("存在试用期结算记录,请回退应用并保留业务数据")
if bind.execute(
sa.text(
"SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code LIKE 'coefficient.personal.%'"
)
).scalar():
raise RuntimeError("存在已维护的产品系数,请保留新结构")
bind.execute(
sa.text("DELETE FROM param_definitions WHERE param_code LIKE 'coefficient.personal.%'")
)
op.drop_table("probation_payments")
op.drop_table("probation_deferrals")
for name in ("normal_payable_sum", "deferred_sum", "probation_payment_sum"):
op.drop_column("settlement_items", name)
op.drop_column("products", "quantity_price_manual")
op.drop_column("products", "source_trade_json")
op.drop_column("personnel", "regularization_date")
op.drop_column("personnel", "salary_history_json")
op.drop_column("personnel", "resignation_date")
op.drop_column("personnel", "probation_start_date")

View File

@ -0,0 +1,24 @@
"""V25 将新增系统监控接入统一功能权限,不修改已应用的 V22。"""
from alembic import op
import sqlalchemy as sa
revision: str = "25d6e7f8a9b0"
down_revision: str | None = "24c5d6e7f8a9"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
for code, name, kind in (
("system:monitor:view", "系统监控:查看", 1),
("system:monitor:export", "系统监控:导出", 2),
):
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), dict(code=code,name=name,kind=kind))
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), dict(code=code))
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))
def downgrade():
# V23 也可能已登记目录;回退此兼容版本不删除任何既有授权。
return

View File

@ -0,0 +1,31 @@
"""授权归一到角色,停用全局制作人入口,保留旧结构与历史工资依据。"""
from alembic import op
from sqlalchemy import text
revision: str = "26e7f8a9b0c1"
down_revision: str | None = "25d6e7f8a9b0"
branch_labels = None
depends_on = None
STATEMENTS = (
"UPDATE authorization_settings SET maker_user_id=NULL, revision=revision+1 WHERE id=1",
"UPDATE permissions SET status=0 WHERE perm_code IN ('system:authorization:view','system:authorization:update')",
"UPDATE roles SET role_name='工资经办', description='按角色授权维护核算资料、计算工资及生成工资;月份锁定和实际经办人留痕继续生效' WHERE role_code='payroll_maker' AND role_name='工资制作人'",
"UPDATE roles SET description='管理业务基础资料,具体功能由角色权限配置决定' WHERE role_code='marketing_ops' AND description LIKE '%审批链%'",
)
def upgrade():
bind = op.get_bind()
if bind.execute(text("SELECT COUNT(*) FROM user_permission_overrides")).scalar():
raise RuntimeError("存在个人权限覆盖,请先整理为等效角色后再升级,避免隐式改变用户权限")
for statement in STATEMENTS:
op.execute(text(statement))
def downgrade():
# 只恢复旧入口,不猜测重新绑定哪个账号。旧版本需由管理员重新指定。
op.execute(text("UPDATE permissions SET status=1 WHERE perm_code IN ('system:authorization:view','system:authorization:update')"))
op.execute(text("UPDATE roles SET role_name='工资制作人' WHERE role_code='payroll_maker' AND role_name='工资经办'"))
op.execute(text("UPDATE authorization_settings SET revision=revision+1 WHERE id=1"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "已设为离职")

View File

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

View File

@ -0,0 +1,22 @@
"""表单参照选项,不能由此读取薪酬、联系方式或完整业务对象。"""
from typing import Annotated
from fastapi import APIRouter, Query
from app.api.deps import DbSession, LoginUser
from app.core.response import ok
from app.services.authorization_metadata import options, payroll_options
router = APIRouter(prefix="/meta", tags=["业务参照"])
@router.get("/payroll-input/{business_id}")
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser):
return ok(payroll_options(session, actor, business_id))
@router.get("/options")
def metadata_options(session: DbSession, actor: LoginUser, resource: str,
business_id: Annotated[int | None, Query(ge=1)] = None,
search: Annotated[str | None, Query(max_length=64)] = None):
return ok(options(session, actor, resource, business_id=business_id, search=search))

View File

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

View File

@ -0,0 +1,56 @@
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
from fastapi import APIRouter
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ok
from app.schemas.authorization import (
AuthorizationPreviewIn,
AuthorizationSettingsIn,
RolePermissionsIn,
UserAuthorizationIn,
)
from app.services import authorization_management as service
from app.services.permission_catalog import CATALOG
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
@router.get("/permissions/catalog")
def catalog(session: DbSession, actor: AdminUser):
service.require_admin(session, actor)
return ok(list(CATALOG.values()))
@router.get("/roles/{role_id}/permissions")
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
return ok(service.role_permissions(session, role_id))
@router.put("/roles/{role_id}/permissions")
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_role(session, role_id, payload, actor, meta))
@router.get("/users/{user_id}/authorization")
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
return ok(service.user_authorization(session, user_id))
@router.post("/users/{user_id}/authorization/preview")
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
return ok(service.preview(session, user_id, payload))
@router.put("/users/{user_id}/authorization")
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_user(session, user_id, payload, actor, meta))
@router.get("/authorization-settings")
def settings(session: DbSession, actor: AdminUser):
return ok(service.settings_out(session))
@router.put("/authorization-settings")
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_settings(session, payload, actor, meta))

View File

@ -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="岗位详情")

View File

@ -0,0 +1,70 @@
"""计提公式模板管理接口(管理员)。"""
from typing import Annotated
from fastapi import APIRouter, Path
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, ok
from app.schemas.calc_template import (
CalcTemplateCreateIn,
CalcTemplateOut,
CalcTemplateUpdateIn,
FormulaOptionOut,
)
from app.services import calc_template_service
router = APIRouter(prefix="/calc-templates", tags=["系统管理 · 公式模板"])
@router.get("", response_model=ApiResponse[list[CalcTemplateOut]], summary="模板列表")
def list_templates(session: DbSession, _: AdminUser) -> ApiResponse[list[CalcTemplateOut]]:
"""取全部模板(含停用与岗位引用计数)。"""
return ok(calc_template_service.list_templates(session).records)
@router.get("/meta/formulas", response_model=ApiResponse[list[FormulaOptionOut]], summary="公式骨架下拉")
def list_formula_options(_: AdminUser) -> ApiResponse[list[FormulaOptionOut]]:
"""取受控公式骨架集合(新增模板时选择)。"""
return ok([FormulaOptionOut.model_validate(row) for row in calc_template_service.list_formula_options()])
@router.post("", response_model=ApiResponse[CalcTemplateOut], summary="新增模板")
def create_template(
session: DbSession,
current_user: AdminUser,
payload: CalcTemplateCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""新增公式模板。编码唯一、骨架受控、参数绑定按骨架角色校验。"""
return ok(
calc_template_service.create_template(session, payload, operator=current_user, meta=meta),
message="模板已创建",
)
@router.put("/{template_id}", response_model=ApiResponse[CalcTemplateOut], summary="编辑模板")
def update_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
payload: CalcTemplateUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""编辑模板名称/说明/排序;自定义模板可调整参数绑定。"""
return ok(
calc_template_service.update_template(session, template_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{template_id}", response_model=ApiResponse[None], summary="删除模板")
def delete_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除模板。系统模板禁删;被岗位引用禁删。"""
calc_template_service.delete_template(session, template_id, operator=current_user, meta=meta)
return ok(message="模板已删除")

View File

@ -0,0 +1,37 @@
"""系数表查询、维护与工作簿导入导出,复用计提参数权限。"""
from datetime import date
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import Response
from app.api.deps import DbSession, LoginUser, RequestMetaDep
from app.core.response import ok
from app.schemas.coefficient import CoefficientChangeIn
from app.services import coefficient_service
router = APIRouter(prefix="/coefficients", tags=["计提系数表"])
@router.get("")
def catalog(session: DbSession, _: LoginUser, as_of: date | None = None):
return ok(coefficient_service.catalog(session, as_of))
@router.put("")
def save(payload: CoefficientChangeIn, session: DbSession, actor: LoginUser, meta: RequestMetaDep):
return ok(coefficient_service.save(session, payload, actor, meta))
@router.get("/export")
def export(session: DbSession, _: LoginUser, as_of: date | None = None):
return Response(
coefficient_service.workbook(session, as_of),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="coefficients.xlsx"'},
)
@router.post("/import-preview")
def import_preview(session: DbSession, _: LoginUser, file: UploadFile = File(...)):
return ok(coefficient_service.import_preview(session, file.file.read(2 * 1024 * 1024 + 1)))

View File

@ -0,0 +1,56 @@
"""系统监控只读接口;全部复用管理员依赖,不提供运维写操作。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.monitor import EventOut, EventQuery, MonitorWindow, OverviewOut
from app.services import monitor_service
router = APIRouter(prefix="/monitor", tags=["系统管理 · 系统监控"])
@router.get("/overview", response_model=ApiResponse[OverviewOut], summary="健康总览")
def overview(request: Request, session: DbSession, _: AdminUser):
return ok(monitor_service.overview(session, request.app.state.monitor))
@router.get("/trends", response_model=ApiResponse[dict], summary="分钟级监控趋势")
def trends(
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
):
return ok(monitor_service.trends(session, request.app.state.monitor, query))
@router.get("/interfaces", response_model=ApiResponse[dict], summary="接口性能排行")
def interfaces(
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
):
return ok(monitor_service.interfaces(session, request.app.state.monitor, query))
@router.get("/events", response_model=ApiResponse[PageResult[EventOut]], summary="诊断事件")
def events(session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]):
return ok(monitor_service.events(session, query))
@router.get("/events/{event_id}", response_model=ApiResponse[EventOut], summary="诊断事件详情")
def event(session: DbSession, _: AdminUser, event_id: Annotated[int, Path(ge=1)]):
return ok(monitor_service.event(session, event_id))
@router.get("/diagnostic", summary="下载脱敏诊断摘要")
def diagnostic(
request: Request, session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]
):
return JSONResponse(
jsonable_encoder(monitor_service.diagnostic(session, request.app.state.monitor, query)),
headers={
"Content-Disposition": 'attachment; filename="monitor-diagnostic.json"',
"Cache-Control": "no-store",
},
)

View File

@ -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()]):
"""分页查询批次元数据。"""

View File

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

View File

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

View File

@ -52,3 +52,10 @@ class NotFoundError(AppException):
code = CODE_NOT_FOUND
message = "资源不存在"
class ConflictError(AppException):
"""授权版本已变化,客户端应刷新后重新核对。"""
code = 409
message = "配置已被其他人修改,请刷新后重新核对"

View File

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

View File

@ -0,0 +1,240 @@
"""有界、线程安全的请求遥测;请求线程只写内存,绝不等待监控数据库。"""
import hashlib
import logging
import math
import threading
from collections import OrderedDict, deque
from contextvars import ContextVar
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from time import monotonic, perf_counter
from uuid import uuid4
from starlette.types import ASGIApp, Message, Receive, Scope, Send
REQUEST_ID: ContextVar[str] = ContextVar("request_id", default="-")
SHANGHAI = timezone(timedelta(hours=8))
# 固定上界直方图,可相加后求近似 P95;最后一档使用实际最大耗时。
BOUNDS = (
5,
10,
25,
50,
100,
200,
300,
500,
750,
1000,
1500,
2000,
3000,
5000,
10000,
30000,
60000,
120000,
300000,
)
def now_local() -> datetime:
return datetime.now(SHANGHAI).replace(tzinfo=None)
def minute_of(value: datetime) -> datetime:
return value.replace(second=0, microsecond=0)
def empty_metric() -> dict:
return {
"count": 0,
"errors_5xx": 0,
"errors_4xx": 0,
"max_ms": 0.0,
"histogram": [0] * (len(BOUNDS) + 1),
"last_error_at": None,
}
def merge_metrics(items) -> dict:
result = empty_metric()
for item in items:
for key in ("count", "errors_5xx", "errors_4xx"):
result[key] += item[key]
result["max_ms"] = max(result["max_ms"], item["max_ms"])
for index, count in enumerate(item["histogram"]):
result["histogram"][index] += count
last = item.get("last_error_at")
if last and (not result["last_error_at"] or last > result["last_error_at"]):
result["last_error_at"] = last
result["error_rate"] = (
round(result["errors_5xx"] / result["count"] * 100, 2) if result["count"] else 0
)
result["p95_ms"] = percentile(result)
return result
def percentile(metric: dict) -> float | None:
if not metric["count"]:
return None
target, count = math.ceil(metric["count"] * 0.95), 0
for index, value in enumerate(metric["histogram"]):
count += value
if count >= target:
return min(BOUNDS[index], metric["max_ms"]) if index < len(BOUNDS) else metric["max_ms"]
return None
def fingerprint(code: str, component: str = "") -> str:
return hashlib.sha256(f"{code}:{component}".encode()).hexdigest()
class Recorder:
"""最多保留十分钟、每分钟 200 个路由、2000 条待写事件。"""
def __init__(self):
self.lock = threading.Lock()
self.minutes = OrderedDict()
self.events = deque(maxlen=2000)
self.dropped_events = 0
self.started = monotonic()
self.instance_id = uuid4().hex[:16]
self.slow_ms = 2000
def record(self, method, route, status, elapsed, request_id, *, at=None):
at = at or now_local()
key = minute_of(at)
with self.lock:
endpoints = self.minutes.setdefault(key, {})
endpoint = (method, route)
if endpoint not in endpoints and len(endpoints) >= 200:
endpoint = ("OTHER", "/__other__")
metric = endpoints.setdefault(endpoint, empty_metric())
metric["count"] += 1
metric["errors_5xx"] += int(status >= 500)
metric["errors_4xx"] += int(400 <= status < 500)
metric["max_ms"] = max(metric["max_ms"], round(elapsed, 2))
bucket = next((i for i, bound in enumerate(BOUNDS) if elapsed <= bound), len(BOUNDS))
metric["histogram"][bucket] += 1
if status >= 500:
metric["last_error_at"] = at.isoformat()
while len(self.minutes) > 10:
self.minutes.popitem(last=False)
if status >= 500 or elapsed >= self.slow_ms:
self._event(
{
"kind": "REQUEST_ERROR" if status >= 500 else "SLOW_REQUEST",
"code": "HTTP_5XX" if status >= 500 else "REQUEST_SLOW",
"component": "http",
"message": "接口处理异常,请按请求编号检索服务端日志。"
if status >= 500
else "接口响应较慢,请核对同一时段资源和任务。",
"request_id": request_id,
"method": method,
"route": route,
"status_code": status,
"duration_ms": round(elapsed, 2),
"first_seen": at,
"last_seen": at,
"occurrences": 1,
"level": "error" if status >= 500 else "warning",
}
)
def _event(self, event):
if len(self.events) == self.events.maxlen:
self.dropped_events += 1
self.events.append(event)
def runtime_error(self, component):
with self.lock:
at = now_local()
self._event(
{
"kind": "RUNTIME_ERROR",
"code": "BACKGROUND_ERROR",
"component": component[:128],
"message": "后台运行出现错误,请检索服务端日志。",
"first_seen": at,
"last_seen": at,
"occurrences": 1,
"level": "error",
}
)
def metrics(self, start=None, end=None):
with self.lock:
return deepcopy(
{
key: value
for key, value in self.minutes.items()
if (start is None or key >= start) and (end is None or key < end)
}
)
def take_events(self):
with self.lock:
values = list(self.events)
self.events.clear()
return values
def restore_events(self, values):
with self.lock:
for value in reversed(values):
if len(self.events) < self.events.maxlen:
self.events.appendleft(value)
else:
self.dropped_events += 1
class MonitorMiddleware:
"""纯 ASGI 中间件:包含异常、流式响应与取消路径,避免读取请求正文。"""
def __init__(self, app: ASGIApp, recorder: Recorder):
self.app, self.recorder = app, recorder
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request_id = uuid4().hex
scope.setdefault("state", {})["request_id"] = request_id
token = REQUEST_ID.set(request_id)
started, status = perf_counter(), 500
async def with_id(message: Message):
nonlocal status
if message["type"] == "http.response.start":
status = message["status"]
message["headers"] = [
(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"
]
message["headers"].append((b"x-request-id", request_id.encode()))
if scope.get("path", "").startswith("/api/v1/system/monitor"):
message["headers"].append((b"cache-control", b"no-store"))
await send(message)
try:
await self.app(scope, receive, with_id)
except Exception:
status = 500
raise
finally:
try:
route = getattr(scope.get("route"), "path", "/__unmatched__")
excluded = route.startswith(
("/api/v1/system/monitor", "/api/health", "/api/docs", "/api/openapi")
)
if not excluded:
method = scope.get("method", "OTHER")
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
method = "OTHER"
self.recorder.record(
method, route, status, (perf_counter() - started) * 1000, request_id
)
except Exception:
logging.getLogger(__name__).warning("请求遥测写入失败", exc_info=True)
finally:
REQUEST_ID.reset(token)

View File

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

View File

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

View File

@ -0,0 +1,28 @@
"""授权版本锁及旧版授权结构;V26 后不再使用个人覆盖或全局制作人绑定。"""
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class UserPermissionOverride(Base, TimestampMixin):
__tablename__ = "user_permission_overrides"
__table_args__ = mysql_table_args(
"用户权限覆盖表(个人禁止优先)",
UniqueConstraint("user_id", "permission_id", name="uk_user_permission_overrides_user_permission"),
Index("idx_user_permission_overrides_permission_id", "permission_id"),
)
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
user_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="CASCADE"), comment="系统用户ID")
permission_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("permissions.id", ondelete="RESTRICT"), comment="权限ID")
effect: Mapped[str] = mapped_column(String(8), comment="个人覆盖:ALLOW允许 DENY禁止")
class AuthorizationSetting(Base, TimestampMixin):
__tablename__ = "authorization_settings"
__table_args__ = mysql_table_args("授权设置表(单例与并发修改锁)")
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="单例主键ID")
maker_user_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="RESTRICT"), comment="现任工资制作人账号ID")
revision: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1", comment="全局授权版本")
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="SET NULL"), comment="最近修改人ID")

View File

@ -0,0 +1,52 @@
"""计提公式模板定义模型。
把一期代码内注册表升级为数据库配置:模板名称、说明、参数绑定可在页面维护,
公式执行骨架仍是受控枚举(formula_type),不允许自由表达式,避免注入与算错。
岗位角色 `business_roles.calc_template` 绑定模板编码,编码创建后不可修改。
"""
from sqlalchemy import JSON, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class CalcTemplateDefinition(Base, TimestampMixin):
"""计提公式模板定义表。内置模板随迁移初始化,页面可新增自定义模板。"""
__tablename__ = "calc_template_definitions"
__table_args__ = mysql_table_args("计提公式模板定义表(页面可维护,骨架受控)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
template_code: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="模板编码,创建后不可修改;岗位表 business_roles.calc_template 引用",
)
template_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="模板名称")
formula_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="公式骨架类型:linear_decay/fixed_profit/fixed_sales/manager_share/prepay_fixed,创建后不可修改",
)
params_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="骨架参数角色到参数编码的 JSON 映射,如 {start:..., slope:...}",
)
description: Mapped[str | None] = mapped_column(String(255), comment="模板说明")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
is_system: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否系统模板:1内置(禁删、骨架与编码锁定) 0自定义",
)

View File

@ -0,0 +1,79 @@
"""监控数据独立保存,不关联工资或审计表;仅这些新表按七天保留。"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
class MonitorSnapshot(Base):
__tablename__ = "monitor_snapshots"
__table_args__ = mysql_table_args(
"分钟级运行监控快照",
UniqueConstraint("instance_id", "minute", name="uk_monitor_snapshots_instance_minute"),
Index("idx_monitor_snapshots_minute", "minute"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
minute: Mapped[datetime] = mapped_column(DateTime, comment="采集分钟,北京时间")
sample_count: Mapped[int] = mapped_column(Integer, comment="实际采样次数")
resources: Mapped[dict] = mapped_column(JSON, comment="资源均值与采集范围,不含配置")
requests: Mapped[dict] = mapped_column(JSON, comment="请求汇总与固定耗时直方图")
class MonitorRequestMinute(Base):
__tablename__ = "monitor_request_minutes"
__table_args__ = mysql_table_args(
"接口分钟级运行统计",
UniqueConstraint(
"instance_id", "minute", "method", "route", name="uk_monitor_request_minutes_endpoint"
),
Index("idx_monitor_request_minutes_minute", "minute"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
minute: Mapped[datetime] = mapped_column(DateTime, comment="请求分钟,北京时间")
method: Mapped[str] = mapped_column(String(8), comment="HTTP方法,固定集合")
route: Mapped[str] = mapped_column(String(256), comment="路由模板,不含原始地址及参数")
metrics: Mapped[dict] = mapped_column(JSON, comment="请求数、状态分类与固定直方图")
class MonitorEvent(Base):
__tablename__ = "monitor_events"
__table_args__ = mysql_table_args(
"脱敏诊断事件与异常恢复记录",
Index("idx_monitor_events_last_seen", "last_seen"),
Index("idx_monitor_events_request_id", "request_id"),
Index("idx_monitor_events_fingerprint", "fingerprint", "resolved_at"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
kind: Mapped[str] = mapped_column(String(24), comment="事件类型,固定集合")
code: Mapped[str] = mapped_column(String(64), comment="稳定错误码")
component: Mapped[str] = mapped_column(String(128), comment="所属组件或日志模块")
level: Mapped[str] = mapped_column(String(16), comment="异常级别")
message: Mapped[str] = mapped_column(String(256), comment="通用说明,不保存内部异常文本")
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="同类健康异常合并键")
request_id: Mapped[str | None] = mapped_column(String(32), comment="请求关联编号")
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP方法")
route: Mapped[str | None] = mapped_column(String(256), comment="路由模板")
status_code: Mapped[int | None] = mapped_column(Integer, comment="HTTP状态码")
duration_ms: Mapped[float | None] = mapped_column(Float, comment="请求耗时,毫秒")
batch_no: Mapped[str | None] = mapped_column(String(32), comment="相关同步批次编号")
first_seen: Mapped[datetime] = mapped_column(DateTime, comment="首次发生时间")
last_seen: Mapped[datetime] = mapped_column(DateTime, comment="最近发生时间")
occurrences: Mapped[int] = mapped_column(Integer, comment="发生或持续采样次数")
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="恢复时间,仅用于健康异常"
)

View File

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

View File

@ -0,0 +1,81 @@
"""试用期缓发与结清记录,引用固化台账,不使用人员当前开关重写历史。"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
class ProbationDeferral(Base):
__tablename__ = "probation_deferrals"
__table_args__ = mysql_table_args(
"试用期缓发台账(每个正式台账版本一条)",
UniqueConstraint("ledger_id", name="uk_probation_deferrals_ledger_id"),
Index("idx_probation_deferrals_personnel_id_year", "personnel_id", "year"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
ledger_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("performance_ledgers.id", ondelete="RESTRICT"),
nullable=False,
comment="原核算台账版本ID",
)
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原计提年度")
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次计提或应付变化月份")
accrued: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="本次试用期计提额,可负"
)
eligible: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="满足起付及权重的本次应付差额,可负"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
)
class ProbationPayment(Base):
__tablename__ = "probation_payments"
__table_args__ = mysql_table_args(
"试用期年终及后续结清记录(工资生成事务内固化)",
UniqueConstraint(
"settlement_id",
"personnel_id",
"year",
name="uk_probation_payments_settlement_person_year",
),
UniqueConstraint("adjustment_order_id", name="uk_probation_payments_adjustment_order_id"),
Index("idx_probation_payments_personnel_id_year", "personnel_id", "year"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=True,
comment="月结ID(当月调整单结清时为空)",
)
adjustment_order_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("adjustment_orders.id", ondelete="RESTRICT"),
nullable=True,
comment="已生成工资后的结清调整单ID",
)
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原累计年度")
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次实际支付月份")
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="本次补发或扣回金额"
)
snapshot_json: Mapped[dict] = mapped_column(
JSON, nullable=False, comment="计提、起付权重、门槛及历史补发依据"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
)

View File

@ -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随源更新")

View File

@ -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="个人明细快照(台账行引用列表)"
)

View File

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

View File

@ -0,0 +1,66 @@
"""计提公式模板数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BusinessRole, CalcTemplateDefinition
def get_by_id(session: Session, template_id: int, *, for_update: bool = False) -> CalcTemplateDefinition | None:
"""按主键查询;删除校验走行锁。"""
if for_update:
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.id == template_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(CalcTemplateDefinition, template_id)
def get_by_code(session: Session, template_code: str) -> CalcTemplateDefinition | None:
"""按编码查询模板。编码是岗位绑定与参数解析的锚点。"""
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.template_code == template_code)
return session.execute(stmt).scalar_one_or_none()
def list_all(session: Session) -> list[CalcTemplateDefinition]:
"""全部模板(含停用,供管理列表展示引用状态)。"""
stmt = select(CalcTemplateDefinition).order_by(
CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id
)
return list(session.execute(stmt).scalars())
def list_active(session: Session) -> list[CalcTemplateDefinition]:
"""启用模板,供岗位表单下拉。"""
stmt = (
select(CalcTemplateDefinition)
.where(CalcTemplateDefinition.status == 1)
.order_by(CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id)
)
return list(session.execute(stmt).scalars())
def count_roles_by_codes(session: Session, codes: list[str]) -> dict[str, int]:
"""批量统计各模板被岗位引用的数量,用于删除保护与列表展示。"""
if not codes:
return {}
rows = session.execute(
select(BusinessRole.calc_template, func.count(BusinessRole.id))
.where(BusinessRole.calc_template.in_(codes))
.group_by(BusinessRole.calc_template)
).all()
return {code: int(count) for code, count in rows}
def create(session: Session, template: CalcTemplateDefinition) -> CalcTemplateDefinition:
"""新增模板。只 flush 以取得自增 id。"""
session.add(template)
session.flush()
return template
def delete(session: Session, template: CalcTemplateDefinition) -> None:
"""删除模板。调用方须先完成保护规则校验。"""
session.delete(template)
session.flush()

View File

@ -0,0 +1,59 @@
"""监控查询只使用固定字段与有界窗口;事务由服务层负责。"""
from sqlalchemy import func, select
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
def snapshots(session, start, end):
return session.scalars(
select(MonitorSnapshot)
.where(MonitorSnapshot.minute >= start, MonitorSnapshot.minute < end)
.order_by(MonitorSnapshot.minute)
.limit(10082)
).all()
def request_minutes(session, start, end, route=None):
statement = select(MonitorRequestMinute).where(
MonitorRequestMinute.minute >= start, MonitorRequestMinute.minute < end
)
if route:
statement = statement.where(MonitorRequestMinute.route == route)
return session.scalars(
statement.order_by(MonitorRequestMinute.minute.desc()).limit(20001)
).all()
def events(session, query, start, end):
filters = [MonitorEvent.last_seen >= start, MonitorEvent.first_seen < end]
if query.kind:
filters.append(MonitorEvent.kind == query.kind)
if query.request_id:
filters.append(MonitorEvent.request_id == query.request_id)
if query.route:
filters.append(MonitorEvent.route == query.route)
if query.active_only:
filters.extend([MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None)])
total = session.scalar(select(func.count()).select_from(MonitorEvent).where(*filters)) or 0
rows = session.scalars(
select(MonitorEvent)
.where(*filters)
.order_by(MonitorEvent.last_seen.desc(), MonitorEvent.id.desc())
.offset(query.offset)
.limit(query.page_size)
).all()
return rows, total
def active_events(session):
return session.scalars(
select(MonitorEvent)
.where(MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None))
.order_by(MonitorEvent.last_seen.desc())
.limit(100)
).all()
def event(session, event_id):
return session.get(MonitorEvent, event_id)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,45 @@
"""授权请求版本化,禁止匿名覆盖他人的修改。"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PermissionOverrideIn(BaseModel):
model_config = ConfigDict(extra="forbid")
permission_code: str = Field(min_length=1, max_length=128)
effect: Literal["ALLOW", "DENY"]
class AuthorizationPreviewIn(BaseModel):
model_config = ConfigDict(extra="forbid")
role_codes: list[str] = Field(default_factory=list, max_length=100)
# 保留空字段兼容旧客户端;不再允许个人允许/禁止覆盖角色授权。
overrides: list[PermissionOverrideIn] = Field(default_factory=list, max_length=0)
class UserAuthorizationIn(AuthorizationPreviewIn):
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
@field_validator("reason")
@classmethod
def reason_required(cls, value):
if not value.strip():
raise ValueError("修改原因不能为空")
return value.strip()
class RolePermissionsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
permission_codes: list[str] = Field(max_length=300)
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
class AuthorizationSettingsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
maker_user_id: int | None = Field(default=None, ge=1)
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)

View File

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

View File

@ -0,0 +1,82 @@
"""计提公式模板的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
# 模板编码与历史 TPL_ 编码一致:大写字母开头,大写字母/数字/下划线,3–32 位。
TEMPLATE_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,31}$")
class TemplateParamOut(BaseModel):
"""模板骨架参数角色映射(角色 → 参数编码)。"""
role: str
param_code: str
class CalcTemplateOut(BaseModel):
"""模板列表/详情响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
template_code: str
template_name: str
formula_type: str
params: dict[str, str]
description: str | None = None
sort_order: int = 0
status: int = 1
is_system: int = 0
role_count: int = 0
class CalcTemplateOptionOut(BaseModel):
"""岗位表单下拉项(仅启用模板,含骨架与参数绑定说明)。"""
code: str
name: str
formula_type: str
description: str
params: list[TemplateParamOut]
class FormulaOptionOut(BaseModel):
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
code: str
name: str
description: str
roles: list[str]
class CalcTemplateCreateIn(BaseModel):
"""新增模板。编码与骨架创建后不可修改。"""
model_config = ConfigDict(str_strip_whitespace=True)
template_code: str = Field(min_length=3, max_length=32, description="模板编码,创建后不可修改")
template_name: str = Field(min_length=1, max_length=64, description="模板名称")
formula_type: str = Field(min_length=1, max_length=32, description="公式骨架类型")
params: dict[str, str] = Field(default_factory=dict, description="骨架参数角色到参数编码映射")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0)
@field_validator("template_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not TEMPLATE_CODE_PATTERN.match(value):
raise ValueError("模板编码须以大写字母开头,仅可含大写字母、数字与下划线")
return value
class CalcTemplateUpdateIn(BaseModel):
"""编辑模板。编码与骨架锁定;参数绑定仍按骨架角色校验。"""
model_config = ConfigDict(str_strip_whitespace=True)
template_name: str = Field(min_length=1, max_length=64)
params: dict[str, str] = Field(default_factory=dict)
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0)

View File

@ -0,0 +1,23 @@
"""系数表与 Excel 导入共用的边界模型。"""
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class CoefficientRowIn(BaseModel):
product_category: Literal["municipal_water", "rail_transit"]
customer_type: Literal["personal", "enterprise"]
stage: Literal["first", "linear", "after", "fixed"]
value: str = Field(min_length=1, max_length=64)
slope: str | None = Field(default=None, max_length=64)
boundary: int | None = Field(default=None, ge=1, le=3660)
class CoefficientChangeIn(BaseModel):
effective_date: date
base_date: date | None = None
remark: str = Field(default="系数表维护", max_length=255)
fingerprint: str = Field(min_length=64, max_length=64)
rows: list[CoefficientRowIn] = Field(min_length=1, max_length=16)

View File

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

View File

@ -0,0 +1,78 @@
"""监控接口只暴露固定白名单,不传输内部异常或连接配置。"""
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
from app.core.response import PageQuery
Status = Literal["normal", "warning", "error", "unknown"]
Hours = Annotated[
Literal[1, 24, 168],
BeforeValidator(lambda value: int(value) if value in ("1", "24", "168") else value),
]
class MonitorWindow(BaseModel):
hours: Hours = 1
end: datetime | None = None
route: str | None = Field(default=None, max_length=256)
class EventQuery(PageQuery):
hours: Hours = 24
end: datetime | None = None
route: str | None = Field(default=None, max_length=256)
kind: Literal["HEALTH", "REQUEST_ERROR", "SLOW_REQUEST", "RUNTIME_ERROR"] | None = None
request_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
active_only: bool = False
class EventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
instance_id: str
kind: str
code: str
component: str
level: str
message: str
request_id: str | None = None
method: str | None = None
route: str | None = None
status_code: int | None = None
duration_ms: float | None = None
batch_no: str | None = None
first_seen: datetime
last_seen: datetime
occurrences: int
resolved_at: datetime | None = None
class DependencyOut(BaseModel):
name: str
status: Status
message: str
checked_at: datetime | None = None
duration_ms: float | None = None
class OverviewOut(BaseModel):
status: Status
environment: str
version: str
instance_id: str
uptime_seconds: int
collected_at: datetime | None
last_persisted_at: datetime | None
collection_status: Status
collection_message: str
resources: dict
requests: dict
dependencies: list[DependencyOut]
alerts: list[EventOut]
tasks: dict
dropped_events: int
retention_days: int = 7

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,171 @@
"""管理员维护授权;审计、版本检查和权限写入在同一业务事务中。"""
from sqlalchemy import delete, select
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
from app.models import Permission, Role, RolePermission, User, UserRole
from app.repositories import user_repository
from app.services import audit_service
from app.services import authorization_service as auth
from app.services.audit_service import AuditRecord
from app.services.permission_catalog import CATALOG
def require_admin(session, actor):
if actor is None or actor.status != 1 or "admin" not in auth.role_codes(session, actor.id, for_update=True):
raise ForbiddenError("仅系统管理员可以配置权限")
def lock_policy(session, actor, expected=None):
policy = auth.setting(session, lock=True)
require_admin(session, actor)
if expected is not None and policy.revision != expected:
raise ConflictError()
return policy
def bump(policy, actor):
policy.revision += 1
policy.updated_by = actor.id
def _user(session, user_id):
user = session.get(User, user_id)
if user is None:
raise NotFoundError("用户不存在")
return user
def _roles(session, codes, *, existing=()):
if len(set(codes)) != len(codes):
raise BadRequestError("角色不能重复")
rows = list(session.scalars(select(Role).where(Role.role_code.in_(codes))))
if len(rows) != len(codes) or any(r.status != 1 and r.role_code not in existing for r in rows):
raise BadRequestError("角色不存在或已停用")
return rows
def _overrides(payload):
if payload.overrides:
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
return {}
def user_authorization(session, user_id):
user = _user(session, user_id)
result = auth.calculate(session, user)
return {**result, "id": user.id, "username": user.username,
"role_codes": [link.role.role_code for link in user.user_roles],
"overrides": [], "revision": auth.setting(session).revision,
"is_payroll_maker": auth.is_maker(session, user)}
def preview(session, user_id, payload):
user = _user(session, user_id)
_roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
overrides = _overrides(payload)
if "admin" in payload.role_codes and overrides:
raise BadRequestError("管理员权限为固定基线,不能设置个人覆盖")
proposed = auth.calculate(session, user, roles=payload.role_codes, overrides=overrides)
before = set(auth.calculate(session, user)["permissions"])
after = set(proposed["permissions"])
return {**proposed, "added": sorted(after - before), "removed": sorted(before - after)}
def guard_user_change(session, user, actor, *, roles=None, status=None, deleting=False):
"""调用方已持全局授权锁;防止两个管理员并发把系统管理资格撤空。"""
session.refresh(user, with_for_update=True)
current = auth.role_codes(session, user.id, for_update=True)
removes_admin = "admin" in current and (
deleting or status == 0 or (roles is not None and "admin" not in roles)
)
if removes_admin:
if actor.id == user.id:
raise ForbiddenError("不能撤销自己的管理员资格或停用自己")
active = list(session.scalars(select(User.id).join(UserRole).join(Role).where(
User.status == 1, Role.role_code == "admin", Role.status == 1
).with_for_update()))
if len(active) <= 1:
raise ForbiddenError("不能移除最后一个启用的管理员")
def save_user(session, user_id, payload, actor, meta=None):
audit_data = payload.model_dump(mode="json")
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
"权限管理", "用户授权", f"修改用户 #{user_id} 的权限", audit_data
)):
policy = lock_policy(session, actor, payload.expected_revision)
user = _user(session, user_id)
before = user_authorization(session, user_id)
changes = preview(session, user_id, payload)
guard_user_change(session, user, actor, roles=payload.role_codes)
roles = _roles(session, payload.role_codes, existing=[link.role.role_code for link in user.user_roles])
user_repository.replace_roles(session, user, roles)
bump(policy, actor)
session.flush()
result = user_authorization(session, user_id)
result["changes"] = {"added": changes["added"], "removed": changes["removed"],
"before_roles": before["role_codes"], "after_roles": result["role_codes"]}
audit_data["changes"] = result["changes"]
session.commit()
return result
def role_permissions(session, role_id):
role = session.get(Role, role_id)
if role is None:
raise NotFoundError("角色不存在")
codes = sorted(CATALOG) if role.role_code == "admin" else list(session.scalars(
select(Permission.perm_code).join(RolePermission, RolePermission.permission_id == Permission.id)
.where(RolePermission.role_id == role.id, Permission.perm_code.in_(CATALOG))
))
return {"role_id": role.id, "role_code": role.role_code, "role_name": role.role_name,
"permission_codes": codes, "revision": auth.setting(session).revision,
"fixed": role.role_code == "admin"}
def validate_role_permissions(permission_codes):
"""新建与修改角色共用同一权限目录、管理员边界及查看依赖校验。"""
codes = set(permission_codes)
if codes - CATALOG.keys() or len(codes) != len(permission_codes):
raise BadRequestError("权限未知或重复,请刷新目录")
if any(CATALOG[c]["admin_only"] for c in codes):
raise BadRequestError("管理员专用权限不能分配给普通角色")
if any(CATALOG[c]["view_code"] not in codes for c in codes):
raise BadRequestError("操作权限必须同时授予相应查看权限")
return codes
def save_role(session, role_id, payload, actor, meta=None):
audit_data = payload.model_dump(mode="json")
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
"权限管理", "角色授权", f"修改角色 #{role_id} 的权限", audit_data
)):
policy = lock_policy(session, actor, payload.expected_revision)
role = session.get(Role, role_id)
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == "admin":
raise ForbiddenError("管理员角色权限固定,不能修改")
codes = validate_role_permissions(payload.permission_codes)
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
audit_data["after"] = sorted(codes)
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
bump(policy, actor)
session.flush()
result = role_permissions(session, role.id)
session.commit()
return result
def settings_out(session):
policy = auth.setting(session)
return {"mode": "roles", "revision": policy.revision, "maker_user_id": None,
"maker_username": None,
"maker_status": None, "updated_by": policy.updated_by,
"updated_at": policy.update_time}
def save_settings(session, payload, actor, meta=None):
raise BadRequestError("已取消单独的制作人绑定,请在角色管理配置权限,再在用户管理分配角色")

View File

@ -0,0 +1,101 @@
"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
from sqlalchemy import select
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
Business,
BusinessRole,
CalcTemplateDefinition,
Customer,
CustomerRatingGrade,
Personnel,
Product,
Shipment,
)
from app.services.authorization_service import calculate
OPTIONS = {
"managers": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
"base:personnel:view", "base:personnel:create", "base:personnel:update",
)),
"rating_grades": (CustomerRatingGrade, ("id", "grade_name", "min_score", "max_score", "sort_order", "remark"), (
"base:customers:view", "base:customers:rating", "business:ratings:view",
)),
"personnel": (Personnel, ("id", "personnel_name", "business_role_id", "status"), (
"base:personnel:view", "base:customers:view", "base:customers:create", "base:customers:update",
"base:customers:ownership", "business:businesses:view", "business:businesses:create",
"business:businesses:update", "business:businesses:ownership", "payroll:prepare", "adjustments:create",
"payroll:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
)),
"customers": (Customer, ("id", "company_name", "customer_type"), (
"base:customers:view", "business:businesses:view", "business:businesses:create", "business:businesses:update",
"reports:business:view",
)),
"products": (Product, ("id", "product_name", "spec_model", "unit"), (
"base:products:view", "business:businesses:create", "business:businesses:update",
"business:shipments:create", "business:shipments:update",
)),
"businesses": (Business, ("id", "business_no", "status"), (
"business:businesses:view", "business:shipments:view", "business:shipments:create",
"business:shipments:update", "business:receipts:view", "business:receipts:create", "business:receipts:update",
)),
"shipments": (Shipment, ("id", "shipment_no", "business_id", "ship_date"), (
"business:shipments:view", "business:receipts:view", "business:receipts:create", "business:receipts:update",
)),
"business_roles": (BusinessRole, ("id", "role_code", "role_name", "status"), (
"base:personnel:view", "base:personnel:create", "base:personnel:update", "system:business_roles:view",
"system:params:view", "reports:personal:view", "reports:department:view", "reports:business:view", "reports:payments:view",
)),
}
def payroll_options(session, actor, business_id):
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
from app.core.exceptions import NotFoundError
from app.models import ShipmentItem
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:view")
if session.get(Business, business_id) is None:
raise NotFoundError("业务不存在")
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
if len(heads) > 200:
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
if len(people) > 200:
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
by_head = {head.id: head for head in heads}
return {"personnel": people, "shipments": [
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
for row in lines
]}
def options(session, actor, resource, *, business_id=None, search=None):
definition = OPTIONS.get(resource)
if definition is None:
raise BadRequestError("不支持的参照类型")
model, fields, callers = definition
if not set(calculate(session, actor)["permissions"]) & set(callers):
raise ForbiddenError("没有使用此参照数据的业务权限")
statement = select(*(getattr(model, f) for f in fields))
if resource == "managers":
statement = (
statement.join(BusinessRole, BusinessRole.id == Personnel.business_role_id)
.join(CalcTemplateDefinition, CalcTemplateDefinition.template_code == BusinessRole.calc_template)
.where(
Personnel.status == 1, BusinessRole.status == 1,
CalcTemplateDefinition.formula_type == "manager_share",
)
)
if business_id is not None and model == Shipment:
statement = statement.where(Shipment.business_id == business_id)
if search:
name = getattr(model, fields[1])
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
statement = statement.where(name.like(f"%{escaped}%", escape="\\"))
order = (model.sort_order, model.id) if model == CustomerRatingGrade else (model.id,)
rows = session.execute(statement.order_by(*order).limit(200)).mappings()
return [dict(row) for row in rows]

View File

@ -0,0 +1,91 @@
"""每个受保护接口必须映射权限;未登记接口默认拒绝。"""
RESOURCE_MODULES = {
"base.personnel": "base:personnel", "base.products": "base:products",
"base.customers": "base:customers", "business.businesses": "business:businesses",
"business.shipments": "business:shipments", "business.receipts": "business:receipts",
"business.ratings": "business:ratings", "system.users": "system:users",
"system.roles": "system:roles", "system.business_roles": "system:business_roles",
"system.logs": "system:logs", "system.settings": "system:settings",
"system.monitor": "system:monitor",
}
REPORT_MODULES = {
"personal-detail": "reports:personal", "department-summary": "reports:department",
"business-stats": "reports:business", "payment-stats": "reports:payments",
}
PUBLIC_ENDPOINTS = {("auth", "get_captcha"), ("auth", "login"),
("system.settings", "get_settings_public"), ("system.settings", "get_logo_public")}
LOGIN_ENDPOINTS = {("auth", "me"), ("auth", "logout")}
def permission_for(module, name, method, *, params=None, body=None):
"""返回目录操作码;None 表示没有登记,空串仅用于显式登录态接口。"""
params, body = params or {}, body or {}
if (module, name) in PUBLIC_ENDPOINTS | LOGIN_ENDPOINTS:
return ""
if module == "dashboard":
return "dashboard:view"
if module == "system.authorization":
resource = "system:users" if name in ("user_authorization", "preview", "save_user") else "system:roles"
return resource + (":view" if method == "GET" or name == "preview" else ":update")
if module == "metadata":
return "" # 此端点按请求的参照资源执行独立的关联功能校验。
if module == "base.business_roles":
return "" # 下拉只返回选项;由端点校验人员查看/维护能力。
if module in RESOURCE_MODULES:
resource = RESOURCE_MODULES[module]
if module == "base.products" and name in ("extraction_preview", "extraction_apply", "restore_source"):
return resource + (":create" if name == "extraction_apply" else ":update" if name == "restore_source" else ":view")
if method == "GET":
return resource + ":view"
special = {"set_status": "status", "change_status": "status", "reset_password": "password",
"replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating",
"change_ownership": "ownership", "upload_logo": "update"}
action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method)
return resource + ":" + action if action else None
if module == "system.params":
action = "view" if method == "GET" else {"submit_change": "update", "withdraw_batch": "withdraw", "preview": "preview"}.get(name)
if name == "export_changes":
action = "export"
return "system:params:" + action if action else None
if module == "system.coefficients":
return "system:params:" + ({"save": "update", "export": "export", "import_preview": "preview"}.get(name, "view"))
if module == "system.calc_templates":
return "system:params:" + ("view" if method == "GET" else "update")
if module == "system.monitor":
return "system:monitor:" + ("export" if name == "diagnostic" else "view")
if module == "system.tplus":
return "tplus:" + ("view" if method == "GET" else "retry" if name == "retry" else "collect")
if module == "system.tplus_workbench":
if name == "work_report":
return "tplus:export"
if method == "GET":
return "tplus:view"
if name == "save_fact":
return "tplus:facts"
if name == "create_work":
return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview"
if module in ("payroll", "calc"):
if name == "probation_adjustment":
return "adjustments:create"
if name in ("probation_export", "salary_export"):
return "reports:personal:export"
if method == "GET":
return "payroll:view"
return "adjustments:create" if name == "adjustment" else "payroll:prepare" if name == "save_input" else "payroll:calculate"
if module == "settlements":
if name in ("create_adjustment", "list_adjustments"):
return "adjustments:" + ("view" if method == "GET" else "create")
return "settlements:" + ("view" if method == "GET" else "generate" if name == "generate" else "submit")
if module == "system.approvals":
return "approvals:view" # 节点动作由服务层根据当前节点检查 finance/leader。
if module == "reports":
code = params.get("code") if name == "export_report" else {
"personal_detail": "personal-detail", "department_summary": "department-summary",
"business_stats": "business-stats", "payment_stats": "payment-stats",
"payment_detail": "payment-stats", "payment_day_buckets": "payment-stats",
}.get(name)
resource = REPORT_MODULES.get(code)
return resource + (":export" if name == "export_report" else ":view") if resource else None
return None

View File

@ -0,0 +1,92 @@
"""授权查询与计算,权限每次从数据库解析,不采用 JWT 中的角色作为依据。"""
from sqlalchemy import select
from sqlalchemy.orm import object_session
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
AuthorizationSetting,
Permission,
Role,
RolePermission,
User,
UserRole,
)
from app.services.permission_catalog import CATALOG, PAYROLL_WRITE_PERMISSIONS
def setting(session, *, lock=False):
statement = select(AuthorizationSetting).where(AuthorizationSetting.id == 1)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
row = session.scalar(statement)
if row is None:
raise ForbiddenError("授权配置尚未初始化,请先完成数据库迁移")
return row
def role_codes(session, user_id, *, for_update=False):
statement = select(Role.role_code).join(UserRole).where(
UserRole.user_id == user_id, Role.status == 1
)
if for_update:
statement = statement.with_for_update()
return list(session.scalars(statement))
def calculate(session, user, *, roles=None, overrides=None):
"""用户继承启用角色权限的并集;管理员固定全权限,操作依赖模块查看。"""
if overrides:
raise BadRequestError("已改为角色授权,请在角色管理中配置权限")
if user is None or user.status != 1:
return {"permissions": [], "sources": {}, "roles": []}
roles = roles if roles is not None else role_codes(session, user.id)
if "admin" in roles:
return {"permissions": sorted(CATALOG), "sources": {
code: {"roles": ["admin"], "override": None, "effective": True}
for code in CATALOG
}, "roles": roles}
inherited = {}
for code, role in session.execute(
select(Permission.perm_code, Role.role_code)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(Role, Role.id == RolePermission.role_id)
.where(Role.role_code.in_(roles), Role.status == 1, Permission.status == 1)
):
inherited.setdefault(code, []).append(role)
allowed = set(inherited)
allowed &= set(CATALOG)
allowed = {c for c in allowed if not CATALOG[c]["admin_only"]}
allowed = {c for c in allowed if CATALOG[c]["view_code"] in allowed}
sources = {c: {"roles": inherited.get(c, []), "override": None,
"effective": c in allowed} for c in CATALOG}
return {"permissions": sorted(allowed), "sources": sources, "roles": roles}
def is_maker(session, user):
"""兼容旧会话字段:现表示账号具备工资写权限,不再查询全局绑定。"""
return bool(set(calculate(session, user)["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
def require_permission(session, user, code, *, check_maker=True):
item = CATALOG.get(code)
if item is None or code not in calculate(session, user)["permissions"]:
label = f"{item['module_name']}:{item['name']}" if item else "此操作"
raise ForbiddenError(f"当前账号没有「{label}」权限,请联系管理员授权")
return user
def session_info(session, user):
result = calculate(session, user)
maker = bool(set(result["permissions"]) & PAYROLL_WRITE_PERMISSIONS)
return {"permissions": result["permissions"], "authorization_revision": setting(session).revision,
"is_payroll_maker": maker, "can_write_payroll": maker,
"maker_required_permissions": [],
"roles": result["roles"]}
def user_session(user):
session = object_session(user) if isinstance(user, User) else None
if session is None:
raise ForbiddenError("账号状态不可用,请重新登录")
return session

View File

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

View File

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

View File

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

View File

@ -0,0 +1,195 @@
"""计提公式模板管理服务。
页面可维护:名称、说明、参数绑定(受骨架角色约束);公式骨架是受控枚举,
创建后锁定,避免自由表达式注入与执行错位。系统模板随迁移种子初始化,
禁删且骨架/编码/参数锁定;自定义模板无岗位引用才可删除。
"""
from sqlalchemy.exc import IntegrityError
from app.core.constants import ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import CalcTemplateDefinition, User
from app.repositories import calc_template_repository
from app.schemas.calc_template import (
CalcTemplateCreateIn,
CalcTemplateOut,
CalcTemplateUpdateIn,
)
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.calc_templates import FORMULAS, get_formula
from app.services.param_registry import PARAM_REGISTRY
MODULE_CALC_TEMPLATE = "公式模板"
def _to_out(row: CalcTemplateDefinition, role_count: int = 0) -> CalcTemplateOut:
return CalcTemplateOut(
id=row.id,
template_code=row.template_code,
template_name=row.template_name,
formula_type=row.formula_type,
params=dict(row.params_json or {}),
description=row.description,
sort_order=row.sort_order,
status=row.status,
is_system=row.is_system,
role_count=role_count,
)
def _validate_params(formula_type: str, params: dict[str, str]) -> dict[str, str]:
"""骨架角色必须全覆盖且不引入多余角色;目标参数必须已注册。"""
spec = get_formula(formula_type)
expected = set(spec.roles)
provided = set(params)
if provided != expected:
missing = sorted(expected - provided)
extra = sorted(provided - expected)
details = []
if missing:
details.append("缺少参数角色:" + "、".join(missing))
if extra:
details.append("多余参数角色:" + "、".join(extra))
raise BadRequestError(";".join(details))
unknown = sorted(code for code in params.values() if code not in PARAM_REGISTRY)
if unknown:
raise BadRequestError("参数编码未注册:" + "、".join(unknown))
return {role: params[role] for role in spec.roles}
def list_templates(session) -> PageResult[CalcTemplateOut]:
"""模板列表(含停用与引用计数,供管理页)。"""
rows = calc_template_repository.list_all(session)
counts = calc_template_repository.count_roles_by_codes(session, [r.template_code for r in rows])
return PageResult[CalcTemplateOut](
records=[_to_out(row, counts.get(row.template_code, 0)) for row in rows],
total=len(rows),
page_num=1,
page_size=max(len(rows), 1),
)
def list_formula_options() -> list[dict]:
"""骨架下拉(受控集合)。"""
return [
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
for spec in FORMULAS.values()
]
def create_template(
session,
payload: CalcTemplateCreateIn,
*,
operator: User,
meta: RequestMeta,
) -> CalcTemplateOut:
"""新增模板。编码唯一;骨架受控;参数绑定按骨架角色校验。"""
if calc_template_repository.get_by_code(session, payload.template_code) is not None:
raise BadRequestError(f"模板编码 {payload.template_code} 已存在")
try:
get_formula(payload.formula_type)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
params = _validate_params(payload.formula_type, payload.params)
row = CalcTemplateDefinition(
template_code=payload.template_code,
template_name=payload.template_name,
formula_type=payload.formula_type,
params_json=params,
description=payload.description,
sort_order=payload.sort_order,
status=1,
is_system=0,
)
try:
calc_template_repository.create(session, row)
session.commit()
except IntegrityError as exc:
session.rollback()
raise BadRequestError(f"模板编码 {payload.template_code} 已存在") from exc
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_CREATE,
target=payload.template_code,
summary=f"新增计提公式模板 {payload.template_name}",
detail={"formula_type": payload.formula_type, "params": params},
),
meta=meta,
):
pass
return _to_out(row)
def update_template(
session,
template_id: int,
payload: CalcTemplateUpdateIn,
*,
operator: User,
meta: RequestMeta,
) -> CalcTemplateOut:
"""编辑模板。系统模板参数锁定;自定义模板参数仍按骨架角色校验。"""
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
if row is None:
raise NotFoundError("模板不存在")
params = dict(row.params_json or {})
if row.is_system != 1:
params = _validate_params(row.formula_type, payload.params)
row.template_name = payload.template_name
row.description = payload.description
row.sort_order = payload.sort_order
row.params_json = params
session.commit()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_UPDATE,
target=row.template_code,
summary=f"修改计提公式模板 {row.template_name}",
detail={"params": params},
),
meta=meta,
):
pass
return _to_out(row)
def delete_template(
session,
template_id: int,
*,
operator: User,
meta: RequestMeta,
) -> None:
"""删除模板。系统模板禁删;被岗位引用禁删。"""
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
if row is None:
raise NotFoundError("模板不存在")
if row.is_system == 1:
raise ForbiddenError("系统内置模板不可删除")
counts = calc_template_repository.count_roles_by_codes(session, [row.template_code])
if counts.get(row.template_code):
raise BadRequestError("模板已被岗位引用,不能删除;可先调整岗位模板后再删除")
calc_template_repository.delete(session, row)
session.commit()
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC_TEMPLATE,
action=ACTION_DELETE,
target=row.template_code,
summary=f"删除计提公式模板 {row.template_name}",
),
meta=meta,
):
pass

View File

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

View File

@ -0,0 +1,330 @@
"""系数表是参数版本的编辑视图,不保留第二套工资公式或独立系数数据。"""
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from io import BytesIO
from zipfile import ZipFile
from openpyxl import Workbook, load_workbook
from pydantic import ValidationError
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.schemas.coefficient import CoefficientRowIn
from app.schemas.param import ParamChangeIn, ParamChangeItemIn
from app.services import param_service
from app.services.param_registry import get_spec, validate_and_normalize
from app.services.rate_rules import decide, personal_values
from app.services.tplus.workbench import digest
CATEGORIES = ("municipal_water", "rail_transit")
HEADERS = ("product_category", "customer_type", "stage", "value", "slope", "boundary")
def _coefficient(rate):
return str(1 / rate) if rate else None
def _average_text(value):
if value is None:
return None
return format(
param_service.to_decimal(value).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP), "f"
)
def _revision(session, lock=False):
if session is None:
return []
from app.models import ParamChangeBatch
query = select(
ParamChangeBatch.id, ParamChangeBatch.effective_date, ParamChangeBatch.version_no
).order_by(ParamChangeBatch.id)
if lock:
query = query.with_for_update()
return [tuple(row) for row in session.execute(query)]
def catalog(session, as_of=None, revision=None):
day = as_of or date.today()
snap = param_service.resolve_snapshot(session, day)
rows = []
for category in CATEGORIES:
values = personal_values(snap, category)
params = {**values, "enterprise": snap.get("enterprise.rate", product_category=category)}
rates = [
decide(
params, category=category, customer_type="personal", role_code="salesperson", days=d
)[0]
for d in range(1, 361)
]
average = sum((1 / r for r in rates if r), Fraction(0)) / 360 if all(rates) else None
for stage, field, days in (
("first", "first", 1),
("linear", "start", 2),
("after", "floor", int(Fraction(values["boundary"])) + 1),
):
rate = decide(
params,
category=category,
customer_type="personal",
role_code="salesperson",
days=days,
)[0]
rows.append(
{
"product_category": category,
"customer_type": "personal",
"stage": stage,
"value": values[field],
"slope": values["slope"] if stage == "linear" else None,
"boundary": int(Fraction(values["boundary"])) if stage == "linear" else None,
"rate": str(rate),
"system_coefficient": _coefficient(rate),
"average_coefficient_360": _average_text(average),
}
)
rate = Fraction(params["enterprise"]) / 1000
rows.append(
{
"product_category": category,
"customer_type": "enterprise",
"stage": "fixed",
"value": params["enterprise"],
"slope": None,
"boundary": None,
"rate": str(rate),
"system_coefficient": _coefficient(rate),
"average_coefficient_360": _average_text(1 / rate if rate else None),
}
)
rows.append(
{
"product_category": category,
"customer_type": "manager_personal",
"stage": "excluded",
"value": "0",
"rate": "0",
"system_coefficient": None,
"average_coefficient_360": None,
"readonly": True,
}
)
return {
"as_of": str(day),
"rows": rows,
"fingerprint": digest(
{
"as_of": day,
"rows": rows,
"revision": _revision(session) if revision is None else revision,
}
),
"average_rule": "第1—360天每日系统系数的算术平均,展示保留6位小数;存在零计提日时不适用。经理个人户不单笔计提。",
}
def items_for(rows):
items = []
seen = set()
for row in rows:
key = (row.product_category, row.customer_type, row.stage)
if key in seen:
raise BadRequestError("存在重复的产品/户型/阶段")
seen.add(key)
if row.customer_type == "enterprise":
if row.stage != "fixed":
raise BadRequestError("企业户使用固定阶段")
fields = [("enterprise.rate", row.value)]
elif row.stage == "fixed":
raise BadRequestError("个人户请选择首日、线性或超期阶段")
elif row.stage == "linear":
if row.slope is None or row.boundary is None:
raise BadRequestError("线性阶段须填写日斜率和截止天数")
try:
endpoint = Fraction(row.value) - row.boundary * Fraction(row.slope)
except (ValueError, ZeroDivisionError) as exc:
raise BadRequestError("比例及日斜率须为有效数值或分数") from exc
if endpoint < 0:
raise BadRequestError("线性阶段末比例不能为负数")
fields = [
("coefficient.personal.start", row.value),
("coefficient.personal.slope", row.slope),
("coefficient.personal.boundary", str(row.boundary)),
]
else:
fields = [
(
"coefficient.personal.first"
if row.stage == "first"
else "coefficient.personal.floor",
row.value,
)
]
for code, value in fields:
normalized = validate_and_normalize(get_spec(code), value)
items.append(
ParamChangeItemIn(
param_code=code, dimension_value=row.product_category, value=normalized
)
)
return items
def save(session, payload, operator, meta=None):
from app.models import ParamDefinition
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
revision = _revision(session, lock=True)
if payload.fingerprint != catalog(session, payload.base_date, revision)["fingerprint"]:
raise BadRequestError("系数表已变化,请重新查询或重新导入预览")
return param_service.submit_change(
session,
ParamChangeIn(
change_type=2,
effective_date=payload.effective_date,
remark=payload.remark,
items=items_for(payload.rows),
),
operator=operator,
meta=meta,
)
def workbook(session, as_of=None):
data = catalog(session, as_of)
book = Workbook()
sheet = book.active
sheet.title = "系数参数"
sheet.append([*HEADERS, "system_coefficient", "average_coefficient_360"])
for row in data["rows"]:
if row.get("readonly"):
continue
sheet.append(
[row.get(k) for k in (*HEADERS, "system_coefficient", "average_coefficient_360")]
)
note = book.create_sheet("填写说明")
note.append(["基准日期", data["as_of"]])
for line in (
"value为‰数;线性阶段value为公式起点,slope为每日递减‰数,可填写分数。",
"产品:municipal_water市政水利/rail_transit轨道交通;户型personal个人户/enterprise企业户。",
"阶段:first首日/linear线性/after超期/fixed企业固定;经理个人户不单笔计提,不导入。",
data["average_rule"],
"系统系数及平均系数是只读派生列,导入时可留空,填写则必须与参数一致。",
):
note.append([line])
for column in sheet.columns:
sheet.column_dimensions[column[0].column_letter].width = 28
sheet.freeze_panes = "A2"
output = BytesIO()
book.save(output)
return output.getvalue()
def import_preview(session, content):
if len(content) > 2 * 1024 * 1024:
raise BadRequestError("系数文件不能超过2MB")
try:
with ZipFile(BytesIO(content)) as archive:
if sum(item.file_size for item in archive.infolist()) > 20 * 1024 * 1024:
raise BadRequestError("工作簿解压后超过20MB,请使用系数模板")
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
sheet = book["系数参数"]
if sheet.max_row > 30 or sheet.max_column > 8:
raise BadRequestError("系数表最多16条数据")
values = list(sheet.iter_rows(values_only=True))
if tuple(values[0][:6]) != HEADERS:
raise BadRequestError("请使用系数表模板,保留前六列列名")
rows = []
errors = []
for number, value in enumerate(values[1:], 2):
if not any(v is not None for v in value):
continue
try:
raw = {
k: str(v) if v is not None else None
for k, v in zip(HEADERS, value[:6], strict=True)
}
raw["boundary"] = int(raw["boundary"]) if raw["boundary"] else None
row = CoefficientRowIn.model_validate(raw)
items_for([row])
rows.append(row)
except (ValidationError, ValueError, BadRequestError) as exc:
errors.append({"row": number, "message": str(exc)[:180]})
if not rows and not errors:
errors.append({"row": 2, "message": "文件没有可导入数据"})
if rows:
items_for(rows)
# 派生列只做校验,不作为第二套输入。用临时参数快照复算。
base = date.fromisoformat(str(book["填写说明"].cell(1, 2).value))
snap = param_service.resolve_snapshot(session, base)
temporary = {code: dict(dims) for code, dims in snap._values.items()}
for item in items_for(rows):
temporary.setdefault(item.param_code, {})[item.dimension_value] = item.value
candidate = param_service.ParamSnapshot(base, temporary, {})
for number, raw in enumerate(values[1:], 2):
if len(raw) < 7:
continue
matching = next(
(
r
for r in rows
if (r.product_category, r.customer_type, r.stage) == tuple(raw[:3])
),
None,
)
if matching is None:
continue
pv = personal_values(candidate, matching.product_category)
day = {"first": 1, "linear": 2, "after": int(Fraction(pv["boundary"])) + 1, "fixed": 1}[
matching.stage
]
rate = decide(
{
**pv,
"enterprise": candidate.get(
"enterprise.rate", product_category=matching.product_category
),
},
category=matching.product_category,
customer_type=matching.customer_type,
role_code="salesperson",
days=day,
)[0]
if raw[6] not in (None, "") and (rate == 0 or Fraction(str(raw[6])) != 1 / rate):
errors.append(
{"row": number, "message": "系统系数与公式参数不一致,请清空派生列或重新导出"}
)
if len(raw) > 7 and raw[7] not in (None, ""):
if matching.customer_type == "enterprise":
average = 1 / rate if rate else None
else:
daily = [
decide(
{**pv, "enterprise": "0"},
category=matching.product_category,
customer_type="personal",
role_code="salesperson",
days=d,
)[0]
for d in range(1, 361)
]
average = sum((1 / r for r in daily), Fraction(0)) / 360 if all(daily) else None
if average is None or Decimal(str(raw[7])) != Decimal(_average_text(average)):
errors.append({"row": number, "message": "360天平均系数与公式参数不一致"})
return {
"rows": [r.model_dump() for r in rows],
"errors": errors,
"can_apply": not errors,
"fingerprint": catalog(session, base)["fingerprint"],
"base_date": str(base),
}
except BadRequestError:
raise
except Exception as exc:
raise BadRequestError("无法读取系数表,请检查工作表名称和文件格式") from exc
finally:
if "book" in locals():
book.close()

View File

@ -0,0 +1,14 @@
"""含税单位指导成本到总成本的唯一换算,不改写已封存的成本快照。"""
from decimal import ROUND_HALF_UP, Decimal
def guided_total(quantity, guided_price, cost_amount=None):
if guided_price is None:
return Decimal(str(cost_amount)) if cost_amount is not None else None
total = (Decimal(str(quantity)) * Decimal(str(guided_price))).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
if cost_amount is not None and Decimal(str(cost_amount)) != total:
raise ValueError("成本额与数量×含税单位指导成本不一致")
return total

View File

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

View File

@ -0,0 +1,477 @@
"""单应用进程的独立采集线程;探测有超时,写入异常不会进入业务请求。"""
import logging
import threading
from collections import OrderedDict
from copy import deepcopy
from datetime import timedelta
from pathlib import Path
from time import monotonic, perf_counter
import psutil
from redis import Redis
from sqlalchemy import create_engine, delete, select, text
from sqlalchemy.orm import sessionmaker
from app.core.config import BACKEND_DIR
from app.core.monitoring import (
REQUEST_ID,
Recorder,
fingerprint,
merge_metrics,
minute_of,
now_local,
)
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.repositories import monitor_repository as repo
from app.services.tplus import automation
logger = logging.getLogger(__name__)
class RuntimeErrorHandler(logging.Handler):
"""后台错误只采集固定摘要;HTTP 错误由中间件记录,避免同一异常重复。"""
def __init__(self, recorder):
super().__init__(logging.ERROR)
self.recorder = recorder
def emit(self, record):
if (
record.name.startswith("app.")
and ".monitor" not in record.name
and REQUEST_ID.get() == "-"
and getattr(record, "request_id", "-") == "-"
):
self.recorder.runtime_error(record.name)
class MonitorCollector:
def __init__(self, settings, recorder: Recorder):
self.settings, self.recorder = settings, recorder
self.recorder.slow_ms = settings.monitor_slow_request_ms
self.lock = threading.Lock()
self.stopped = threading.Event()
self.thread = None
self.engine = None
self.redis = None
self.process = None
self.samples = OrderedDict()
self.conditions = {}
self.unknown_codes = set()
self.pending_incidents = {}
self.since = {}
self.persisted = set()
self.last_log = 0
self.handler = RuntimeErrorHandler(recorder)
self.state = {
"collected_at": None,
"last_persisted_at": None,
"resources": {},
"dependencies": [],
"tasks": {},
"alerts": [],
"collection_status": "unknown",
"collection_message": "正在等待首次采集。"
if settings.monitor_enabled
else "后台采集未启用。",
}
def current(self):
with self.lock:
return deepcopy(self.state)
def sample_history(self):
with self.lock:
return deepcopy(self.samples)
def start(self):
if not self.settings.monitor_enabled or self.thread:
return
self.stopped.clear()
logging.getLogger().addHandler(self.handler)
self.thread = threading.Thread(target=self._run, name="system-monitor", daemon=True)
self.thread.start()
def stop(self):
self.stopped.set()
if self.thread:
self.thread.join(timeout=15)
if self.thread.is_alive():
logger.warning("监控采集线程未在关闭窗口内结束")
return
logging.getLogger().removeHandler(self.handler)
if self.redis:
self.redis.close()
if self.engine:
self.engine.dispose()
self.thread = None
def _factory(self):
if self.engine is None:
self.engine = create_engine(
self.settings.database_url,
pool_size=1,
max_overflow=0,
pool_timeout=2,
pool_pre_ping=True,
connect_args={"connect_timeout": 2, "read_timeout": 2, "write_timeout": 2},
)
return sessionmaker(self.engine, expire_on_commit=False)
def _resources(self):
first = self.process is None
if first:
self.process = psutil.Process()
psutil.cpu_percent(None)
self.process.cpu_percent(None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage(str(BACKEND_DIR.anchor))
return {
"cpu_percent": None if first else psutil.cpu_percent(None),
"memory_percent": memory.percent,
"disk_percent": disk.percent,
"memory_total_mb": round(memory.total / 1024**2),
"disk_free_gb": round(disk.free / 1024**3, 2),
"process_cpu_percent": None
if first
else round(self.process.cpu_percent(None) / (psutil.cpu_count() or 1), 2),
"process_memory_mb": round(self.process.memory_info().rss / 1024**2, 2),
"scope": "进程可见系统资源,未换算容器限额"
if Path("/.dockerenv").exists()
else "应用所在系统的可见资源",
"process_cpu_scope": "占可见 CPU 总量的比例",
}
def _dependencies(self, at):
dependencies = [
{
"name": "应用",
"status": "normal",
"message": "采集线程正在运行。",
"checked_at": at,
"duration_ms": None,
}
]
tasks = {"status": "unknown", "message": "尚无采集记录。", "alerts": []}
for name in ("MySQL", "Redis"):
started = perf_counter()
try:
if name == "MySQL":
with self._factory()() as session:
session.execute(text("SELECT 1"))
else:
if self.redis is None:
self.redis = Redis.from_url(
self.settings.redis_url, socket_connect_timeout=2, socket_timeout=2
)
self.redis.ping()
status, message = "normal", "连接正常。"
except Exception:
status, message = "error", "连接检查失败,请核对服务和网络。"
dependencies.append(
{
"name": name,
"status": status,
"message": message,
"checked_at": at,
"duration_ms": round((perf_counter() - started) * 1000, 2),
}
)
if dependencies[1]["status"] == "normal":
try:
with self._factory()() as session:
tasks = automation.overview(session, settings=self.settings, now=at)
source_configured = bool(
self.settings.t_plus_enabled or self.settings.t_plus_connection_file
)
if not source_configured:
status, message = "unknown", "T+ 未配置。"
elif tasks.get("stop_requested"):
status, message = "unknown", "已主动请求停止采集。"
elif not tasks["enabled"]:
status, message = "unknown", "自动采集未启用,可在原页面查看手动任务。"
else:
status = "warning" if tasks["alerts"] else "normal"
message = (
"存在采集异常,请查看任务与同步。"
if tasks["alerts"]
else "周期采集状态正常。"
)
tasks.update(status=status, message=message)
except Exception:
tasks = {
"status": "unknown",
"message": "任务状态读取失败,请检查迁移和服务日志。",
"alerts": [],
}
dependencies.append(
{
"name": "T+ 采集进程",
"status": tasks["status"],
"message": tasks["message"],
"checked_at": at,
"duration_ms": None,
}
)
return dependencies, tasks
def _evaluate(self, at, resources, dependencies, tasks):
active = {}
self.unknown_codes = set()
for dependency in dependencies:
if dependency["status"] == "error":
code = "MYSQL_UNAVAILABLE" if dependency["name"] == "MySQL" else "REDIS_UNAVAILABLE"
active[code] = ("error", dependency["name"], dependency["message"], None)
for field, threshold, code, title in (
("cpu_percent", self.settings.monitor_cpu_threshold, "CPU_HIGH", "CPU 占用持续偏高"),
(
"memory_percent",
self.settings.monitor_memory_threshold,
"MEMORY_HIGH",
"内存占用持续偏高",
),
("disk_percent", self.settings.monitor_disk_threshold, "DISK_HIGH", "磁盘空间不足"),
):
value = resources.get(field)
if value is None:
self.unknown_codes.add(code)
self.since.pop(code, None)
# 采集未知时不将仍存在的问题标为恢复。
if code in self.conditions:
previous = self.conditions[code]
active[code] = (
previous["level"],
previous["component"],
previous["message"],
None,
)
continue
if value >= threshold:
since = self.since.setdefault(code, at)
# 重启后的持续时间尚未建立时,不关闭历史同类告警。
self.unknown_codes.add(code)
if (
field == "disk_percent"
or (at - since).total_seconds()
>= self.settings.monitor_resource_duration_seconds
):
active[code] = ("warning", "resources", title + ",请检查资源趋势。", None)
else:
self.since.pop(code, None)
recent = self.recorder.metrics(minute_of(at) - timedelta(minutes=4))
metric = merge_metrics(m for endpoints in recent.values() for m in endpoints.values())
if (
metric["count"] >= self.settings.monitor_error_min_requests
and metric["error_rate"] >= self.settings.monitor_error_rate_threshold
):
active["HTTP_ERROR_RATE"] = (
"warning",
"http",
"近 5 分钟服务端错误率偏高,请查看失败接口。",
None,
)
if tasks.get("status") == "unknown" and "任务状态读取失败" in tasks.get("message", ""):
active["TASK_STATUS_UNKNOWN"] = ("warning", "tplus", tasks["message"], None)
for alert in tasks.get("alerts", []):
active[alert["code"]] = ("warning", "tplus", alert["message"], alert.get("batch_no"))
if "enabled" not in tasks:
self.unknown_codes.update(
{"WORKER_OFFLINE", "QUEUE_OVERDUE", "SYNC_STALE", "SYNC_REQUIRES_ATTENTION"}
)
for code, previous in self.conditions.items():
if code in self.unknown_codes:
active[code] = (
previous["level"],
previous["component"],
previous["message"],
previous.get("batch_no"),
)
for code, (level, component, message, batch) in active.items():
previous = self.conditions.get(code)
incident = previous or {
"id": 0,
"instance_id": self.recorder.instance_id,
"kind": "HEALTH",
"first_seen": at,
"occurrences": 0,
"code": code,
"fingerprint": fingerprint(code),
"resolved_at": None,
}
incident.update(
last_seen=at,
occurrences=incident["occurrences"] + 1,
level=level,
component=component,
message=message,
batch_no=batch,
)
self.conditions[code] = incident
self.pending_incidents[code] = deepcopy(incident)
for code in set(self.conditions) - set(active):
incident = self.conditions.pop(code)
incident.update(resolved_at=at)
self.pending_incidents[code] = deepcopy(incident)
def _persist(self, at, *, final=False):
pending = self.recorder.take_events()
try:
with self._factory()() as session:
# 首次采集复用历史未恢复事件,跨应用重启保留故障起点。
historical = {item.code: item for item in repo.active_events(session)}
for code, item in self.pending_incidents.items():
row = historical.get(code)
fields = {key: value for key, value in item.items() if key != "id"}
if row is None:
row = MonitorEvent(**fields)
session.add(row)
else:
for key in (
"last_seen",
"resolved_at",
"level",
"message",
"batch_no",
"instance_id",
):
setattr(row, key, fields.get(key))
row.occurrences = (
max(row.occurrences, fields["occurrences"])
if item.get("id") == row.id
else row.occurrences + fields["occurrences"]
)
session.flush()
if code in self.conditions:
self.conditions[code].update(
id=row.id, first_seen=row.first_seen, occurrences=row.occurrences
)
for code, row in historical.items():
if (
code not in self.conditions
and code not in self.pending_incidents
and code not in self.unknown_codes
):
# 只在本次完整采集后确认历史异常已恢复。
row.resolved_at = at
for item in pending:
session.add(MonitorEvent(instance_id=self.recorder.instance_id, **item))
completed = minute_of(at) + (timedelta(minutes=1) if final else timedelta())
metrics = self.recorder.metrics(end=completed)
written = []
for minute in sorted(set(self.samples) | set(metrics)):
if minute >= completed or minute in self.persisted:
continue
samples = self.samples.get(minute, [])
endpoints = metrics.get(minute, {})
resources = dict(samples[-1]) if samples else {}
for key in (
"cpu_percent",
"memory_percent",
"disk_percent",
"process_cpu_percent",
"process_memory_mb",
):
numbers = [sample[key] for sample in samples if sample.get(key) is not None]
resources[key] = round(sum(numbers) / len(numbers), 2) if numbers else None
session.add(
MonitorSnapshot(
instance_id=self.recorder.instance_id,
minute=minute,
sample_count=len(samples),
resources=resources,
requests=merge_metrics(endpoints.values()),
)
)
for (method, route), values in endpoints.items():
session.add(
MonitorRequestMinute(
instance_id=self.recorder.instance_id,
minute=minute,
method=method,
route=route,
metrics=values,
)
)
written.append(minute)
# 每次最多删除每表 1000 条,限定监控表及过期时间,避免长事务。
cutoff = at - timedelta(days=7)
for model, column in (
(MonitorSnapshot, MonitorSnapshot.minute),
(MonitorRequestMinute, MonitorRequestMinute.minute),
(MonitorEvent, MonitorEvent.last_seen),
):
expired = select(model.id).where(column < cutoff)
if model is MonitorEvent:
expired = expired.where(
(MonitorEvent.kind != "HEALTH") | MonitorEvent.resolved_at.is_not(None)
)
ids = session.scalars(expired.order_by(column).limit(1000)).all()
if ids:
session.execute(delete(model).where(model.id.in_(ids)))
session.commit()
self.persisted.update(written)
self.persisted = {
minute
for minute in self.persisted
if minute >= minute_of(at) - timedelta(minutes=10)
}
self.pending_incidents.clear()
with self.lock:
self.state.update(
last_persisted_at=at,
collection_status="normal",
collection_message="采集正常,分钟历史保留 7 天。",
)
except Exception:
self.recorder.restore_events(pending)
with self.lock:
self.state.update(
collection_status="warning",
collection_message="历史写入失败,部分数据可能缺失;请检查数据库迁移和服务日志。",
)
if monotonic() - self.last_log >= 60:
logger.warning("监控历史写入失败,请检查迁移及数据库", exc_info=True)
self.last_log = monotonic()
def tick(self):
at = now_local()
try:
resources = self._resources()
except Exception:
resources = {"scope": "资源采集失败"}
logger.warning("系统资源采集失败", exc_info=True)
dependencies, tasks = self._dependencies(at)
with self.lock:
self.samples.setdefault(minute_of(at), []).append(resources)
while len(self.samples) > 10:
self.samples.popitem(last=False)
self._evaluate(at, resources, dependencies, tasks)
with self.lock:
self.state.update(
collected_at=at, resources=resources, dependencies=dependencies, tasks=tasks
)
self._persist(at)
with self.lock:
self.state["alerts"] = deepcopy(list(self.conditions.values()))
if not resources.get("memory_percent"):
self.state.update(
collection_status="warning",
collection_message="资源采集失败,当前资源指标未知。",
)
def _run(self):
while not self.stopped.is_set():
started = monotonic()
try:
self.tick()
except Exception:
logger.warning("监控采集周期失败", exc_info=True)
with self.lock:
self.state.update(
collection_status="warning",
collection_message="采集周期失败,请检查服务日志。",
)
self.stopped.wait(max(0, 15 - (monotonic() - started)))
if self.engine is not None:
self._persist(now_local(), final=True)

View File

@ -0,0 +1,198 @@
"""管理员监控视图:已落库历史与当前分钟合并,保持无数据与故障语义。"""
from collections import defaultdict
from datetime import timedelta
from time import monotonic
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.monitoring import SHANGHAI, merge_metrics, minute_of, now_local
from app.core.response import PageResult
from app.repositories import monitor_repository as repo
from app.schemas.monitor import EventOut, OverviewOut
def window(query):
now = now_local()
end = query.end or now
if end.tzinfo:
end = end.astimezone(SHANGHAI).replace(tzinfo=None)
if end > now + timedelta(seconds=30) or end < now - timedelta(days=7):
raise BadRequestError("查询时间须在最近 7 天内")
return max(end - timedelta(hours=query.hours), now - timedelta(days=7)), end
def overview(session, collector):
state = collector.current()
now = now_local()
current_minute = minute_of(now)
stored = repo.snapshots(session, current_minute - timedelta(minutes=4), current_minute)
# 未落库分钟从有界缓冲补齐;仅跳过同实例已持久化的分钟。
known = {(row.instance_id, row.minute) for row in stored}
recent = collector.recorder.metrics(current_minute - timedelta(minutes=4))
values = [row.requests for row in stored]
values.extend(
metric
for minute, endpoints in recent.items()
if (collector.recorder.instance_id, minute) not in known
for metric in endpoints.values()
)
state["requests"] = merge_metrics(values)
stale = not state["collected_at"] or (now - state["collected_at"]).total_seconds() > 45
if stale:
state.update(
collection_status="unknown",
collection_message="采集未启用或已超过 45 秒未更新,当前状态未知。",
)
for dependency in state["dependencies"]:
dependency.update(status="unknown", message="采集已过期,请检查服务日志。")
persisted_alerts = {
row.code: EventOut.model_validate(row) for row in repo.active_events(session)
}
for item in state["alerts"]:
persisted_alerts[item["code"]] = EventOut.model_validate(item)
state["alerts"] = list(persisted_alerts.values())
status = (
"unknown"
if stale
else "error"
if any(dep["status"] == "error" for dep in state["dependencies"])
else "warning"
if state["alerts"] or state["collection_status"] == "warning"
else "normal"
)
return OverviewOut(
status=status,
environment=collector.settings.app_env,
version="0.1.0",
instance_id=collector.recorder.instance_id,
uptime_seconds=int(monotonic() - collector.recorder.started),
dropped_events=collector.recorder.dropped_events,
**state,
)
def trends(session, collector, query):
start, end = window(query)
stored = repo.snapshots(session, minute_of(start), end)
# 24 小时按 5 分钟、7 天按 30 分钟聚合,间断采集保留 null 点。
step = 1 if query.hours == 1 else 5 if query.hours == 24 else 30
groups = defaultdict(list)
for row in stored:
minute = row.minute.replace(minute=(row.minute.minute // step) * step)
groups[minute].append(
{"sample_count": row.sample_count, "resources": row.resources, "requests": row.requests}
)
at = minute_of(end)
recent = collector.recorder.metrics(start, end)
# 只补尚未持久化的分钟,历史查看不引入窗口外最新资源。
known = {(row.instance_id, row.minute) for row in stored}
sample_history = collector.sample_history()
for minute in set(recent) | set(sample_history):
if not start <= minute < end or (collector.recorder.instance_id, minute) in known:
continue
samples = sample_history.get(minute, [])
groups[minute.replace(minute=(minute.minute // step) * step)].append(
{
"sample_count": len(samples),
"resources": samples[-1] if samples else {},
"requests": merge_metrics(recent.get(minute, {}).values()),
}
)
cursor = minute_of(start).replace(minute=(start.minute // step) * step)
points = []
while cursor <= at:
rows = groups.get(cursor, [])
point = {"at": cursor, "sample_count": sum(row["sample_count"] for row in rows)}
for key in ("cpu_percent", "memory_percent", "disk_percent"):
values = [
(row["resources"].get(key), row["sample_count"])
for row in rows
if row["resources"].get(key) is not None and row["sample_count"]
]
point[key] = (
round(
sum(value * count for value, count in values)
/ sum(count for _, count in values),
2,
)
if values
else None
)
metric = merge_metrics(row["requests"] for row in rows)
point.update(
request_count=metric["count"] if rows else None,
error_rate=metric["error_rate"] if rows else None,
p95_ms=metric["p95_ms"],
)
points.append(point)
cursor += timedelta(minutes=step)
return {
"start": start,
"end": end,
"step_minutes": step,
"points": points,
"p95_approximate": True,
}
def interfaces(session, collector, query):
start, end = window(query)
rows = repo.request_minutes(session, minute_of(start), end, query.route)
truncated = len(rows) > 20000
rows = rows[:20000]
grouped, known = defaultdict(list), set()
for row in rows:
grouped[(row.method, row.route)].append(row.metrics)
known.add((row.instance_id, row.minute, row.method, row.route))
for minute, endpoints in collector.recorder.metrics(minute_of(start), end).items():
for (method, route), metric in endpoints.items():
if (not query.route or route == query.route) and (
collector.recorder.instance_id,
minute,
method,
route,
) not in known:
grouped[(method, route)].append(metric)
records = [
{"method": method, "route": route, **merge_metrics(items)}
for (method, route), items in grouped.items()
]
records.sort(key=lambda item: (item["p95_ms"] or 0, item["count"]), reverse=True)
return {
"start": start,
"end": end,
"list": records[:200],
"truncated": truncated or len(records) > 200,
"p95_approximate": True,
}
def events(session, query):
start, end = window(query)
rows, total = repo.events(session, query, start, end)
return PageResult(
records=[EventOut.model_validate(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def event(session, event_id):
row = repo.event(session, event_id)
if row is None or (
row.last_seen < now_local() - timedelta(days=7)
and not (row.kind == "HEALTH" and row.resolved_at is None)
):
raise NotFoundError("诊断事件不存在或已过保留期")
return EventOut.model_validate(row)
def diagnostic(session, collector, query):
# 仅导出与页面相同的白名单模型;不读取日志文件、配置、请求正文或工资明细。
return {
"generated_at": now_local(),
"overview": overview(session, collector),
"events": events(session, query),
"note": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,79 @@
"""权限目录随代码发布;菜单、按钮和 API 共用同一操作码。"""
MODULES = (
("dashboard", "工作台", "首页", ("view",)),
("system:users", "系统管理", "用户管理", ("view", "create", "update", "delete", "status", "password")),
("system:roles", "系统管理", "角色管理", ("view", "create", "update", "delete", "status")),
("system:business_roles", "系统管理", "计提岗位", ("view", "create", "update", "delete", "status")),
("system:params", "系统管理", "计提参数", ("view", "update", "withdraw", "export", "preview")),
("system:settings", "系统管理", "公司信息", ("view", "update")),
("system:logs", "系统管理", "日志查询", ("view",)),
("system:monitor", "系统管理", "系统监控", ("view", "export")),
("base:personnel", "基础资料", "人员管理", ("view", "create", "update", "delete", "status", "regions", "plans")),
("base:products", "基础资料", "产品管理", ("view", "create", "update", "delete", "status")),
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
("settlements", "绩效工资", "月度工资", ("view", "generate", "submit")),
("adjustments", "绩效工资", "工资调整", ("view", "create")),
("approvals", "绩效工资", "审核记录", ("view", "finance", "leader")),
("reports:business", "报表统计", "业务统计", ("view", "export")),
("reports:personal", "报表统计", "个人明细", ("view", "export")),
("reports:department", "报表统计", "部门汇总", ("view", "export")),
("reports:payments", "报表统计", "回款统计", ("view", "export")),
)
ACTION_NAMES = {
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",
"withdraw": "撤回参数", "collect": "创建采集", "retry": "重试采集", "import": "执行导入",
"facts": "维护补充资料", "prepare": "准备核算资料", "calculate": "核算与重算",
"allocation": "修改工资分配", "generate": "生成工资", "submit": "提交审核",
"finance": "财务复核", "leader": "领导审核",
}
CATALOG = {
f"{module}:{action}": {
"code": f"{module}:{action}", "module": module, "group": group,
"module_name": name, "name": ACTION_NAMES[action], "action": action,
"view_code": f"{module}:view", "admin_only": module in (
"system:users", "system:roles", "system:authorization", "system:monitor"
),
"maker_required": False,
}
for module, group, name, actions in MODULES for action in actions
}
MAKER_ROLE = "payroll_maker"
PAYROLL_WRITE_PERMISSIONS = frozenset(
code for code, item in CATALOG.items()
if item["module"] in ("payroll", "settlements", "adjustments") and item["action"] != "view"
)
MAKER_PERMISSIONS = {
code for code, item in CATALOG.items()
if code in PAYROLL_WRITE_PERMISSIONS or item["module"] in (
"base:personnel", "base:products", "base:customers", "business:businesses",
"business:shipments", "business:receipts", "business:ratings", "tplus",
) or (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计"))
}
USER_PERMISSIONS = {
code for code, item in CATALOG.items()
if item["action"] == "view" and item["group"] in ("基础资料", "业务数据")
}
ROLE_DEFAULTS = {
"admin": set(CATALOG), "user": USER_PERMISSIONS,
"marketing_ops": USER_PERMISSIONS,
"finance": USER_PERMISSIONS | {
code for code, item in CATALOG.items()
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
or (item["group"] == "报表统计" and item["action"] == "export")
} | {"approvals:finance", "system:params:view", "system:params:preview"},
"leader": USER_PERMISSIONS | {
code for code, item in CATALOG.items()
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
or (item["group"] == "报表统计" and item["action"] == "export")
} | {"approvals:leader", "system:params:view", "system:params:preview"},
MAKER_ROLE: MAKER_PERMISSIONS,
}

View File

@ -0,0 +1,84 @@
"""人员工资与任职资料按生效日期留存,生成历史工资不读后来变更的标准。"""
from datetime import date
from types import SimpleNamespace
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import MonthlySettlement
FIELDS = (
"salary_standard",
"business_role_id",
"hire_date",
"resignation_date",
"probation_start_date",
"regularization_date",
"is_probation",
)
def capture(person, effective):
return {
"status": str(person.status),
"effective_date": str(effective),
**{
key: str(getattr(person, key)) if getattr(person, key) is not None else None
for key in FIELDS
},
}
def begin_update(session, person, effective):
if session.scalar(
select(MonthlySettlement.id).where(
MonthlySettlement.settlement_month >= effective.strftime("%Y-%m")
)
):
raise BadRequestError(
"生效月份或后续月份已生成工资,请使用未来生效日期;历史差额走当月调整"
)
history = list(person.salary_history_json or [])
if not history:
baseline = person.hire_date or person.create_time.date()
history.append(capture(person, baseline))
return history
def save_version(person, effective, history=None):
entries = list(history or person.salary_history_json or [])
entries.append(capture(person, effective))
person.salary_history_json = entries
def resolve(person, day):
entries = [
entry for entry in (person.salary_history_json or []) if entry["effective_date"] <= str(day)
]
values = {key: getattr(person, key) for key in FIELDS}
values["status"] = person.status
if person.salary_history_json and not entries:
values["salary_standard"] = None
elif entries:
selected = max(enumerate(entries), key=lambda pair: (pair[1]["effective_date"], pair[0]))[1]
values["status"] = int(selected.get("status", "1"))
for key in FIELDS:
raw = selected.get(key)
if key.endswith("date"):
values[key] = date.fromisoformat(raw) if raw else None
elif key in ("business_role_id", "is_probation"):
values[key] = int(raw) if raw else 0
elif key == "salary_standard":
from decimal import Decimal
values[key] = Decimal(raw) if raw is not None else None
return SimpleNamespace(**values)
def employed(person, profile, day):
if profile.hire_date and profile.hire_date > day:
return False
if profile.resignation_date:
return day < profile.resignation_date
return profile.status == 1

View File

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

View File

@ -0,0 +1,320 @@
"""试用期计提、缓发和年度结清;支付受原业务起付及平方权重约束。"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import select
from app.models import AdjustmentOrder, PerformanceLedger, ProbationDeferral, ProbationPayment
from app.services import param_service
ZERO = Decimal("0")
def payment_state(session, payment, lock=False):
if payment.adjustment_order_id:
order = session.get(AdjustmentOrder, payment.adjustment_order_id, with_for_update=lock, populate_existing=lock)
return "ignored" if order is None or order.status not in (1, 2) else "reserved" if order.status == 1 else "paid"
if payment.settlement_id:
from app.models import MonthlySettlement
settlement = session.get(MonthlySettlement, payment.settlement_id, with_for_update=lock, populate_existing=lock)
return "ignored" if settlement is None or settlement.status == 4 else "reserved" if settlement.status != 3 else "paid"
return "ignored"
def on_date(person, day):
if hasattr(person, "salary_history_json"):
from app.services.personnel_salary import resolve
person = resolve(person, day)
start = person.probation_start_date
end = person.regularization_date
if start or end:
return (start is None or day >= start) and (end is None or day < end)
return person.is_probation == 1
def record(session, ledger):
if ledger.is_probation != 1:
return
if session.scalar(select(ProbationDeferral.id).where(ProbationDeferral.ledger_id == ledger.id)):
return
event = (ledger.snapshot_json or {}).get("event", {})
session.add(
ProbationDeferral(
ledger_id=ledger.id,
personnel_id=ledger.personnel_id,
year=str(event.get("probation_year") or ledger.receipt_date.year),
month=ledger.settlement_month,
accrued=ledger.performance_amount or ZERO,
eligible=ledger.payable_amount or ZERO,
)
)
def preview(session, month, personnel_id=None, *, lock=False):
"""查询不写表;旧试用期台账同样进入缓发查询,历史标记不重分类。"""
from app.core.month import validate_month
validate_month(month)
statement = (
select(PerformanceLedger)
.where(
PerformanceLedger.is_active == 1,
PerformanceLedger.is_probation == 1,
PerformanceLedger.settlement_month <= month,
)
.order_by(PerformanceLedger.personnel_id, PerformanceLedger.id)
)
if personnel_id:
statement = statement.where(PerformanceLedger.personnel_id == personnel_id)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
grouped = {}
for ledger in session.scalars(statement):
event = (ledger.snapshot_json or {}).get("event", {})
year = str(event.get("probation_year") or ledger.receipt_date.year)
key = ledger.personnel_id, year
row = grouped.setdefault(
key,
{
"personnel_id": ledger.personnel_id,
"personnel_name": ledger.personnel_name,
"year": year,
"accrued": ZERO,
"eligible": ZERO,
"paid": ZERO,
"reserved": ZERO,
"details": [],
},
)
row["accrued"] += ledger.performance_amount or ZERO
row["eligible"] += ledger.payable_amount or ZERO
row["details"].append(
{
"ledger_id": ledger.id,
"business_no": ledger.business_no,
"month": ledger.settlement_month,
"receipt_date": str(ledger.receipt_date),
"accrued": str(ledger.performance_amount or ZERO),
"eligible": str(ledger.payable_amount or ZERO),
}
)
payments = select(ProbationPayment).where(ProbationPayment.month <= month)
if lock:
payments = payments.with_for_update().execution_options(populate_existing=True)
for payment in session.scalars(payments):
state = payment_state(session, payment, lock)
if state == "ignored":
continue
key = payment.personnel_id, payment.year
if key in grouped:
grouped[key][state] += payment.amount
result = []
for (_, year), row in sorted(grouped.items()):
snap = param_service.resolve_snapshot(session, date(int(year), 12, 31))
threshold = param_service.to_decimal(Fraction(snap.get("probation.annual.threshold")))
entitlement = max(row["accrued"] - threshold, ZERO)
available = min(entitlement, max(row["eligible"], ZERO))
enabled = month >= year + "-12"
delta = available - row["paid"] - row["reserved"] if enabled else ZERO
result.append(
{
**{
k: v
for k, v in row.items()
if k not in ("accrued", "eligible", "paid", "reserved")
},
"accrued": str(row["accrued"]),
"eligible": str(row["eligible"]),
"paid": str(row["paid"]),
"threshold": str(threshold),
"entitlement": str(entitlement),
"available": str(available),
"reserved": str(row["reserved"]),
"remaining": str(max(entitlement - row["paid"], ZERO)),
"delta": str(delta),
"payment_month": month,
"can_settle": enabled,
}
)
from app.services.tplus.workbench import digest
result[-1]["fingerprint"] = digest(result[-1])
return {
"month": month,
"rows": result,
"rule": "仅累计试用期绩效;超年度门槛部分仍受业务起付和R×R限制;12月及以后月份结清差额。",
}
def persist_payments(session, settlement, items):
for item in items:
for row in (item.snapshot_json or {}).get("probation_settlements", []):
amount = Decimal(row["delta"])
if amount:
session.add(
ProbationPayment(
settlement_id=settlement.id,
personnel_id=item.personnel_id,
year=row["year"],
month=settlement.settlement_month,
amount=amount,
snapshot_json=row,
)
)
def export(session, month):
data = preview(session, month)
book = Workbook()
sheet = book.active
sheet.title = "缓发及年度结清"
sheet.append(
[
"人员",
"原年度",
"试用期计提",
"符合支付条件",
"年度门槛",
"超额权益",
"累计可补发",
"历史已补发",
"待审核占用",
"本月差额",
"剩余权益",
]
)
keys = (
"personnel_name",
"year",
"accrued",
"eligible",
"threshold",
"entitlement",
"available",
"paid",
"reserved",
"delta",
"remaining",
)
detail = book.create_sheet("原台账明细")
detail.append(["人员", "年度", "台账ID", "业务编号", "月份", "到账日", "计提额", "应付变化"])
for row in data["rows"]:
values = [row[k] for k in keys]
sheet.append(values)
for cell in sheet[sheet.max_row]:
if cell.data_type == "f":
cell.data_type = "s"
for entry in row["details"]:
detail.append(
[
row["personnel_name"],
row["year"],
*[
entry[k]
for k in (
"ledger_id",
"business_no",
"month",
"receipt_date",
"accrued",
"eligible",
)
],
]
)
for cell in detail[detail.max_row]:
if cell.data_type == "f":
cell.data_type = "s"
for page in (sheet, detail):
page.freeze_panes = "A2"
for col in page.columns:
page.column_dimensions[col[0].column_letter].width = 22
output = BytesIO()
book.save(output)
return output.getvalue()
def adjustment(session, payload, actor, meta=None):
"""月结已生成后只创建当月差额调整;年度余额与调整单原子写入。"""
from app.core.config import get_settings
from app.core.exceptions import BadRequestError
from app.models import MonthlySettlement, Personnel
from app.services import approval_service, audit_service, payroll_months
from app.services.audit_service import AuditRecord
from app.services.no_generator import next_no
from app.services.settlement_service import _wire_adjustment_approval
month = payroll_months.current_month()
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(actor)
payroll_months.lock_month(session, month, actor)
if (
session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
is None
):
raise BadRequestError("本月工资尚未生成,请随月结结清,无需单独调整")
person = session.get(Personnel, payload.personnel_id, with_for_update=True)
if person is None:
raise BadRequestError("人员不存在")
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="月度工资",
action="试用期结清调整",
summary=f"人员#{person.id}年度{payload.year}结清差额",
request_data=payload.model_dump(),
),
meta=meta,
):
row = next(
(r for r in preview(session, month, person.id, lock=True)["rows"] if r["year"] == payload.year),
None,
)
if row is None or row["fingerprint"] != payload.fingerprint:
raise BadRequestError("年度余额已变化,请重新查询预览")
amount = Decimal(row["delta"])
if not amount:
raise BadRequestError("没有待补发或扣回差额")
order = AdjustmentOrder(
order_no=next_no(session, model=AdjustmentOrder, column_name="order_no", prefix="TZ"),
settlement_month=month,
adjustment_type="PROBATION",
personnel_id=person.id,
personnel_name=person.personnel_name,
direction=1 if amount > 0 else 2,
amount=abs(amount),
reason=payload.reason,
submitter_id=actor.id,
submitter_name=actor.username,
attribution_json={"probation_year": payload.year, "snapshot": row},
)
session.add(order)
session.flush()
if get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3):
_wire_adjustment_approval(session, order, actor)
else:
order.status = 2
session.add(
ProbationPayment(
adjustment_order_id=order.id,
personnel_id=person.id,
year=payload.year,
month=month,
amount=amount,
snapshot_json=row,
)
)
session.flush()
out = {"order_no": order.order_no, "amount": str(amount), "month": month}
session.commit()
return out

View File

@ -0,0 +1,263 @@
"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。"""
from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import Business, BusinessItem, Product
from app.services import audit_service
from app.services.audit_service import AuditRecord
from app.services.tplus.snapshots import source_records, source_statement
from app.services.tplus.workbench import WorkbenchError, digest
def recent_trades(session, source, ids):
if not ids:
return {}
try:
lines = list(
session.scalars(
source_statement(session, source, "shipment_items").where(
source_statement_inventory_filter(ids)
)
)
)
parent_ids = {
str(r.payload_json["source_parent_id"])
for r in lines
if r.payload_json.get("source_parent_id") is not None
}
heads = {
r.source_id: r for r in source_records(session, source, "shipments", list(parent_ids))
}
except WorkbenchError:
return {}
latest = {}
for record in lines:
raw = record.payload_json
head = heads.get(str(raw.get("source_parent_id")))
if head is None:
continue
value = head.payload_json
if str(value.get("voucherState")) != "189" or value.get("isCounteractDelivery") not in (
None,
0,
False,
"0",
):
continue
day = str(value.get("voucherdate") or "")[:10]
try:
quantity = Decimal(str(raw.get("quantity")))
price = (
Decimal(str(raw["taxPrice"]))
if raw.get("taxPrice") is not None
else Decimal(str(raw["taxAmount"])) / quantity
)
if not quantity.is_finite() or quantity <= 0 or not price.is_finite() or price < 0:
continue
except (ValueError, ArithmeticError, TypeError, KeyError):
continue
key = str(raw.get("idinventory"))
order = day, int(head.source_id), int(record.source_id)
if not day or (key in latest and order <= latest[key][0]):
continue
if (
quantity > Decimal("999999999999.99")
or quantity.normalize().as_tuple().exponent < -2
or price > Decimal("9999999999.9999")
):
# 无法以当前产品档案精度无损保存的记录保持待核对,不截断数量。
continue
latest[key] = (
order,
{
"quantity": str(quantity),
"price": str(price.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)),
"date": day,
"source_no": head.source_no,
"source_id": head.source_id,
"line_id": record.source_id,
"source_database": source,
},
)
return {key: value[1] for key, value in latest.items()}
def source_statement_inventory_filter(ids):
from app.models import TPlusSyncRecord
return TPlusSyncRecord.payload_json["idinventory"].as_integer().in_([int(v) for v in ids])
def candidates(session, keyword="", page_num=1, page_size=20):
condition = BusinessItem.product_id.is_(None)
if keyword:
condition &= or_(
BusinessItem.product_name.contains(keyword, autoescape=True),
Business.business_no.contains(keyword, autoescape=True),
)
rows = session.execute(
select(BusinessItem, Business.business_no, Business.business_date)
.join(Business, Business.id == BusinessItem.business_id)
.where(condition)
.order_by(BusinessItem.id.desc())
.offset((page_num - 1) * page_size)
.limit(page_size + 1)
).all()
return {
"rows": [
{
"id": row.id,
"business_no": number,
"business_date": str(day) if day else None,
"product_name": row.product_name,
"spec_model": row.spec_model,
"unit": row.unit,
"product_category": row.product_category,
"quantity": str(row.quantity),
"sale_price": str(row.sale_price) if row.sale_price is not None else None,
}
for row, number, day in rows[:page_size]
],
"has_more": len(rows) > page_size,
"page_num": page_num,
}
def extraction_plan(session, ids, *, lock=False):
if len(ids) != len(set(ids)):
raise BadRequestError("不能重复选择同一业务明细")
statement = (
select(BusinessItem, Business.business_date)
.join(Business, Business.id == BusinessItem.business_id)
.where(BusinessItem.id.in_(ids))
.order_by(BusinessItem.id)
)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
source = session.execute(statement).all()
if len(source) != len(ids):
raise BadRequestError("所选业务明细不存在或已变化")
groups = defaultdict(list)
for item, day in source:
groups[(item.product_name.strip(), (item.spec_model or "").strip())].append((item, day))
rows = []
for (name, spec), members in groups.items():
target_statement = select(Product).where(
Product.product_name == name, Product.spec_model == spec
)
if lock:
target_statement = target_statement.with_for_update().execution_options(
populate_existing=True
)
target = session.scalar(target_statement)
units = {m.unit or "" for m, _ in members}
categories = {m.product_category for m, _ in members}
conflict = "所选明细单位或核算分类不一致" if len(units) > 1 or len(categories) > 1 else None
if target and (
target.product_category not in categories or (target.unit or "") not in units
):
conflict = "同名同规格档案的单位或核算分类不同"
newest, day = max(
members, key=lambda pair: (str(pair[1] or ""), pair[0].business_id, pair[0].id)
)
rows.append(
{
"product_name": name,
"spec_model": spec,
"unit": newest.unit,
"product_category": newest.product_category,
"quantity": str(newest.quantity),
"sale_price": str(newest.sale_price) if newest.sale_price is not None else None,
"item_ids": [m.id for m, _ in members],
"target_id": target.id if target else None,
"action": "CONFLICT" if conflict else "LINK" if target else "CREATE",
"reason": conflict,
"already_linked": [m.product_id for m, _ in members],
"target_fingerprint": digest(
{c.name: getattr(target, c.name) for c in target.__table__.columns}
)
if target
else None,
"date": str(day) if day else None,
}
)
return {"rows": rows, "fingerprint": digest(rows)}
def extract(session, payload, actor, meta=None):
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="产品管理",
action="提取产品",
summary=f"从{len(payload.item_ids)}条业务明细提取产品",
request_data=payload.model_dump(),
),
meta=meta,
):
plan = extraction_plan(session, payload.item_ids, lock=True)
if payload.fingerprint != plan["fingerprint"]:
raise BadRequestError("业务明细或产品档案已变化,请重新预览")
for row in plan["rows"]:
if row["action"] == "CONFLICT":
continue
try:
with session.begin_nested():
target = (
session.get(Product, row["target_id"])
if row["target_id"]
else Product(
product_name=row["product_name"],
spec_model=row["spec_model"],
unit=row["unit"],
product_category=row["product_category"],
stock_quantity=Decimal(row["quantity"]),
sale_price=Decimal(row["sale_price"])
if row["sale_price"] is not None
else None,
status=1,
quantity_price_manual=1,
)
)
session.add(target)
session.flush()
row["target_id"] = target.id
for item_id in row["item_ids"]:
item = session.get(BusinessItem, item_id)
# 仅建立档案关联,不更改历史冗余名称、金额或冻结核算资料。
if item.product_id is None:
item.product_id = target.id
elif item.product_id != target.id:
raise BadRequestError("业务明细已关联不同产品,请重新核对")
session.flush()
except IntegrityError:
row["action"], row["reason"] = "CONFLICT", "并发建立了同名档案,请重新预览"
session.commit()
return plan
def restore_source(session, product_id, actor, meta=None):
product = session.get(Product, product_id, with_for_update=True)
if product is None:
raise NotFoundError("产品不存在")
if not product.source_trade_json:
raise BadRequestError("没有可恢复的有效发货来源")
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="产品管理", action="恢复源值", summary=f"产品#{product_id}恢复最近有效发货参照"
),
meta=meta,
):
product.quantity_price_manual = 0
product.stock_quantity = Decimal(product.source_trade_json["quantity"])
product.sale_price = Decimal(product.source_trade_json["price"])
session.commit()
return {"id": product_id, "restored": True}

View File

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

View File

@ -0,0 +1,56 @@
"""正式核算与试算共用计提规则;比例保持 Fraction,产品覆盖存于参数版本。"""
from fractions import Fraction
from app.services import formula
PERSONAL_FIELDS = ("first", "start", "slope", "floor", "boundary")
BASE_CODES = {
"first": "salesperson.rate.start",
"start": "salesperson.rate.start",
"slope": "salesperson.rate.slope",
"floor": "salesperson.rate.floor",
"boundary": "salesperson.days.boundary",
}
def personal_values(snapshot, category):
"""未维护产品专属项时,继续使用同日期的通用参数,不复制初始值。"""
result = {}
for field in PERSONAL_FIELDS:
code = f"coefficient.personal.{field}"
overrides = snapshot._values.get(code, {})
result[field] = overrides.get(
category, overrides.get("", snapshot.get(BASE_CODES[field], role_code="salesperson"))
)
return result
def decide(params, *, category, customer_type, role_code, days, prepayment=False):
"""返回纯比例和计算基数类型。经理个人户排除优先于预收/企业户。"""
if role_code == "sales_manager" or customer_type == "manager_personal":
return Fraction(0), "converted", "经理本人/经理个人户不单笔计提"
if role_code == "info_officer":
return Fraction(str(params.get("info", "1"))) / 1000, "sales", "信息员按销售额计提"
if prepayment:
personal = params.get("personal", {}).get(category, params)
maximum = max(
Fraction(str(personal.get(key, personal["start"])))
for key in ("first", "start", "floor")
)
return maximum / 1000, "converted", "预收发货后按最高比例计提"
if customer_type == "enterprise":
value = params.get("enterprise_by_category", {}).get(category, params["enterprise"])
return Fraction(str(value)) / 1000, "converted", "企业户按产品分类固定计提"
values = params.get("personal", {}).get(category, params)
if days <= 1:
rate = Fraction(str(values.get("first", values["start"]))) / 1000
else:
rate = formula.salesperson_rate(
days,
start=Fraction(str(values["start"])),
slope=Fraction(str(values["slope"])),
floor=Fraction(str(values["floor"])),
boundary_days=int(Fraction(str(values.get("boundary", 360)))),
)
return rate, "converted", "个人户按产品分类及回款阶段计提"

View File

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

View File

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

View File

@ -0,0 +1,190 @@
"""完整工资按人汇总:基本工资只计一次,试用期缓发及年度释放分列。"""
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from app.models import BusinessRole, Personnel, ProbationPayment, SettlementItem
from app.services import formula, probation_service
from app.services.manager_share import month_end
ZERO = Decimal("0")
def build_items(session, rows, month):
from app.services import personnel_salary
from app.services.settlement_service import _salary_mode_of
end = month_end(month)
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
people = {
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
}
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
profiles = {pid: personnel_salary.resolve(person, end) for pid, person in people.items()}
buckets = {}
def bucket(person):
return buckets.setdefault(
person.id,
{
"person": person,
"performance": ZERO,
"normal": ZERO,
"deferred": ZERO,
"ledger_ids": [],
"annual": [],
"details": [],
},
)
for person in people.values():
if (
personnel_salary.employed(person, profiles[person.id], end)
and profiles[person.id].salary_standard is not None
):
bucket(person)
for row in rows:
person = people.get(row.personnel_id)
if person is None:
continue
item = bucket(person)
item["performance"] += row.performance_amount or ZERO
key = "deferred" if row.is_probation == 1 else "normal"
item[key] += row.payable_amount or ZERO
item["ledger_ids"].append(row.id)
item["details"].append(
{
"ledger_id": row.id,
"calc_type": row.calc_type,
"performance": str(row.performance_amount or ZERO),
"payable": str(row.payable_amount or ZERO),
}
)
probation_service.record(session, row)
session.flush()
preview = probation_service.preview(session, month, lock=True)
for annual in preview["rows"]:
person = people.get(annual["personnel_id"])
if person is None or not Decimal(annual["delta"]):
continue
later = session.scalars(
select(ProbationPayment).where(
ProbationPayment.personnel_id == person.id,
ProbationPayment.year == annual["year"],
ProbationPayment.month > month,
)
)
if any(probation_service.payment_state(session, payment, True) != "ignored" for payment in later):
from app.core.exceptions import BadRequestError
raise BadRequestError("该年度已有后续月份结清记录,请按月份顺序处理差额")
bucket(person)["annual"].append(annual)
result = []
for pid, values in sorted(buckets.items()):
person = values["person"]
profile = profiles[pid]
role = roles.get(profile.business_role_id, "salesperson")
mode = _salary_mode_of(session, role, month)
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
normal = values["normal"]
basic = profile.salary_standard if personnel_salary.employed(person, profile, end) else ZERO
trial = probation_service.on_date(profile, end)
net = (
((basic or ZERO) + normal + annual)
if trial
else formula.monthly_salary(normal + annual, basic, mode)
)
result.append(
SettlementItem(
personnel_id=pid,
personnel_name=person.personnel_name,
role_code=role,
calc_type=2 if role == "sales_manager" else 1,
performance_sum=values["performance"].quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
),
payable_sum=(normal + annual).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
normal_payable_sum=normal,
deferred_sum=values["deferred"],
probation_payment_sum=annual,
basic_amount=basic,
salary_mode="INCLUDE" if trial else mode,
net_amount=net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if net is not None
else None,
is_probation=int(trial),
probation_note="试用期绩效进入缓发台账;本月仅发基本工资及符合条件的年度补发"
if trial
else None,
snapshot_json={
"ledger_ids": values["ledger_ids"],
"calculation_details": values["details"],
"probation_settlements": values["annual"],
},
)
)
return result, [row.id for row in rows]
def frozen_rows(session, month, personnel_ids=None):
from app.models import MonthlySettlement
statement = (
select(SettlementItem)
.join(MonthlySettlement)
.where(MonthlySettlement.settlement_month == month)
)
if personnel_ids is not None:
statement = statement.where(SettlementItem.personnel_id.in_(personnel_ids))
return [
{
"personnel_id": item.personnel_id,
"personnel_name": item.personnel_name,
"basic_amount": str(item.basic_amount) if item.basic_amount is not None else None,
"normal_payable_sum": str(item.normal_payable_sum),
"deferred_sum": str(item.deferred_sum),
"probation_payment_sum": str(item.probation_payment_sum),
"payable_sum": str(item.payable_sum),
"net_amount": str(item.net_amount) if item.net_amount is not None else None,
}
for item in session.scalars(statement)
]
def export_settlement(session, month):
from io import BytesIO
from openpyxl import Workbook
book = Workbook()
sheet = book.active
sheet.title = "工资分项"
sheet.append(
["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"]
)
for row in frozen_rows(session, month):
sheet.append(
[
row["personnel_name"],
*[
Decimal(row[key]) if row[key] is not None else None
for key in (
"basic_amount",
"normal_payable_sum",
"deferred_sum",
"probation_payment_sum",
"payable_sum",
"net_amount",
)
],
]
)
if sheet.cell(sheet.max_row, 1).data_type == "f":
sheet.cell(sheet.max_row, 1).data_type = "s"
sheet.freeze_panes = "A2"
for col in sheet.columns:
sheet.column_dimensions[col[0].column_letter].width = 24
output = BytesIO()
book.save(output)
return output.getvalue()

View File

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

View File

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

View File

@ -0,0 +1,68 @@
"""源级自动采集设置;页面、周期维护共用,不修改进程环境配置。"""
from sqlalchemy import inspect, select, update
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, ConflictError
from app.models import TPlusAutomationConfig
from app.services.authorization_service import require_permission
from app.services.tplus import task_service
def configuration(session, settings):
from app.services.tplus.automation import source_identity
result = {"enabled": settings.t_plus_automation_enabled, "version": 0,
"editable": False, "configuration_origin": "ENVIRONMENT"}
try:
key, _database = source_identity(settings)
except RuntimeError:
return result
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
return result
result["editable"] = True
row = session.scalar(select(TPlusAutomationConfig).where(
TPlusAutomationConfig.source_key == key
).execution_options(populate_existing=True))
if row is not None:
result.update(enabled=bool(row.enabled), version=row.version, configuration_origin="SAVED")
return result
def save(session, payload, actor):
from app.services.tplus.automation import source_identity
require_permission(session, actor.user, "tplus:collect")
settings = task_service.get_settings()
try:
key, database = source_identity(settings)
except RuntimeError:
raise BadRequestError("请先配置可用的 T+ 数据源") from None
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
raise BadRequestError("自动采集设置尚未升级,请联系维护人员")
data = {"enabled": payload.enabled, "expected_version": payload.expected_version}
with task_service._audit(session, actor, "修改自动采集", data):
row = session.scalar(select(TPlusAutomationConfig).where(TPlusAutomationConfig.source_key == key))
if row is None:
if payload.expected_version != 0:
raise ConflictError()
session.add(TPlusAutomationConfig(source_key=key, source_database=database,
enabled=int(payload.enabled), version=1, updated_by=actor.user.id))
try:
session.commit()
except IntegrityError:
session.rollback()
raise ConflictError() from None
else:
changed = session.execute(update(TPlusAutomationConfig).where(
TPlusAutomationConfig.source_key == key,
TPlusAutomationConfig.version == payload.expected_version,
).values(enabled=int(payload.enabled), version=payload.expected_version + 1,
updated_by=actor.user.id))
if changed.rowcount != 1:
session.rollback()
raise ConflictError()
session.commit()
from app.services.tplus.automation import overview
return overview(session, settings=settings)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
"""批次读取进度元数据;只暴露类别和数量,不携带业务原始载荷。"""
import copy
from datetime import datetime
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
ENTITIES = frozenset(mapping.entity_type for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS))
def write(batch, *, phase=None, totals=None, active=None, read=None, done=None, failed=None):
report = copy.deepcopy(batch.audit_result_json or {})
value = report.setdefault("progress", {"phase": "COUNTING", "entities": {}})
if phase:
value["phase"] = phase
if totals is not None:
value["total_records"] = sum(totals.values())
for entity, count in totals.items():
value["entities"][entity] = {"total": count, "processed": 0, "status": "WAITING"}
if active is not None:
value["active_entity"] = active
value["entities"].setdefault(active, {"total": None, "processed": 0})["status"] = "READING"
if read is not None and value.get("active_entity"):
value["entities"][value["active_entity"]]["processed"] = read
if done:
value["entities"][done]["status"] = "COMPLETED"
if failed:
value["entities"].setdefault(failed, {"total": None, "processed": 0})["status"] = "FAILED"
value["updated_at"] = datetime.now().isoformat()
batch.audit_result_json = report
def public(batch, report=None):
"""旧任务总量未知时不给百分比;成功必须以批次终态为准。"""
report = report or batch.__dict__.get("audit_result_json") or {}
value = report.get("progress") or {}
if not isinstance(value, dict):
value = {}
total = value.get("total_records")
total = total if isinstance(total, int) and not isinstance(total, bool) and total >= 0 else None
processed = batch.source_record_count
status = batch.status
percentage = None
if status == "SUCCEEDED":
total, percentage = processed, 100.0
elif total and processed >= 0:
percentage = min(99.9, round(processed * 100 / total, 1))
entities = []
observed = report.get("observed_entities", [])
for entity in batch.entity_types_json or []:
if not isinstance(entity, str) or entity not in ENTITIES:
continue
details = value.get("entities", {}).get(entity, {})
count = details.get("total")
entities.append({"entity_type": entity, "total_records": count,
"processed_records": details.get("processed"),
"status": details.get("status", "COMPLETED" if entity in observed else "WAITING")})
updated = value.get("updated_at")
try:
updated = datetime.fromisoformat(updated) if updated else None
except (ValueError, TypeError):
updated = None
elapsed = max(0, int(((batch.finished_at or datetime.now()) - batch.started_at).total_seconds())) if batch.started_at else 0
phase = "COMPLETED" if status == "SUCCEEDED" else "FAILED" if status in ("FAILED", "PARTIAL") else value.get("phase", status)
return {"phase": phase, "total_records": total, "processed_records": processed,
"percentage": percentage, "active_entity": value.get("active_entity")
if value.get("active_entity") in ENTITIES else None,
"updated_at": updated, "elapsed_seconds": elapsed, "entities": entities}

View File

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

View File

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

View File

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

View File

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

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