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

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

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

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

No files matched your search

+21 -6
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
+4 -3
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,
),
)
@@ -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("已取消单独的制作人绑定,请在角色管理配置权限,再在用户管理分配角色")
@@ -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]
@@ -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
@@ -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
+40 -15
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,
+20 -8
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(
+21 -18
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}"}
)
@@ -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
+139 -34
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
+330
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()
+14
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
+54 -16
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"],
}
+477
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)
+198
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": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。",
}
+38 -22
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)
+36 -44
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:
+12 -6
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
+23 -3
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:
+5 -4
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)
@@ -0,0 +1,79 @@
"""权限目录随代码发布;菜单、按钮和 API 共用同一操作码。"""
MODULES = (
("dashboard", "工作台", "首页", ("view",)),
("system:users", "系统管理", "用户管理", ("view", "create", "update", "delete", "status", "password")),
("system:roles", "系统管理", "角色管理", ("view", "create", "update", "delete", "status")),
("system:business_roles", "系统管理", "计提岗位", ("view", "create", "update", "delete", "status")),
("system:params", "系统管理", "计提参数", ("view", "update", "withdraw", "export", "preview")),
("system:settings", "系统管理", "公司信息", ("view", "update")),
("system:logs", "系统管理", "日志查询", ("view",)),
("system:monitor", "系统管理", "系统监控", ("view", "export")),
("base:personnel", "基础资料", "人员管理", ("view", "create", "update", "delete", "status", "regions", "plans")),
("base:products", "基础资料", "产品管理", ("view", "create", "update", "delete", "status")),
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
("settlements", "绩效工资", "月度工资", ("view", "generate", "submit")),
("adjustments", "绩效工资", "工资调整", ("view", "create")),
("approvals", "绩效工资", "审核记录", ("view", "finance", "leader")),
("reports:business", "报表统计", "业务统计", ("view", "export")),
("reports:personal", "报表统计", "个人明细", ("view", "export")),
("reports:department", "报表统计", "部门汇总", ("view", "export")),
("reports:payments", "报表统计", "回款统计", ("view", "export")),
)
ACTION_NAMES = {
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",
"withdraw": "撤回参数", "collect": "创建采集", "retry": "重试采集", "import": "执行导入",
"facts": "维护补充资料", "prepare": "准备核算资料", "calculate": "核算与重算",
"allocation": "修改工资分配", "generate": "生成工资", "submit": "提交审核",
"finance": "财务复核", "leader": "领导审核",
}
CATALOG = {
f"{module}:{action}": {
"code": f"{module}:{action}", "module": module, "group": group,
"module_name": name, "name": ACTION_NAMES[action], "action": action,
"view_code": f"{module}:view", "admin_only": module in (
"system:users", "system:roles", "system:authorization", "system:monitor"
),
"maker_required": False,
}
for module, group, name, actions in MODULES for action in actions
}
MAKER_ROLE = "payroll_maker"
PAYROLL_WRITE_PERMISSIONS = frozenset(
code for code, item in CATALOG.items()
if item["module"] in ("payroll", "settlements", "adjustments") and item["action"] != "view"
)
MAKER_PERMISSIONS = {
code for code, item in CATALOG.items()
if code in PAYROLL_WRITE_PERMISSIONS or item["module"] in (
"base:personnel", "base:products", "base:customers", "business:businesses",
"business:shipments", "business:receipts", "business:ratings", "tplus",
) or (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计"))
}
USER_PERMISSIONS = {
code for code, item in CATALOG.items()
if item["action"] == "view" and item["group"] in ("基础资料", "业务数据")
}
ROLE_DEFAULTS = {
"admin": set(CATALOG), "user": USER_PERMISSIONS,
"marketing_ops": USER_PERMISSIONS,
"finance": USER_PERMISSIONS | {
code for code, item in CATALOG.items()
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
or (item["group"] == "报表统计" and item["action"] == "export")
} | {"approvals:finance", "system:params:view", "system:params:preview"},
"leader": USER_PERMISSIONS | {
code for code, item in CATALOG.items()
if (item["action"] == "view" and item["group"] in ("绩效工资", "报表统计", "工作台"))
or (item["group"] == "报表统计" and item["action"] == "export")
} | {"approvals:leader", "system:params:view", "system:params:preview"},
MAKER_ROLE: MAKER_PERMISSIONS,
}
+84
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
+52 -7
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
+320
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
+263
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}
+8 -2
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
+56
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", "个人户按产品分类及回款阶段计提"
+11
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()
+5
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,
+190
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()
+21 -2
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),
+16 -2
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":
@@ -0,0 +1,68 @@
"""源级自动采集设置;页面、周期维护共用,不修改进程环境配置。"""
from sqlalchemy import inspect, select, update
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, ConflictError
from app.models import TPlusAutomationConfig
from app.services.authorization_service import require_permission
from app.services.tplus import task_service
def configuration(session, settings):
from app.services.tplus.automation import source_identity
result = {"enabled": settings.t_plus_automation_enabled, "version": 0,
"editable": False, "configuration_origin": "ENVIRONMENT"}
try:
key, _database = source_identity(settings)
except RuntimeError:
return result
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
return result
result["editable"] = True
row = session.scalar(select(TPlusAutomationConfig).where(
TPlusAutomationConfig.source_key == key
).execution_options(populate_existing=True))
if row is not None:
result.update(enabled=bool(row.enabled), version=row.version, configuration_origin="SAVED")
return result
def save(session, payload, actor):
from app.services.tplus.automation import source_identity
require_permission(session, actor.user, "tplus:collect")
settings = task_service.get_settings()
try:
key, database = source_identity(settings)
except RuntimeError:
raise BadRequestError("请先配置可用的 T+ 数据源") from None
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
raise BadRequestError("自动采集设置尚未升级,请联系维护人员")
data = {"enabled": payload.enabled, "expected_version": payload.expected_version}
with task_service._audit(session, actor, "修改自动采集", data):
row = session.scalar(select(TPlusAutomationConfig).where(TPlusAutomationConfig.source_key == key))
if row is None:
if payload.expected_version != 0:
raise ConflictError()
session.add(TPlusAutomationConfig(source_key=key, source_database=database,
enabled=int(payload.enabled), version=1, updated_by=actor.user.id))
try:
session.commit()
except IntegrityError:
session.rollback()
raise ConflictError() from None
else:
changed = session.execute(update(TPlusAutomationConfig).where(
TPlusAutomationConfig.source_key == key,
TPlusAutomationConfig.version == payload.expected_version,
).values(enabled=int(payload.enabled), version=payload.expected_version + 1,
updated_by=actor.user.id))
if changed.rowcount != 1:
session.rollback()
raise ConflictError()
session.commit()
from app.services.tplus.automation import overview
return overview(session, settings=settings)
@@ -22,8 +22,8 @@ from app.models import (
User,
)
from app.services import payroll_months
from app.services.authorization_service import require_permission
from app.services.no_generator import next_no
from app.services.payroll_access import ensure_payroll_writer
from app.services.tplus.calculation_preview import execute_preview
from app.services.tplus.fifo import money
from app.services.tplus.workbench import WorkbenchError, digest, get_run
@@ -367,13 +367,13 @@ def _create(session, run, row, frozen):
def execute_business(session, run):
operator = session.get(User, run.requested_by)
ensure_payroll_writer(operator)
require_permission(session, operator, "tplus:import" if run.kind == "BUSINESS_APPLY" else "tplus:preview")
request = run
if run.kind == "BUSINESS_APPLY":
request = get_run(session, run.input_json["preview_no"], run.source_database)
if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"):
raise WorkbenchError("PREVIEW_REQUIRED")
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator)
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator, require_maker=False)
rows, frozen, fingerprint = _build(session, request)
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
raise WorkbenchError("PREVIEW_STALE")
@@ -10,6 +10,7 @@ from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import Customer, Product, TPlusSourceLink, TPlusSyncRecord, TPlusWorkLine
from app.services import param_service
from app.services.rate_rules import personal_values
from app.services.tplus.fifo import START, amount_text, money, preview_group
from app.services.tplus.snapshots import source_records, source_statement
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
@@ -20,11 +21,24 @@ def _parameters(session, days):
for day in sorted(days):
snap = param_service.resolve_snapshot(session, date.fromisoformat(day))
values[day] = {
"start": snap.get("salesperson.rate.start"),
"slope": snap.get("salesperson.rate.slope"),
"floor": snap.get("salesperson.rate.floor"),
"boundary": snap.integer("salesperson.days.boundary"),
"parameter_snapshot_hash": digest([
(code, dimension, value, str(snap._effective.get((code, dimension))))
for code, dimension, value in sorted(snap.iter_items())
]),
"start": snap.get("salesperson.rate.start", role_code="salesperson"),
"slope": snap.get("salesperson.rate.slope", role_code="salesperson"),
"floor": snap.get("salesperson.rate.floor", role_code="salesperson"),
"boundary": snap.integer("salesperson.days.boundary", role_code="salesperson"),
"enterprise": snap.get("enterprise.rate"),
"enterprise_by_category": {
category: snap.get("enterprise.rate", product_category=category)
for category in ("municipal_water", "rail_transit")
},
"info": snap.get("info.rate", role_code="info_officer"),
"personal": {
category: personal_values(snap, category)
for category in ("municipal_water", "rail_transit")
},
"thresholds": {
category: snap.get("product.threshold", product_category=category)
for category in ("municipal_water", "rail_transit")
+5
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):
+11 -15
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",
}
+15 -5
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())
+67
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}
+16 -5
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()),
)
+14
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,
@@ -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(
+29 -1
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
+36 -20
View File
@@ -9,13 +9,13 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.task_service import _source_database, new_batch_no
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
RULE_VERSION = "2026-10-30.2"
logger = logging.getLogger(__name__)
@@ -30,6 +30,17 @@ def digest(data):
).hexdigest()
def _work_source_database(session):
"""工作区使用本地已存资料;SQL Server 读取开关只约束真正的源采集。"""
try:
return _source_database()
except BadRequestError:
source = history_source_database(session)
if source is None:
raise BadRequestError("尚无可用的源账套,请先配置账套并完成源数据采集") from None
return source
def latest_facts(session, source):
"""每个事实键只读取最近版本,历史行保持可追溯。"""
ids = (
@@ -47,7 +58,7 @@ def latest_facts(session, source):
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
source = _source_database()
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
@@ -88,7 +99,7 @@ def fact_out(fact):
def list_facts(session, query):
source = _source_database()
source = history_source_database(session)
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
@@ -115,8 +126,13 @@ def list_facts(session, query):
def queue_work(session, payload: WorkCreateIn, actor_id):
"""HTTP 只排队;同幂等键的不同意图必须被拒绝。"""
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, actor_id),
"tplus:import" if payload.kind.endswith("_APPLY") else "tplus:preview")
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _source_database()
source = _work_source_database(session)
key = str(payload.request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
@@ -131,15 +147,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
preview = get_run(session, payload.preview_no, source)
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的基础导入预览")
if payload.kind.startswith("BUSINESS_"):
from app.models import User
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(session.get(User, actor_id))
if payload.kind == "BUSINESS_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
if payload.kind == "BUSINESS_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
if payload.replay_no:
previous = get_run(session, payload.replay_no, source)
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
@@ -165,7 +176,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
def get_run(session, number, source=None):
source = source or _source_database()
source = source or history_source_database(session)
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source)
@@ -194,7 +205,7 @@ def work_out(run):
def list_work(session, query):
conditions = [TPlusWorkRun.source_database == _source_database()]
conditions = [TPlusWorkRun.source_database == history_source_database(session)]
if query.kind:
conditions.append(TPlusWorkRun.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions))
@@ -248,7 +259,7 @@ def list_lines(session, number, query):
def fact_history(session, fact_id, query):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
fact = session.get(TPlusFact, fact_id)
if fact is None or fact.source_database != _source_database():
if fact is None or fact.source_database != history_source_database(session):
raise NotFoundError("补充资料不存在")
conditions = [
TPlusFact.source_database == fact.source_database,
@@ -276,7 +287,7 @@ def source_choices(session, query):
from app.services.tplus.snapshots import source_statement
try:
statement = source_statement(session, _source_database(), query.entity)
statement = source_statement(session, history_source_database(session), query.entity)
except WorkbenchError:
raise BadRequestError("请先完整读取相关源数据") from None
if query.search:
@@ -409,8 +420,13 @@ def execute_next(session):
run.status, run.started_at = "RUNNING", datetime.now()
session.commit()
try:
if run.source_database != _source_database():
if run.source_database != _work_source_database(session):
raise WorkbenchError("SOURCE_CHANGED")
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, run.requested_by),
"tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview")
if run.kind.startswith("BUSINESS_"):
from app.services.tplus.business_import import execute_business
@@ -430,7 +446,7 @@ def execute_next(session):
run = get_run(session, run.run_no, run.source_database)
run.status, run.finished_at = "FAILED", datetime.now()
# 不让驱动异常、SQL 或原始数据进入响应。
run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)
+6
View File
@@ -43,6 +43,12 @@ def run_once(session, source_factory=None, *, worker_owner=None):
def _execute(session, batch, source_factory):
configured = False
try:
if batch.requested_by is not None:
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, batch.requested_by),
"tplus:retry" if batch.parent_batch_no else "tplus:collect")
settings = resolve_tplus_settings(task_service.get_settings())
if batch.source_database and batch.source_database != settings.t_plus_database:
raise RuntimeError("任务排队后的源账套配置已改变")
+12
View File
@@ -42,6 +42,7 @@ from app.repositories import role_repository, user_repository
from app.schemas.role import RoleOut
from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn
from app.services import audit_service
from app.services import authorization_management as auth_management
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
@@ -115,6 +116,7 @@ def create_user(
if user_repository.get_by_username(session, payload.username) is not None:
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
policy = auth_management.lock_policy(session, operator)
roles = _resolve_roles(session, payload.roles)
user = User(
@@ -129,6 +131,7 @@ def create_user(
try:
user_repository.create(session, user)
user_repository.replace_roles(session, user, roles)
auth_management.bump(policy, operator)
out = _to_out(user)
session.commit()
except IntegrityError as exc:
@@ -192,8 +195,11 @@ def update_user(
session.flush()
if payload.roles is not None:
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, roles=payload.roles)
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
auth_management.bump(policy, operator)
out = _to_out(user)
session.commit()
@@ -273,7 +279,10 @@ def set_status(
if status == 0 and user_id == operator.id:
raise ForbiddenError("不能禁用自己")
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, status=status)
user.status = status
auth_management.bump(policy, operator)
if status == 0:
user.token_version += 1
out = _to_out(user)
@@ -313,6 +322,9 @@ def delete_user(
if user_id == operator.id:
raise ForbiddenError("不能删除自己")
policy = auth_management.lock_policy(session, operator)
auth_management.guard_user_change(session, user, operator, deleting=True)
auth_management.bump(policy, operator)
username = user.username # 固化:对象随删除失效
user_repository.delete(session, user)
session.commit()