feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
This commit is contained in:
1 parent
02fb06e0af
commit
1461a8d113
149 files changed
+8736
-368
No files matched your search
@@ -35,7 +35,7 @@ from app.core.constants import (
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, Business, User
|
||||
from app.models.approval import (
|
||||
@@ -53,7 +53,6 @@ from app.models.approval import (
|
||||
APPROVAL_STATUS_REJECTED,
|
||||
APPROVAL_STATUS_WITHDRAWN,
|
||||
)
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.approval import (
|
||||
ApprovalActionIn,
|
||||
ApprovalOrderDetailOut,
|
||||
@@ -261,7 +260,6 @@ def act(
|
||||
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
||||
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
||||
"""
|
||||
codes = set(user_repository.get_role_codes(operator))
|
||||
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -281,15 +279,29 @@ def act(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=True)
|
||||
# 与工资生成保持相同人员锁顺序,审核驳回释放年度余额不得绕过结清锁。
|
||||
hint = session.get(ApprovalOrder, order_id)
|
||||
if hint is not None and hint.order_type in (2, 3):
|
||||
from app.models import AdjustmentOrder, Personnel, SettlementItem
|
||||
|
||||
person_ids = []
|
||||
if hint.order_type == 2:
|
||||
person_ids = list(session.scalars(select(SettlementItem.personnel_id).where(SettlementItem.settlement_id == hint.ref_id)))
|
||||
else:
|
||||
target_adjustment = session.get(AdjustmentOrder, hint.ref_id)
|
||||
if target_adjustment:
|
||||
person_ids = [target_adjustment.personnel_id]
|
||||
list(session.scalars(select(Personnel).where(Personnel.id.in_(person_ids)).order_by(Personnel.id).with_for_update()))
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=True, populate_existing=True)
|
||||
if order is None:
|
||||
raise NotFoundError("审批单不存在")
|
||||
if order.status not in _PENDING_STATUSES:
|
||||
raise BadRequestError("审批单已结束,不可重复操作")
|
||||
|
||||
required_role = _NODE_ROLE_BY_STATUS[order.status]
|
||||
if required_role not in codes:
|
||||
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, operator, "approvals:" + required_role)
|
||||
|
||||
node_now = APPROVAL_NODES[order.status]
|
||||
rejected = payload.action == APPROVAL_ACTION_REJECT
|
||||
@@ -528,6 +540,9 @@ def settlement_content(session: Session, settlement_id: int) -> dict | None:
|
||||
"basic_amount": _amount_text(item.basic_amount),
|
||||
"salary_mode": item.salary_mode,
|
||||
"net_amount": _amount_text(item.net_amount),
|
||||
"normal_payable_sum": str(item.normal_payable_sum),
|
||||
"deferred_sum": str(item.deferred_sum),
|
||||
"probation_payment_sum": str(item.probation_payment_sum),
|
||||
"is_probation": item.is_probation,
|
||||
}
|
||||
for item in items
|
||||
|
||||
@@ -17,7 +17,6 @@ from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -141,12 +140,14 @@ def login(
|
||||
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
|
||||
|
||||
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
|
||||
from app.services.authorization_service import session_info
|
||||
|
||||
authorization = session_info(session, user)
|
||||
return LoginOut(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes,
|
||||
can_write_payroll=can_write_payroll(user),
|
||||
id=user.id, username=user.username, real_name=user.real_name, **authorization,
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +28,7 @@ from app.core.constants import (
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import BusinessRole, User
|
||||
from app.repositories import business_role_repository
|
||||
from app.repositories import business_role_repository, calc_template_repository
|
||||
from app.schemas.business_role import (
|
||||
BusinessRoleCreateIn,
|
||||
BusinessRoleOptionOut,
|
||||
@@ -60,10 +60,13 @@ def _to_out(role: BusinessRole) -> BusinessRoleOut:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_template(calc_template: str) -> None:
|
||||
"""模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。"""
|
||||
if calc_template not in TEMPLATES:
|
||||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||||
def _ensure_template(session: Session, calc_template: str) -> None:
|
||||
"""模板必须存在于模板定义;迁移前旧库回退内置注册表兜底。"""
|
||||
if calc_template_repository.get_by_code(session, calc_template) is not None:
|
||||
return
|
||||
if calc_template in TEMPLATES:
|
||||
return
|
||||
raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在")
|
||||
|
||||
|
||||
def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]:
|
||||
@@ -106,18 +109,40 @@ def get_role(session: Session, role_id: int) -> BusinessRoleOut:
|
||||
return _to_out(role)
|
||||
|
||||
|
||||
def list_template_options() -> list[TemplateOptionOut]:
|
||||
"""公式模板下拉(含声明参数,供岗位表单展示与引导配置)。"""
|
||||
def list_template_options(session: Session) -> list[TemplateOptionOut]:
|
||||
"""公式模板下拉:数据库定义优先;迁移前旧库回退内置注册表。
|
||||
|
||||
含停用模板(历史岗位仍绑定);新增岗位表单按 status 过滤启用项。
|
||||
"""
|
||||
options: list[TemplateOptionOut] = []
|
||||
for tpl in TEMPLATES.values():
|
||||
rows = calc_template_repository.list_all(session)
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
seen.add(row.template_code)
|
||||
options.append(
|
||||
TemplateOptionOut(
|
||||
code=tpl.code,
|
||||
name=tpl.name,
|
||||
description=tpl.description,
|
||||
code=row.template_code,
|
||||
name=row.template_name,
|
||||
description=row.description or "",
|
||||
params=[
|
||||
TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name)
|
||||
for code in tpl.params
|
||||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||||
for param_code in (row.params_json or {}).values()
|
||||
if param_code in PARAM_REGISTRY
|
||||
],
|
||||
)
|
||||
)
|
||||
for spec in TEMPLATES.values():
|
||||
if spec.code in seen:
|
||||
continue
|
||||
options.append(
|
||||
TemplateOptionOut(
|
||||
code=spec.code,
|
||||
name=spec.name,
|
||||
description=spec.description,
|
||||
params=[
|
||||
TemplateParamOut(code=param_code, name=PARAM_REGISTRY[param_code].name)
|
||||
for param_code in spec.params.values()
|
||||
if param_code in PARAM_REGISTRY
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -135,7 +160,7 @@ def create_role(
|
||||
|
||||
:raises BadRequestError: 编码已存在 / 模板不存在
|
||||
"""
|
||||
_ensure_template(payload.calc_template)
|
||||
_ensure_template(session, payload.calc_template)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -188,7 +213,7 @@ def update_role(
|
||||
:raises BadRequestError: 模板不存在
|
||||
"""
|
||||
role = business_role_repository.get_by_id(session, role_id)
|
||||
_ensure_template(payload.calc_template)
|
||||
_ensure_template(session, payload.calc_template)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
|
||||
@@ -483,7 +483,6 @@ def update_business(
|
||||
from app.services import payroll_months
|
||||
|
||||
allocation_month = payroll_months.current_month()
|
||||
payroll_months.lock_month(session, allocation_month, operator)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
business = row[0] if row else None
|
||||
with audit_service.audit(
|
||||
@@ -523,9 +522,19 @@ def update_business(
|
||||
business.remark = payload.remark
|
||||
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
|
||||
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
new_items = _build_item_rows(business_id, payload.items)
|
||||
from app.services.payroll_calculation import latest_input
|
||||
|
||||
formal = latest_input(session, business_id)
|
||||
if formal and formal.data_json.get("guidance_finalized"):
|
||||
from app.models import BusinessItem
|
||||
|
||||
old_items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)))
|
||||
def costs(items):
|
||||
return sorted((i.product_name, i.spec_model, str(i.quantity.normalize()), str(i.cost_amount.normalize()) if i.cost_amount is not None else "", str(i.guided_price.normalize()) if i.guided_price is not None else "") for i in items)
|
||||
if costs(old_items) != costs(new_items):
|
||||
raise BadRequestError("指导成本已封存,不能通过业务编辑改变数量或指导成本;差额请走更正或当月调整")
|
||||
business_repository.replace_items(session, business_id, new_items)
|
||||
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||
old_allocations = business_repository.list_allocations(session, business_id)
|
||||
@@ -957,7 +966,10 @@ def _gross_profit_row(
|
||||
"sale_amount": str(item.sale_amount),
|
||||
"threshold": str(threshold),
|
||||
}
|
||||
if item.cost_amount is None:
|
||||
from app.services.cost_rules import guided_total
|
||||
|
||||
cost = item.cost_amount if item.cost_amount is not None else guided_total(item.quantity, item.guided_price)
|
||||
if cost is None:
|
||||
row_kwargs.update(
|
||||
cost_amount=None,
|
||||
profit_amount=None,
|
||||
@@ -967,17 +979,17 @@ def _gross_profit_row(
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), None
|
||||
|
||||
profit = item.sale_amount - item.cost_amount
|
||||
profit = item.sale_amount - cost
|
||||
rate = profit_rate(profit, item.sale_amount)
|
||||
conv = converted_profit(item.sale_amount, rate, threshold)
|
||||
row_kwargs.update(
|
||||
cost_amount=str(item.cost_amount),
|
||||
cost_amount=str(cost),
|
||||
profit_amount=str(profit),
|
||||
profit_rate=str(rate),
|
||||
converted_profit=str(conv),
|
||||
missing_reason=None,
|
||||
)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, item.cost_amount)
|
||||
return GrossProfitRowOut(**row_kwargs), (item.sale_amount, cost)
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
|
||||
@@ -559,7 +559,11 @@ def _personal_rate(
|
||||
return _RateDecision(rate, "企业户:不按回款天数,统一按固定比例计提", None, False)
|
||||
|
||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
from app.services.calc_templates import resolve_definition
|
||||
|
||||
definition = resolve_definition(session, template)
|
||||
params = definition.params
|
||||
if definition.formula_type == "manager_share":
|
||||
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||
@@ -570,35 +574,33 @@ def _personal_rate(
|
||||
None,
|
||||
True,
|
||||
)
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
decay = resolve_definition(session, "TPL_SALESPERSON_DECAY").params
|
||||
start = parse_fraction(snapshot.get(decay.get("start", "salesperson.rate.start")))
|
||||
slope = parse_fraction(snapshot.get(decay.get("slope", "salesperson.rate.slope")))
|
||||
floor = parse_fraction(snapshot.get(decay.get("floor", "salesperson.rate.floor")))
|
||||
boundary = int(parse_fraction(snapshot.get(decay.get("boundary", "salesperson.days.boundary"))))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"经理个人户(已开启计入分成基数):按业务员递减,回款 {days} 天,起始 {start}‰"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
if definition.formula_type == "fixed_sales":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
return _RateDecision(rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", None, False)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
rate = parse_fraction(snapshot.get(params.get("rate", "info.rate"))) / 1000
|
||||
return _RateDecision(rate, "按岗位固定比例计提,不随回款天数递减", None, False)
|
||||
if definition.formula_type == "linear_decay":
|
||||
start = parse_fraction(snapshot.get(params.get("start", "salesperson.rate.start")))
|
||||
slope = parse_fraction(snapshot.get(params.get("slope", "salesperson.rate.slope")))
|
||||
floor = parse_fraction(snapshot.get(params.get("floor", "salesperson.rate.floor")))
|
||||
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
boundary = int(parse_fraction(snapshot.get(params.get("boundary", "salesperson.days.boundary"))))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = (
|
||||
f"回款 {days} 天,从起始 {start}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {floor}‰ 计算)"
|
||||
)
|
||||
return _RateDecision(rate, note, None, False)
|
||||
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是「营销经理·团队与全公司分成」
|
||||
from app.services.calc_templates import TEMPLATES
|
||||
|
||||
tpl_name = TEMPLATES[template].name if template in TEMPLATES else template
|
||||
raise BadRequestError(f"「{tpl_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||
# 提示给模板中文名而非编码:业务人员在岗位角色页看到的是配置名称
|
||||
raise BadRequestError(f"「{definition.template_name}」不支持单笔核算,请检查该人员的岗位设置")
|
||||
|
||||
|
||||
def _role_of(session: Session, person):
|
||||
@@ -697,6 +699,7 @@ def batch_calc(
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("批量核算单笔失败 | receipt_id=%s", rid)
|
||||
results.append(
|
||||
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""计提公式模板管理服务。
|
||||
|
||||
页面可维护:名称、说明、参数绑定(受骨架角色约束);公式骨架是受控枚举,
|
||||
创建后锁定,避免自由表达式注入与执行错位。系统模板随迁移种子初始化,
|
||||
禁删且骨架/编码/参数锁定;自定义模板无岗位引用才可删除。
|
||||
"""
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.constants import ACTION_CREATE, ACTION_DELETE, ACTION_UPDATE
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import CalcTemplateDefinition, User
|
||||
from app.repositories import calc_template_repository
|
||||
from app.schemas.calc_template import (
|
||||
CalcTemplateCreateIn,
|
||||
CalcTemplateOut,
|
||||
CalcTemplateUpdateIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.calc_templates import FORMULAS, get_formula
|
||||
from app.services.param_registry import PARAM_REGISTRY
|
||||
|
||||
MODULE_CALC_TEMPLATE = "公式模板"
|
||||
|
||||
|
||||
def _to_out(row: CalcTemplateDefinition, role_count: int = 0) -> CalcTemplateOut:
|
||||
return CalcTemplateOut(
|
||||
id=row.id,
|
||||
template_code=row.template_code,
|
||||
template_name=row.template_name,
|
||||
formula_type=row.formula_type,
|
||||
params=dict(row.params_json or {}),
|
||||
description=row.description,
|
||||
sort_order=row.sort_order,
|
||||
status=row.status,
|
||||
is_system=row.is_system,
|
||||
role_count=role_count,
|
||||
)
|
||||
|
||||
|
||||
def _validate_params(formula_type: str, params: dict[str, str]) -> dict[str, str]:
|
||||
"""骨架角色必须全覆盖且不引入多余角色;目标参数必须已注册。"""
|
||||
spec = get_formula(formula_type)
|
||||
expected = set(spec.roles)
|
||||
provided = set(params)
|
||||
if provided != expected:
|
||||
missing = sorted(expected - provided)
|
||||
extra = sorted(provided - expected)
|
||||
details = []
|
||||
if missing:
|
||||
details.append("缺少参数角色:" + "、".join(missing))
|
||||
if extra:
|
||||
details.append("多余参数角色:" + "、".join(extra))
|
||||
raise BadRequestError(";".join(details))
|
||||
unknown = sorted(code for code in params.values() if code not in PARAM_REGISTRY)
|
||||
if unknown:
|
||||
raise BadRequestError("参数编码未注册:" + "、".join(unknown))
|
||||
return {role: params[role] for role in spec.roles}
|
||||
|
||||
|
||||
def list_templates(session) -> PageResult[CalcTemplateOut]:
|
||||
"""模板列表(含停用与引用计数,供管理页)。"""
|
||||
rows = calc_template_repository.list_all(session)
|
||||
counts = calc_template_repository.count_roles_by_codes(session, [r.template_code for r in rows])
|
||||
return PageResult[CalcTemplateOut](
|
||||
records=[_to_out(row, counts.get(row.template_code, 0)) for row in rows],
|
||||
total=len(rows),
|
||||
page_num=1,
|
||||
page_size=max(len(rows), 1),
|
||||
)
|
||||
|
||||
|
||||
def list_formula_options() -> list[dict]:
|
||||
"""骨架下拉(受控集合)。"""
|
||||
return [
|
||||
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
|
||||
for spec in FORMULAS.values()
|
||||
]
|
||||
|
||||
|
||||
def create_template(
|
||||
session,
|
||||
payload: CalcTemplateCreateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> CalcTemplateOut:
|
||||
"""新增模板。编码唯一;骨架受控;参数绑定按骨架角色校验。"""
|
||||
if calc_template_repository.get_by_code(session, payload.template_code) is not None:
|
||||
raise BadRequestError(f"模板编码 {payload.template_code} 已存在")
|
||||
try:
|
||||
get_formula(payload.formula_type)
|
||||
except ValueError as exc:
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
params = _validate_params(payload.formula_type, payload.params)
|
||||
row = CalcTemplateDefinition(
|
||||
template_code=payload.template_code,
|
||||
template_name=payload.template_name,
|
||||
formula_type=payload.formula_type,
|
||||
params_json=params,
|
||||
description=payload.description,
|
||||
sort_order=payload.sort_order,
|
||||
status=1,
|
||||
is_system=0,
|
||||
)
|
||||
try:
|
||||
calc_template_repository.create(session, row)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise BadRequestError(f"模板编码 {payload.template_code} 已存在") from exc
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_CREATE,
|
||||
target=payload.template_code,
|
||||
summary=f"新增计提公式模板 {payload.template_name}",
|
||||
detail={"formula_type": payload.formula_type, "params": params},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def update_template(
|
||||
session,
|
||||
template_id: int,
|
||||
payload: CalcTemplateUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> CalcTemplateOut:
|
||||
"""编辑模板。系统模板参数锁定;自定义模板参数仍按骨架角色校验。"""
|
||||
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
|
||||
if row is None:
|
||||
raise NotFoundError("模板不存在")
|
||||
params = dict(row.params_json or {})
|
||||
if row.is_system != 1:
|
||||
params = _validate_params(row.formula_type, payload.params)
|
||||
row.template_name = payload.template_name
|
||||
row.description = payload.description
|
||||
row.sort_order = payload.sort_order
|
||||
row.params_json = params
|
||||
session.commit()
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_UPDATE,
|
||||
target=row.template_code,
|
||||
summary=f"修改计提公式模板 {row.template_name}",
|
||||
detail={"params": params},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def delete_template(
|
||||
session,
|
||||
template_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta,
|
||||
) -> None:
|
||||
"""删除模板。系统模板禁删;被岗位引用禁删。"""
|
||||
row = calc_template_repository.get_by_id(session, template_id, for_update=True)
|
||||
if row is None:
|
||||
raise NotFoundError("模板不存在")
|
||||
if row.is_system == 1:
|
||||
raise ForbiddenError("系统内置模板不可删除")
|
||||
counts = calc_template_repository.count_roles_by_codes(session, [row.template_code])
|
||||
if counts.get(row.template_code):
|
||||
raise BadRequestError("模板已被岗位引用,不能删除;可先调整岗位模板后再删除")
|
||||
calc_template_repository.delete(session, row)
|
||||
session.commit()
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC_TEMPLATE,
|
||||
action=ACTION_DELETE,
|
||||
target=row.template_code,
|
||||
summary=f"删除计提公式模板 {row.template_name}",
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
pass
|
||||
@@ -1,22 +1,140 @@
|
||||
"""计提公式模板注册表(代码内有限集合)。
|
||||
"""计提公式模板骨架注册与解析。
|
||||
|
||||
一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值
|
||||
(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码,
|
||||
新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。
|
||||
新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受,
|
||||
岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。
|
||||
一期骨架仍是代码内受控集合:页面可维护模板名称、说明、参数绑定(JSON),
|
||||
但不允许自由表达式,避免注入与算错。数据库定义优先;迁移前的旧库用
|
||||
`SYSTEM_TEMPLATES` 兜底解析,保证读写与历史行为一致。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateSpec:
|
||||
"""公式模板元数据。"""
|
||||
class FormulaSpec:
|
||||
"""公式骨架元数据:参数角色 → 模板参数映射键。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
params: tuple[str, ...]
|
||||
roles: tuple[str, ...]
|
||||
description: str
|
||||
|
||||
|
||||
# 骨架类型(formula_type)。新增骨架需同步执行引擎分派点。
|
||||
FORMULAS: dict[str, FormulaSpec] = {
|
||||
"linear_decay": FormulaSpec(
|
||||
code="linear_decay",
|
||||
name="天数线性递减",
|
||||
roles=("start", "slope", "floor", "boundary"),
|
||||
description="按回款天数线性递减,超过截止天数取下限比例",
|
||||
),
|
||||
"fixed_profit": FormulaSpec(
|
||||
code="fixed_profit",
|
||||
name="折算毛利固定比例",
|
||||
roles=("rate",),
|
||||
description="折算毛利润额 × 固定比例",
|
||||
),
|
||||
"fixed_sales": FormulaSpec(
|
||||
code="fixed_sales",
|
||||
name="销售额固定比例",
|
||||
roles=("rate",),
|
||||
description="本人销售额 × 固定比例",
|
||||
),
|
||||
"manager_share": FormulaSpec(
|
||||
code="manager_share",
|
||||
name="经理团队分成",
|
||||
roles=("team", "company"),
|
||||
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
|
||||
),
|
||||
"prepay_fixed": FormulaSpec(
|
||||
code="prepay_fixed",
|
||||
name="预付款固定比例",
|
||||
roles=("rate",),
|
||||
description="预付回款额 × 固定比例",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateDefinition:
|
||||
"""模板定义解析结果:数据库行或旧库兜底系统模板。"""
|
||||
|
||||
template_code: str
|
||||
template_name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str | None
|
||||
is_system: bool
|
||||
status: int
|
||||
|
||||
|
||||
def get_formula(code: str) -> FormulaSpec:
|
||||
"""取骨架元数据;未知编码属配置错误,调用方转用户错误。"""
|
||||
spec = FORMULAS.get(code)
|
||||
if spec is None:
|
||||
raise ValueError(f"公式骨架未注册:{code}")
|
||||
return spec
|
||||
|
||||
|
||||
def system_definition(code: str) -> TemplateDefinition | None:
|
||||
"""旧库兜底:迁移未应用时按内置注册表合成定义,行为与历史一致。"""
|
||||
spec = TEMPLATES.get(code)
|
||||
if spec is None:
|
||||
return None
|
||||
return TemplateDefinition(
|
||||
template_code=spec.code,
|
||||
template_name=spec.name,
|
||||
formula_type=spec.formula_type,
|
||||
params=dict(spec.params),
|
||||
description=spec.description,
|
||||
is_system=True,
|
||||
status=1,
|
||||
)
|
||||
|
||||
|
||||
def resolve_definition(session: Session, code: str) -> TemplateDefinition:
|
||||
"""按编码解析模板定义:数据库优先,旧库兜底系统模板。"""
|
||||
from app.repositories import calc_template_repository
|
||||
|
||||
row = calc_template_repository.get_by_code(session, code)
|
||||
if row is not None:
|
||||
return TemplateDefinition(
|
||||
template_code=row.template_code,
|
||||
template_name=row.template_name,
|
||||
formula_type=row.formula_type,
|
||||
params=dict(row.params_json or {}),
|
||||
description=row.description,
|
||||
is_system=row.is_system == 1,
|
||||
status=row.status,
|
||||
)
|
||||
fallback = system_definition(code)
|
||||
if fallback is None:
|
||||
raise ValueError(f"计提公式模板未定义:{code}")
|
||||
return fallback
|
||||
|
||||
|
||||
def formula_type_of(session: Session, code: str) -> str:
|
||||
"""执行分派用:模板 → 公式骨架类型。"""
|
||||
return resolve_definition(session, code).formula_type
|
||||
|
||||
|
||||
# 模板参数角色到参数编码的内置映射(与 V28 种子一致;旧库兜底用)。
|
||||
_TPL_SALESPERSON = {
|
||||
"start": "salesperson.rate.start",
|
||||
"slope": "salesperson.rate.slope",
|
||||
"floor": "salesperson.rate.floor",
|
||||
"boundary": "salesperson.days.boundary",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TemplateSpec:
|
||||
"""旧库兜底模板元数据(历史编码锚点)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str
|
||||
|
||||
|
||||
@@ -24,49 +142,36 @@ TEMPLATES: dict[str, TemplateSpec] = {
|
||||
"TPL_SALESPERSON_DECAY": TemplateSpec(
|
||||
code="TPL_SALESPERSON_DECAY",
|
||||
name="业务员个人户·天数线性递减",
|
||||
params=(
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.days.boundary",
|
||||
),
|
||||
formula_type="linear_decay",
|
||||
params=_TPL_SALESPERSON,
|
||||
description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限",
|
||||
),
|
||||
"TPL_MANAGER_SHARE": TemplateSpec(
|
||||
code="TPL_MANAGER_SHARE",
|
||||
name="营销经理·团队与全公司分成",
|
||||
params=("manager.share.team", "manager.share.company"),
|
||||
description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%",
|
||||
formula_type="manager_share",
|
||||
params={"team": "manager.share.team", "company": "manager.share.company"},
|
||||
description="聚合:本团队业务员绩效×团队比例 + 全公司业务员绩效×公司比例",
|
||||
),
|
||||
"TPL_ENTERPRISE_FIXED": TemplateSpec(
|
||||
code="TPL_ENTERPRISE_FIXED",
|
||||
name="企业户·固定计提",
|
||||
params=("enterprise.rate",),
|
||||
formula_type="fixed_profit",
|
||||
params={"rate": "enterprise.rate"},
|
||||
description="乘法:折算毛利润额 × 计提比例",
|
||||
),
|
||||
"TPL_INFO_SALES_FIXED": TemplateSpec(
|
||||
code="TPL_INFO_SALES_FIXED",
|
||||
name="信息员·销售额固定比例",
|
||||
params=("info.rate",),
|
||||
formula_type="fixed_sales",
|
||||
params={"rate": "info.rate"},
|
||||
description="乘法:本人销售额 × 计提比例",
|
||||
),
|
||||
"TPL_PREPAY_FIXED": TemplateSpec(
|
||||
code="TPL_PREPAY_FIXED",
|
||||
name="预付款·固定计提",
|
||||
params=("prepay.rate",),
|
||||
description="乘法:回款额 × 计提比例",
|
||||
formula_type="prepay_fixed",
|
||||
params={"rate": "prepay.rate"},
|
||||
description="乘法:预付回款额 × 计提比例",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_template(code: str) -> TemplateSpec:
|
||||
"""取模板元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||
tpl = TEMPLATES.get(code)
|
||||
if tpl is None:
|
||||
raise ValueError(f"公式模板编码未注册:{code}")
|
||||
return tpl
|
||||
|
||||
|
||||
def required_params(code: str) -> tuple[str, ...]:
|
||||
"""模板声明的参数编码列表(岗位表单展示与校验用)。"""
|
||||
return get_template(code).params
|
||||
@@ -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()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""含税单位指导成本到总成本的唯一换算,不改写已封存的成本快照。"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
|
||||
def guided_total(quantity, guided_price, cost_amount=None):
|
||||
if guided_price is None:
|
||||
return Decimal(str(cost_amount)) if cost_amount is not None else None
|
||||
total = (Decimal(str(quantity)) * Decimal(str(guided_price))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if cost_amount is not None and Decimal(str(cost_amount)) != total:
|
||||
raise ValueError("成本额与数量×含税单位指导成本不一致")
|
||||
return total
|
||||
@@ -6,8 +6,7 @@
|
||||
- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身
|
||||
已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在
|
||||
计划开放项,待业务确认是否物化
|
||||
- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以
|
||||
``sync_available=false`` 显式降级,不返回伪造的 0 条
|
||||
- 同步记录与采集开关分别展示;读取关闭不抹掉已完成的采集记录
|
||||
- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照
|
||||
- **最近动态独立分页**(2026-10-23):操作日志可能很长,聚合响应里
|
||||
只能塞前 20 条;拆成独立端点后翻页只重取这张列表
|
||||
@@ -16,11 +15,14 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
from app.repositories import dashboard_repository, operation_log_repository, user_repository
|
||||
from app.models import TPlusSyncBatch
|
||||
from app.repositories import dashboard_repository, operation_log_repository
|
||||
from app.repositories.query_utils import LogFilters
|
||||
from app.schemas.dashboard import ActivityItem, ActivityPageOut, ActivityQuery
|
||||
from app.services import approval_service
|
||||
@@ -42,17 +44,54 @@ def get_dashboard(session: Session, month: str | None = None, *, current_user) -
|
||||
"""
|
||||
resolved = _normalize_month(month)
|
||||
warning_params = _warning_params(session)
|
||||
pending_statuses = approval_service.pending_statuses_for_roles(
|
||||
user_repository.get_role_codes(current_user)
|
||||
)
|
||||
from app.services.authorization_service import calculate
|
||||
|
||||
permissions = calculate(session, current_user)["permissions"]
|
||||
pending_statuses = approval_service.pending_statuses_for_roles([
|
||||
role for role in ("finance", "leader") if "approvals:" + role in permissions
|
||||
])
|
||||
sync = _sync_status(session)
|
||||
return {
|
||||
"month": resolved,
|
||||
"overview": _overview(session, resolved),
|
||||
"todos": _todos(session, pending_statuses),
|
||||
"warnings": _warnings(session, warning_params),
|
||||
"todos": _todos(session, pending_statuses, sync),
|
||||
"warnings": _warnings(session, warning_params, sync),
|
||||
"sync": sync,
|
||||
}
|
||||
|
||||
|
||||
def _sync_status(session: Session) -> dict:
|
||||
"""只读本地批次元数据,不连接源库,兼容尚未应用自动化迁移的开发库。"""
|
||||
settings = get_settings()
|
||||
result = {
|
||||
"available": False,
|
||||
"enabled": settings.t_plus_enabled,
|
||||
"automation_enabled": settings.t_plus_automation_enabled,
|
||||
"latest_status": None,
|
||||
"last_success_at": None,
|
||||
"historical_failure_total": 0,
|
||||
}
|
||||
if not inspect(session.connection()).has_table(TPlusSyncBatch.__tablename__):
|
||||
return result
|
||||
# 关联检查成功不能替代数据采集成功;仅查询原有列,避免加载 V20 新字段。
|
||||
reads = TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL"))
|
||||
result["available"] = True
|
||||
result["latest_status"] = session.scalar(
|
||||
select(TPlusSyncBatch.status).where(reads).order_by(TPlusSyncBatch.id.desc()).limit(1)
|
||||
)
|
||||
result["last_success_at"] = session.scalar(
|
||||
select(func.max(TPlusSyncBatch.finished_at)).where(
|
||||
reads, TPlusSyncBatch.status == "SUCCEEDED"
|
||||
)
|
||||
)
|
||||
result["historical_failure_total"] = session.scalar(
|
||||
select(func.count()).select_from(TPlusSyncBatch).where(
|
||||
reads, TPlusSyncBatch.status.in_(("FAILED", "PARTIAL"))
|
||||
)
|
||||
) or 0
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_month(month: str | None) -> str:
|
||||
"""未传月份按当月;非法月份在入口拦成 400(避免 500)。"""
|
||||
if not month:
|
||||
@@ -120,7 +159,7 @@ def _overview(session: Session, month: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
def _todos(session: Session, pending_statuses: tuple[int, ...], sync: dict) -> dict:
|
||||
"""待办事项(FR-034,澄清 #11 起步范围)。
|
||||
|
||||
``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、
|
||||
@@ -156,9 +195,8 @@ def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict:
|
||||
}
|
||||
for row in receipts
|
||||
],
|
||||
# ⑥ T+ 同步未建:显式降级,不伪造「无失败」
|
||||
"sync_available": False,
|
||||
"sync_failure_total": 0,
|
||||
"sync_available": sync["available"],
|
||||
"sync_failure_total": sync["historical_failure_total"],
|
||||
}
|
||||
|
||||
|
||||
@@ -174,8 +212,8 @@ def _warning_params(session: Session) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _warnings(session: Session, params: dict) -> dict:
|
||||
"""风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。"""
|
||||
def _warnings(session: Session, params: dict, sync: dict) -> dict:
|
||||
"""风险预警(FR-035);采集状态与经营预警开关分别展示。"""
|
||||
if not params["enabled"]:
|
||||
return {
|
||||
"enabled": False,
|
||||
@@ -185,7 +223,7 @@ def _warnings(session: Session, params: dict) -> dict:
|
||||
"total": 0,
|
||||
"receipt_overdue_total": 0,
|
||||
"approval_timeout_total": 0,
|
||||
"sync_available": False,
|
||||
"sync_available": sync["available"],
|
||||
}
|
||||
|
||||
receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"])
|
||||
@@ -245,7 +283,7 @@ def _warnings(session: Session, params: dict) -> dict:
|
||||
"total": receipt_overdue_total + approval_timeout_total,
|
||||
"receipt_overdue_total": receipt_overdue_total,
|
||||
"approval_timeout_total": approval_timeout_total,
|
||||
"sync_available": False,
|
||||
"sync_available": sync["available"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""管理员监控视图:已落库历史与当前分钟合并,保持无数据与故障语义。"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from time import monotonic
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.monitoring import SHANGHAI, merge_metrics, minute_of, now_local
|
||||
from app.core.response import PageResult
|
||||
from app.repositories import monitor_repository as repo
|
||||
from app.schemas.monitor import EventOut, OverviewOut
|
||||
|
||||
|
||||
def window(query):
|
||||
now = now_local()
|
||||
end = query.end or now
|
||||
if end.tzinfo:
|
||||
end = end.astimezone(SHANGHAI).replace(tzinfo=None)
|
||||
if end > now + timedelta(seconds=30) or end < now - timedelta(days=7):
|
||||
raise BadRequestError("查询时间须在最近 7 天内")
|
||||
return max(end - timedelta(hours=query.hours), now - timedelta(days=7)), end
|
||||
|
||||
|
||||
def overview(session, collector):
|
||||
state = collector.current()
|
||||
now = now_local()
|
||||
current_minute = minute_of(now)
|
||||
stored = repo.snapshots(session, current_minute - timedelta(minutes=4), current_minute)
|
||||
# 未落库分钟从有界缓冲补齐;仅跳过同实例已持久化的分钟。
|
||||
known = {(row.instance_id, row.minute) for row in stored}
|
||||
recent = collector.recorder.metrics(current_minute - timedelta(minutes=4))
|
||||
values = [row.requests for row in stored]
|
||||
values.extend(
|
||||
metric
|
||||
for minute, endpoints in recent.items()
|
||||
if (collector.recorder.instance_id, minute) not in known
|
||||
for metric in endpoints.values()
|
||||
)
|
||||
state["requests"] = merge_metrics(values)
|
||||
stale = not state["collected_at"] or (now - state["collected_at"]).total_seconds() > 45
|
||||
if stale:
|
||||
state.update(
|
||||
collection_status="unknown",
|
||||
collection_message="采集未启用或已超过 45 秒未更新,当前状态未知。",
|
||||
)
|
||||
for dependency in state["dependencies"]:
|
||||
dependency.update(status="unknown", message="采集已过期,请检查服务日志。")
|
||||
persisted_alerts = {
|
||||
row.code: EventOut.model_validate(row) for row in repo.active_events(session)
|
||||
}
|
||||
for item in state["alerts"]:
|
||||
persisted_alerts[item["code"]] = EventOut.model_validate(item)
|
||||
state["alerts"] = list(persisted_alerts.values())
|
||||
status = (
|
||||
"unknown"
|
||||
if stale
|
||||
else "error"
|
||||
if any(dep["status"] == "error" for dep in state["dependencies"])
|
||||
else "warning"
|
||||
if state["alerts"] or state["collection_status"] == "warning"
|
||||
else "normal"
|
||||
)
|
||||
return OverviewOut(
|
||||
status=status,
|
||||
environment=collector.settings.app_env,
|
||||
version="0.1.0",
|
||||
instance_id=collector.recorder.instance_id,
|
||||
uptime_seconds=int(monotonic() - collector.recorder.started),
|
||||
dropped_events=collector.recorder.dropped_events,
|
||||
**state,
|
||||
)
|
||||
|
||||
|
||||
def trends(session, collector, query):
|
||||
start, end = window(query)
|
||||
stored = repo.snapshots(session, minute_of(start), end)
|
||||
# 24 小时按 5 分钟、7 天按 30 分钟聚合,间断采集保留 null 点。
|
||||
step = 1 if query.hours == 1 else 5 if query.hours == 24 else 30
|
||||
groups = defaultdict(list)
|
||||
for row in stored:
|
||||
minute = row.minute.replace(minute=(row.minute.minute // step) * step)
|
||||
groups[minute].append(
|
||||
{"sample_count": row.sample_count, "resources": row.resources, "requests": row.requests}
|
||||
)
|
||||
at = minute_of(end)
|
||||
recent = collector.recorder.metrics(start, end)
|
||||
# 只补尚未持久化的分钟,历史查看不引入窗口外最新资源。
|
||||
known = {(row.instance_id, row.minute) for row in stored}
|
||||
sample_history = collector.sample_history()
|
||||
for minute in set(recent) | set(sample_history):
|
||||
if not start <= minute < end or (collector.recorder.instance_id, minute) in known:
|
||||
continue
|
||||
samples = sample_history.get(minute, [])
|
||||
groups[minute.replace(minute=(minute.minute // step) * step)].append(
|
||||
{
|
||||
"sample_count": len(samples),
|
||||
"resources": samples[-1] if samples else {},
|
||||
"requests": merge_metrics(recent.get(minute, {}).values()),
|
||||
}
|
||||
)
|
||||
cursor = minute_of(start).replace(minute=(start.minute // step) * step)
|
||||
points = []
|
||||
while cursor <= at:
|
||||
rows = groups.get(cursor, [])
|
||||
point = {"at": cursor, "sample_count": sum(row["sample_count"] for row in rows)}
|
||||
for key in ("cpu_percent", "memory_percent", "disk_percent"):
|
||||
values = [
|
||||
(row["resources"].get(key), row["sample_count"])
|
||||
for row in rows
|
||||
if row["resources"].get(key) is not None and row["sample_count"]
|
||||
]
|
||||
point[key] = (
|
||||
round(
|
||||
sum(value * count for value, count in values)
|
||||
/ sum(count for _, count in values),
|
||||
2,
|
||||
)
|
||||
if values
|
||||
else None
|
||||
)
|
||||
metric = merge_metrics(row["requests"] for row in rows)
|
||||
point.update(
|
||||
request_count=metric["count"] if rows else None,
|
||||
error_rate=metric["error_rate"] if rows else None,
|
||||
p95_ms=metric["p95_ms"],
|
||||
)
|
||||
points.append(point)
|
||||
cursor += timedelta(minutes=step)
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"step_minutes": step,
|
||||
"points": points,
|
||||
"p95_approximate": True,
|
||||
}
|
||||
|
||||
|
||||
def interfaces(session, collector, query):
|
||||
start, end = window(query)
|
||||
rows = repo.request_minutes(session, minute_of(start), end, query.route)
|
||||
truncated = len(rows) > 20000
|
||||
rows = rows[:20000]
|
||||
grouped, known = defaultdict(list), set()
|
||||
for row in rows:
|
||||
grouped[(row.method, row.route)].append(row.metrics)
|
||||
known.add((row.instance_id, row.minute, row.method, row.route))
|
||||
for minute, endpoints in collector.recorder.metrics(minute_of(start), end).items():
|
||||
for (method, route), metric in endpoints.items():
|
||||
if (not query.route or route == query.route) and (
|
||||
collector.recorder.instance_id,
|
||||
minute,
|
||||
method,
|
||||
route,
|
||||
) not in known:
|
||||
grouped[(method, route)].append(metric)
|
||||
records = [
|
||||
{"method": method, "route": route, **merge_metrics(items)}
|
||||
for (method, route), items in grouped.items()
|
||||
]
|
||||
records.sort(key=lambda item: (item["p95_ms"] or 0, item["count"]), reverse=True)
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"list": records[:200],
|
||||
"truncated": truncated or len(records) > 200,
|
||||
"p95_approximate": True,
|
||||
}
|
||||
|
||||
|
||||
def events(session, query):
|
||||
start, end = window(query)
|
||||
rows, total = repo.events(session, query, start, end)
|
||||
return PageResult(
|
||||
records=[EventOut.model_validate(row) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def event(session, event_id):
|
||||
row = repo.event(session, event_id)
|
||||
if row is None or (
|
||||
row.last_seen < now_local() - timedelta(days=7)
|
||||
and not (row.kind == "HEALTH" and row.resolved_at is None)
|
||||
):
|
||||
raise NotFoundError("诊断事件不存在或已过保留期")
|
||||
return EventOut.model_validate(row)
|
||||
|
||||
|
||||
def diagnostic(session, collector, query):
|
||||
# 仅导出与页面相同的白名单模型;不读取日志文件、配置、请求正文或工资明细。
|
||||
return {
|
||||
"generated_at": now_local(),
|
||||
"overview": overview(session, collector),
|
||||
"events": events(session, query),
|
||||
"note": "仅含脱敏运行摘要;完整异常请按请求编号检索服务端日志。",
|
||||
}
|
||||
@@ -47,7 +47,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款当天能拿到的最高计提比例。回款每晚一天,按日递减斜率往下走,直到分界天数为止。",
|
||||
description="业务员个人户的起始计提比例;实际比例按回款天数递减,超过分界天数后使用计提下限。",
|
||||
sort_order=1,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -61,7 +61,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4/360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款每延后一天,计提比例下降多少。按分数填写(如 4/360)可保持精确,填小数会引入四舍五入误差。",
|
||||
description="回款每延后一天,计提比例减少的幅度。可填写分数,例如 4/360,以保留准确取值。",
|
||||
sort_order=2,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -75,7 +75,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="4",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数超过分界天数后,计提比例不再下降,固定按这个值计算,即最低计提比例。",
|
||||
description="回款天数超过分界天数后,按此固定比例计提。",
|
||||
sort_order=3,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -89,7 +89,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="360",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="回款天数在这个范围内按日递减;超过这个天数就固定按下限计提。默认 360 天。",
|
||||
description="按日递减的回款天数上限。超过该天数后,使用业务员计提下限。",
|
||||
sort_order=4,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -103,7 +103,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="12.5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="经理按本团队业务员的绩效总额提取的分成比例,只统计自己带的业务员。",
|
||||
description="按分管业务员的绩效总额计算团队分成。正式核算使用发货时登记的业务分管关系。",
|
||||
sort_order=5,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -117,7 +117,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="5",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="经理按全公司业务员的绩效总额提取的分成比例,与本团队分成叠加计算。",
|
||||
description="按全公司业务员的绩效总额计算公司分成,并与本团队分成相加。",
|
||||
sort_order=6,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -131,7 +131,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="企业户客户的回款统一按这个比例计提,不看回款天数。2026-08-13 起由 2‰ 调整为 3‰。",
|
||||
description="企业户按此比例计提,不随回款天数递减。实际适用值以回款到账日的生效记录为准。",
|
||||
sort_order=7,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -145,7 +145,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="信息员的绩效按本人销售额乘这个比例计算,不随回款天数递减。",
|
||||
description="信息员按本人销售额和此比例计提,不随回款天数递减。",
|
||||
sort_order=8,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -159,7 +159,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="10",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="预付款到账即按这个比例计提绩效,不必等到全额回款。",
|
||||
description="历史核算参数。正式核算中,预收款在实际发货后才参与计提,并按最高计提比例计算。",
|
||||
sort_order=9,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -173,7 +173,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="80",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="以物抵顶的回款,按应发绩效的这个比例折算,即打折后的金额参与绩效。",
|
||||
description="历史核算参数,用于以物抵顶绩效折算。当前特殊抵顶业务按线下流程单独核算。",
|
||||
sort_order=10,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -187,7 +187,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="60",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="单项业务的回款累计达到销售额(不含运费)的这个比例后才开始发放绩效;达标之前只累计、不发放。",
|
||||
description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -201,7 +201,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="FULL",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="选择支付权重乘在哪个基数上:当月全额 = 乘本月全部应发绩效;余额 = 只乘尚未发放的部分。",
|
||||
description="历史核算的支付基数设置。正式核算按累计回款比例的平方计算累计应付,本月只计与历史应付的差额。",
|
||||
sort_order=12,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -215,7 +215,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="30",
|
||||
value_min="0",
|
||||
value_max="100",
|
||||
description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值),低于阈值的部分不参与绩效。市政水利默认 30%,轨道交通默认 20%。",
|
||||
description="折算毛利润额=不含运费的含税销售额×(毛利率-阈值)。低于阈值时结果可为负值,仍按规则计入。",
|
||||
sort_order=13,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -229,7 +229,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="INCLUDE",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="算月工资时怎么处理基本工资:含基本工资 = 与绩效一起发;扣除基本工资 = 从绩效里扣掉已发的基本工资;不计 = 月工资只发绩效。",
|
||||
description="设置基本工资是否与绩效相加、扣除已发基本工资,或不参与月工资计算。",
|
||||
sort_order=14,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -243,7 +243,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="18000",
|
||||
value_min="0",
|
||||
value_max=None,
|
||||
description="试用期绩效先不发放;年内累计超过这个金额的部分,年底一次性结算。",
|
||||
description="试用期绩效正常核算、暂不发放;年内累计超过此金额的部分,在年底一次性结算。",
|
||||
sort_order=15,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -257,7 +257,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="二次分配方案提交后是否要审批:开启 = 先经财务复核、再经领导审核,通过后才生效;关闭 = 提交即生效,系统自动记录各节点留痕。",
|
||||
description="旧版比例分配方案的审核设置,仅用于历史核算流程。当前正式核算按各人发货对应的绩效分配。",
|
||||
sort_order=17,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -271,7 +271,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="月度结算提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 生成即生效。月结涉及工资发放,默认开启。",
|
||||
description="启用系统审核后,设置月度结算是否经过财务复核和领导审核。系统审核关闭时,工资生成即完成,不产生审核流程记录。",
|
||||
sort_order=18,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -285,7 +285,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="调整单提交后是否要审批:开启 = 先经财务复核、再经领导审核;关闭 = 提交即生效。调整单是已锁定月份补发或扣减的唯一通道。",
|
||||
description="启用系统审核后,设置调整单是否经过财务复核和领导审核。关闭时调整单直接生效,业务操作记录仍保留。",
|
||||
sort_order=19,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -299,7 +299,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="经理提取团队分成时,是否把经理本人名下的业绩(经理个人户)也算进分成基数。默认不计入。",
|
||||
description="历史核算的经理个人户汇总设置。当前正式核算排除经理本人业务,不受此参数影响。",
|
||||
sort_order=20,
|
||||
),
|
||||
# ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)=====
|
||||
@@ -315,7 +315,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="工作台风险预警区块的总开关:关闭后不再扫描、也不展示预警条目。默认开启。",
|
||||
description="控制首页的业务风险预警,关闭后停止按回款逾期、审批超时等规则扫描。",
|
||||
sort_order=21,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -329,7 +329,7 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="90",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="发货后超过这么多天仍未收到回款的发货批次,会出现在工作台的预警列表里。默认 90 天。",
|
||||
description="发货后超过设定天数仍未收到回款的批次,会显示在首页风险预警中。",
|
||||
sort_order=22,
|
||||
),
|
||||
ParamSpec(
|
||||
@@ -343,13 +343,29 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
default_value="3",
|
||||
value_min="1",
|
||||
value_max=None,
|
||||
description="审批单提交后超过这么多天还没处理,会出现在工作台的预警列表里。默认 3 天。",
|
||||
description="审核流程开启后,提交超过设定天数仍未处理的单据,会显示在首页风险预警中。",
|
||||
sort_order=23,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
for _field, _title, _kind, _unit in (
|
||||
("first", "个人户首日比例", "DECIMAL", "‰"),
|
||||
("start", "个人户线性阶段起始比例", "DECIMAL", "‰"),
|
||||
("slope", "个人户线性阶段日斜率", "DECIMAL", "‰/天"),
|
||||
("floor", "个人户超期比例", "DECIMAL", "‰"),
|
||||
("boundary", "个人户线性阶段截止天数", "INT", "天"),
|
||||
):
|
||||
_code = f"coefficient.personal.{_field}"
|
||||
PARAM_REGISTRY[_code] = ParamSpec(
|
||||
code=_code, name=_title, group="产品系数表", value_type=_kind, unit=_unit,
|
||||
dimension_type="PRODUCT_CATEGORY", options=None, default_value=None,
|
||||
value_min="1" if _kind == "INT" else "0", value_max="3660" if _kind == "INT" else None,
|
||||
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。", sort_order=30 + len(PARAM_REGISTRY),
|
||||
)
|
||||
|
||||
|
||||
def get_spec(code: str) -> ParamSpec:
|
||||
"""取参数元数据。未注册编码属代码缺陷(500),不是用户错误。"""
|
||||
spec = PARAM_REGISTRY.get(code)
|
||||
|
||||
@@ -179,7 +179,8 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
|
||||
default_value=definition.default_value,
|
||||
value_min=definition.value_min,
|
||||
value_max=definition.value_max,
|
||||
description=definition.description,
|
||||
# 说明文案随已确认规则更新,数值与生效日期仍从历史记录读取。
|
||||
description=spec.description,
|
||||
values=by_code.get(definition.param_code, []),
|
||||
)
|
||||
)
|
||||
@@ -280,6 +281,11 @@ def submit_change(
|
||||
if payload.change_type == 2 and effective < date.today():
|
||||
raise BadRequestError("指定生效日期不得早于今日")
|
||||
|
||||
# 系数表与原参数页共用串行化入口,未来版本也不能绕过预览指纹。
|
||||
from sqlalchemy import select
|
||||
|
||||
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
|
||||
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||
normalized = _normalize_items(session, payload.items)
|
||||
|
||||
@@ -545,20 +551,10 @@ class _PreviewPayment:
|
||||
|
||||
|
||||
def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decimal | None]:
|
||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||
|
||||
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||
"""
|
||||
payload = ctx.payload
|
||||
take = ctx.take
|
||||
if payload.customer_type == "enterprise":
|
||||
# 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount
|
||||
# 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线)
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户:不按回款天数,统一按固定比例计提", None
|
||||
"""试算使用正式规则的优先级和基数,并记录真正使用的覆盖参数。"""
|
||||
from app.services.rate_rules import decide, personal_values
|
||||
|
||||
payload, take = ctx.payload, ctx.take
|
||||
role_code = payload.business_role
|
||||
template = "TPL_SALESPERSON_DECAY"
|
||||
if role_code:
|
||||
@@ -566,36 +562,32 @@ def _preview_rate(ctx: _PreviewContext, days: int) -> tuple[Fraction, str, Decim
|
||||
if role is None:
|
||||
raise BadRequestError(f"岗位「{role_code}」不存在,请到岗位角色里确认")
|
||||
template = role.calc_template
|
||||
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
raise BadRequestError(
|
||||
"营销经理的绩效按团队与全公司分成计算,不单笔试算;请到月度结算里查看"
|
||||
)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍)
|
||||
rate = take("info.rate") / 1000
|
||||
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||
return rate, "信息员:按本人销售额乘计提比例,不随回款天数递减", Decimal(payload.revenue)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = take("salesperson.rate.start")
|
||||
slope = take("salesperson.rate.slope")
|
||||
floor = take("salesperson.rate.floor")
|
||||
boundary = int(take("salesperson.days.boundary"))
|
||||
rate = formula.salesperson_rate(
|
||||
days, start=start, slope=slope, floor=floor, boundary_days=boundary
|
||||
)
|
||||
# 说明写成业务话:说清这笔为什么是这个比例、什么时候不再降
|
||||
return (
|
||||
rate,
|
||||
f"回款 {days} 天,从起始 {to_decimal(start)}‰ 按日递减"
|
||||
f"(满 {boundary} 天后固定按 {to_decimal(floor)}‰ 计算)",
|
||||
None,
|
||||
)
|
||||
# 提示里给模板中文名而不是编码:编码是内部标识,业务人员在岗位角色页看不到它
|
||||
tpl_name = calc_templates.TEMPLATES[template].name if template in calc_templates.TEMPLATES else template
|
||||
raise BadRequestError(
|
||||
f"「{tpl_name}」不支持单笔试算,请在岗位角色里改用业务员递减或信息员固定计提"
|
||||
)
|
||||
definition = calc_templates.resolve_definition(ctx.session, template)
|
||||
prepayment = payload.receipt_date < payload.ship_date
|
||||
if definition.formula_type == "manager_share" or payload.customer_type == "manager_personal":
|
||||
return Fraction(0), "经理本人业务不单笔计提,团队分成请到月度工资查看", None
|
||||
if definition.formula_type == "fixed_sales":
|
||||
rate = take(definition.params.get("rate", "info.rate")) / 1000
|
||||
return rate, "按岗位固定比例计提", Decimal(payload.revenue)
|
||||
if payload.customer_type == "enterprise" and not prepayment:
|
||||
rate = take("enterprise.rate") / 1000
|
||||
return rate, "企业户按产品分类及到账日的固定比例计提", None
|
||||
if definition.formula_type != "linear_decay" and not prepayment:
|
||||
raise BadRequestError(f"「{definition.template_name}」不支持单笔试算,请检查岗位模板")
|
||||
decay = definition.params
|
||||
for code in (decay.get("start", "salesperson.rate.start"), decay.get("slope", "salesperson.rate.slope"), decay.get("floor", "salesperson.rate.floor"), decay.get("boundary", "salesperson.days.boundary")):
|
||||
take(code)
|
||||
snapshot = resolve_snapshot(ctx.session, payload.receipt_date)
|
||||
category = payload.product_category or "municipal_water"
|
||||
values = personal_values(snapshot, category)
|
||||
for field in ("first", "start", "slope", "floor", "boundary"):
|
||||
code = f"coefficient.personal.{field}"
|
||||
dimensions = snapshot._values.get(code, {})
|
||||
if category in dimensions or "" in dimensions:
|
||||
take(code)
|
||||
rate, _, rule = decide({**values, "enterprise": "0"}, category=category,
|
||||
customer_type=payload.customer_type, role_code="salesperson", days=days, prepayment=prepayment)
|
||||
return rate, rule, None
|
||||
|
||||
|
||||
def _preview_rate_decision(ctx: _PreviewContext) -> _PreviewRateDecision:
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"""工资操作按已绑定的账号身份授权,管理员和岗位角色不构成旁路。"""
|
||||
"""工资操作使用账号继承的角色权限,不使用全局制作人绑定。"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import ForbiddenError
|
||||
|
||||
|
||||
def can_write_payroll(user) -> bool:
|
||||
"""未绑定制作人时关闭业务写入;停用账号不能操作。"""
|
||||
maker = get_settings().payroll_maker_user_id
|
||||
return maker is not None and user is not None and user.id == maker and user.status == 1
|
||||
"""兼容工资写入口;各操作的具体权限仍由接口和服务校验。"""
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from app.models import User
|
||||
from app.services.authorization_service import is_maker
|
||||
|
||||
if not isinstance(user, User) or user.status != 1:
|
||||
return False
|
||||
session = object_session(user)
|
||||
return session is not None and is_maker(session, user)
|
||||
|
||||
|
||||
def ensure_payroll_writer(user):
|
||||
if not can_write_payroll(user):
|
||||
raise ForbiddenError("仅指定的工资制作人可以修改业务数据")
|
||||
raise ForbiddenError("当前账号没有工资操作权限,请联系管理员分配相应角色")
|
||||
return user
|
||||
@@ -28,6 +28,9 @@ from app.services.tplus.calculation_preview import _parameters
|
||||
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
|
||||
|
||||
def latest_input(session, business_id, *, for_update=False):
|
||||
statement = (
|
||||
@@ -133,6 +136,8 @@ def _post(
|
||||
version,
|
||||
reason=None,
|
||||
):
|
||||
from app.services.probation_service import record as record_probation
|
||||
|
||||
person = session.get(Personnel, owner)
|
||||
if person is None:
|
||||
raise BadRequestError("正式归属人员不存在")
|
||||
@@ -166,7 +171,7 @@ def _post(
|
||||
weight_r=weight_r,
|
||||
payment_weight=formula.to_decimal(formula.payment_weight(weight_r)),
|
||||
settlement_month=month,
|
||||
is_probation=person.is_probation,
|
||||
is_probation=1 if event.get("is_probation", False) else 0,
|
||||
missing_cost=0,
|
||||
calc_reason=reason,
|
||||
snapshot_json={
|
||||
@@ -178,12 +183,13 @@ def _post(
|
||||
"manager_id": manager,
|
||||
"manager_own_excluded": event.get("excluded_manager_own", False),
|
||||
"payable_delta": amount_text(payable),
|
||||
"rule_version": "2026-10-30.2",
|
||||
"rule_version": RULE_VERSION,
|
||||
"recalc_reason": reason,
|
||||
},
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
record_probation(session, row)
|
||||
session.add(
|
||||
PayrollAllocation(
|
||||
ledger_id=row.id,
|
||||
@@ -279,6 +285,7 @@ def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
|
||||
def _calculate_month(session, business, record, month, reason=None):
|
||||
from app.services.manager_share import month_end
|
||||
from app.services.probation_service import on_date
|
||||
|
||||
business_id = business.id
|
||||
until = month_end(month)
|
||||
@@ -298,7 +305,12 @@ def _calculate_month(session, business, record, month, reason=None):
|
||||
elif line["effective_date"][:7] < month:
|
||||
raise BadRequestError(f"请先完成 {line['effective_date'][:7]} 的业务核算")
|
||||
else:
|
||||
ship = next(s for s in values["shipments"] if str(s["line_id"]) == str(line["line_id"]))
|
||||
shipments_by_line = {
|
||||
str(s["line_id"]): s for s in values["shipments"]
|
||||
}
|
||||
ship = shipments_by_line.get(str(line["line_id"]))
|
||||
if ship is None:
|
||||
raise BadRequestError(f"发货明细缺失,行号 {line['line_id']} 未找到,请重新核对资料")
|
||||
current[key] = {
|
||||
**line,
|
||||
"category": ship["category"],
|
||||
@@ -344,6 +356,14 @@ def _calculate_month(session, business, record, month, reason=None):
|
||||
owner = values["maps"]["personnel"].get(str(target))
|
||||
if owner is None:
|
||||
raise BadRequestError("接手人员尚未正式导入")
|
||||
person = session.get(Personnel, owner)
|
||||
# 试用判定以回款日为准:_post 会把结果写进快照,历史台账不再按当前状态重分类。
|
||||
receipt_day = date.fromisoformat(event["receipt_date"])
|
||||
event = {
|
||||
**event,
|
||||
"is_probation": on_date(person, receipt_day),
|
||||
"probation_year": event["receipt_date"][:4],
|
||||
}
|
||||
source_manager = event.get("manager_id")
|
||||
manager = values["maps"]["personnel"].get(str(source_manager)) if source_manager else None
|
||||
if not event.get("excluded_manager_own") and not manager:
|
||||
|
||||
@@ -29,9 +29,10 @@ def month_state(session, month):
|
||||
}
|
||||
|
||||
|
||||
def lock_month(session, month, operator):
|
||||
def lock_month(session, month, operator, *, require_maker=True):
|
||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
||||
ensure_payroll_writer(operator)
|
||||
if require_maker:
|
||||
ensure_payroll_writer(operator)
|
||||
validate_month(month)
|
||||
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
||||
if session.bind.dialect.name == "mysql":
|
||||
@@ -50,8 +51,8 @@ def lock_month(session, month, operator):
|
||||
return control
|
||||
|
||||
|
||||
def ensure_allocation_editable(session, month, operator):
|
||||
control = lock_month(session, month, operator)
|
||||
def ensure_allocation_editable(session, month, operator, *, require_maker=True):
|
||||
control = lock_month(session, month, operator, require_maker=require_maker)
|
||||
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
|
||||
old = session.scalar(
|
||||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""人员工资与任职资料按生效日期留存,生成历史工资不读后来变更的标准。"""
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import MonthlySettlement
|
||||
|
||||
FIELDS = (
|
||||
"salary_standard",
|
||||
"business_role_id",
|
||||
"hire_date",
|
||||
"resignation_date",
|
||||
"probation_start_date",
|
||||
"regularization_date",
|
||||
"is_probation",
|
||||
)
|
||||
|
||||
|
||||
def capture(person, effective):
|
||||
return {
|
||||
"status": str(person.status),
|
||||
"effective_date": str(effective),
|
||||
**{
|
||||
key: str(getattr(person, key)) if getattr(person, key) is not None else None
|
||||
for key in FIELDS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def begin_update(session, person, effective):
|
||||
if session.scalar(
|
||||
select(MonthlySettlement.id).where(
|
||||
MonthlySettlement.settlement_month >= effective.strftime("%Y-%m")
|
||||
)
|
||||
):
|
||||
raise BadRequestError(
|
||||
"生效月份或后续月份已生成工资,请使用未来生效日期;历史差额走当月调整"
|
||||
)
|
||||
history = list(person.salary_history_json or [])
|
||||
if not history:
|
||||
baseline = person.hire_date or person.create_time.date()
|
||||
history.append(capture(person, baseline))
|
||||
return history
|
||||
|
||||
|
||||
def save_version(person, effective, history=None):
|
||||
entries = list(history or person.salary_history_json or [])
|
||||
entries.append(capture(person, effective))
|
||||
person.salary_history_json = entries
|
||||
|
||||
|
||||
def resolve(person, day):
|
||||
entries = [
|
||||
entry for entry in (person.salary_history_json or []) if entry["effective_date"] <= str(day)
|
||||
]
|
||||
values = {key: getattr(person, key) for key in FIELDS}
|
||||
values["status"] = person.status
|
||||
if person.salary_history_json and not entries:
|
||||
values["salary_standard"] = None
|
||||
elif entries:
|
||||
selected = max(enumerate(entries), key=lambda pair: (pair[1]["effective_date"], pair[0]))[1]
|
||||
values["status"] = int(selected.get("status", "1"))
|
||||
for key in FIELDS:
|
||||
raw = selected.get(key)
|
||||
if key.endswith("date"):
|
||||
values[key] = date.fromisoformat(raw) if raw else None
|
||||
elif key in ("business_role_id", "is_probation"):
|
||||
values[key] = int(raw) if raw else 0
|
||||
elif key == "salary_standard":
|
||||
from decimal import Decimal
|
||||
|
||||
values[key] = Decimal(raw) if raw is not None else None
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def employed(person, profile, day):
|
||||
if profile.hire_date and profile.hire_date > day:
|
||||
return False
|
||||
if profile.resignation_date:
|
||||
return day < profile.resignation_date
|
||||
return profile.status == 1
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位
|
||||
会让 M3 核算找不到模板
|
||||
- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5%
|
||||
聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算
|
||||
- 直属经理是可空的档案参考;实际绩效按业务分管及冻结核算资料归属。
|
||||
新选经理不能是自己,须在职且使用启用的经理计提岗位;旧参考关系保留供核对
|
||||
- 删除保护:被团队成员(personnel.manager_id)或客户归属
|
||||
(customers.owner_personnel_id)引用的人员**禁删**,应改为离职 ——
|
||||
历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用
|
||||
@@ -16,6 +16,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -42,7 +43,7 @@ from app.schemas.personnel import (
|
||||
PlanReplaceIn,
|
||||
RegionReplaceIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, calc_templates
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -94,6 +95,7 @@ def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut:
|
||||
personnel, role_name, manager_name = row
|
||||
return PersonnelDetailOut(
|
||||
**_to_out(personnel, role_name, manager_name).model_dump(),
|
||||
salary_history_json=personnel.salary_history_json,
|
||||
regions=[
|
||||
PersonnelRegionOut.model_validate(item)
|
||||
for item in personnel_repository.list_regions(session, personnel_id)
|
||||
@@ -140,12 +142,18 @@ def create_personnel(
|
||||
phone=payload.phone,
|
||||
hire_date=payload.hire_date,
|
||||
is_probation=payload.is_probation,
|
||||
probation_start_date=payload.probation_start_date,
|
||||
regularization_date=payload.regularization_date,
|
||||
resignation_date=payload.resignation_date,
|
||||
salary_standard=_parse_salary(payload.salary_standard),
|
||||
status=1,
|
||||
remark=payload.remark,
|
||||
)
|
||||
try:
|
||||
personnel_repository.create(session, personnel)
|
||||
from app.services.personnel_salary import save_version
|
||||
|
||||
save_version(personnel, payload.salary_effective_date or payload.hire_date or date.today())
|
||||
out = _to_out(personnel, role.role_name, manager_name)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -186,18 +194,28 @@ def update_personnel(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("人员不存在")
|
||||
personnel = row[0]
|
||||
personnel = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
|
||||
role, manager_name = _validate_refs(
|
||||
session, payload.business_role_id, payload.manager_id, personnel.id
|
||||
)
|
||||
from app.services.personnel_salary import FIELDS, begin_update, save_version
|
||||
|
||||
salary_changed = any(str(getattr(personnel, key)) != str(getattr(payload, key)) for key in FIELDS)
|
||||
effective = payload.salary_effective_date or date.today()
|
||||
history = begin_update(session, personnel, effective) if salary_changed else None
|
||||
personnel.personnel_name = payload.personnel_name
|
||||
personnel.business_role_id = payload.business_role_id
|
||||
personnel.manager_id = payload.manager_id
|
||||
personnel.phone = payload.phone
|
||||
personnel.hire_date = payload.hire_date
|
||||
personnel.is_probation = payload.is_probation
|
||||
personnel.probation_start_date = payload.probation_start_date
|
||||
personnel.regularization_date = payload.regularization_date
|
||||
personnel.resignation_date = payload.resignation_date
|
||||
personnel.salary_standard = _parse_salary(payload.salary_standard)
|
||||
personnel.remark = payload.remark
|
||||
if salary_changed:
|
||||
save_version(personnel, effective, history)
|
||||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||
out = _to_out(personnel, role.role_name, manager_name)
|
||||
session.commit()
|
||||
@@ -260,6 +278,7 @@ def set_status(
|
||||
personnel_id: int,
|
||||
status: int,
|
||||
*,
|
||||
effective_date: date | None = None,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> PersonnelOut:
|
||||
@@ -284,8 +303,21 @@ def set_status(
|
||||
):
|
||||
if row is None:
|
||||
raise NotFoundError("人员不存在")
|
||||
personnel, role_name, manager_name = row
|
||||
_, role_name, manager_name = row
|
||||
personnel = session.get(Personnel, personnel_id, with_for_update=True, populate_existing=True)
|
||||
from app.services.personnel_salary import begin_update, save_version
|
||||
|
||||
effective = effective_date or date.today()
|
||||
history = begin_update(session, personnel, effective)
|
||||
previous_status = personnel.status
|
||||
personnel.status = status
|
||||
if status == 0:
|
||||
personnel.resignation_date = effective
|
||||
else:
|
||||
personnel.resignation_date = None
|
||||
if previous_status == 0:
|
||||
personnel.hire_date = effective
|
||||
save_version(personnel, effective, history)
|
||||
out = _to_out(personnel, role_name, manager_name)
|
||||
session.commit()
|
||||
logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status)
|
||||
@@ -381,6 +413,9 @@ def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | Non
|
||||
phone=personnel.phone,
|
||||
hire_date=personnel.hire_date,
|
||||
is_probation=personnel.is_probation,
|
||||
probation_start_date=personnel.probation_start_date,
|
||||
regularization_date=personnel.regularization_date,
|
||||
resignation_date=personnel.resignation_date,
|
||||
salary_standard=(
|
||||
str(personnel.salary_standard) if personnel.salary_standard is not None else None
|
||||
),
|
||||
@@ -395,7 +430,7 @@ def _validate_refs(
|
||||
manager_id: int | None,
|
||||
self_id: int | None,
|
||||
) -> tuple[BusinessRole, str | None]:
|
||||
"""新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。
|
||||
"""新选经理须在职且绑定启用的经理计提岗位;原有参考关系保留供核对。
|
||||
|
||||
``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。
|
||||
返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。
|
||||
@@ -411,8 +446,18 @@ def _validate_refs(
|
||||
if manager_id == self_id:
|
||||
raise BadRequestError("直属经理不能是自己")
|
||||
manager = personnel_repository.get_by_id(session, manager_id)
|
||||
if manager is None or manager.status != 1:
|
||||
if manager is None:
|
||||
raise BadRequestError("直属经理不存在或已离职")
|
||||
original = session.get(Personnel, self_id) if self_id is not None else None
|
||||
unchanged = original is not None and original.manager_id == manager_id
|
||||
if manager.status != 1 and not unchanged:
|
||||
raise BadRequestError("直属经理不存在或已离职")
|
||||
manager_role = business_role_repository.get_by_id(session, manager.business_role_id)
|
||||
if not unchanged and (
|
||||
manager_role is None or manager_role.status != 1
|
||||
or calc_templates.formula_type_of(session, manager_role.calc_template) != "manager_share"
|
||||
):
|
||||
raise BadRequestError("直属经理须选择在职且使用经理计提模板的启用岗位人员")
|
||||
manager_name = manager.personnel_name
|
||||
return role, manager_name
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。"""
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import Business, BusinessItem, Product
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.tplus.snapshots import source_records, source_statement
|
||||
from app.services.tplus.workbench import WorkbenchError, digest
|
||||
|
||||
|
||||
def recent_trades(session, source, ids):
|
||||
if not ids:
|
||||
return {}
|
||||
try:
|
||||
lines = list(
|
||||
session.scalars(
|
||||
source_statement(session, source, "shipment_items").where(
|
||||
source_statement_inventory_filter(ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
parent_ids = {
|
||||
str(r.payload_json["source_parent_id"])
|
||||
for r in lines
|
||||
if r.payload_json.get("source_parent_id") is not None
|
||||
}
|
||||
heads = {
|
||||
r.source_id: r for r in source_records(session, source, "shipments", list(parent_ids))
|
||||
}
|
||||
except WorkbenchError:
|
||||
return {}
|
||||
latest = {}
|
||||
for record in lines:
|
||||
raw = record.payload_json
|
||||
head = heads.get(str(raw.get("source_parent_id")))
|
||||
if head is None:
|
||||
continue
|
||||
value = head.payload_json
|
||||
if str(value.get("voucherState")) != "189" or value.get("isCounteractDelivery") not in (
|
||||
None,
|
||||
0,
|
||||
False,
|
||||
"0",
|
||||
):
|
||||
continue
|
||||
day = str(value.get("voucherdate") or "")[:10]
|
||||
try:
|
||||
quantity = Decimal(str(raw.get("quantity")))
|
||||
price = (
|
||||
Decimal(str(raw["taxPrice"]))
|
||||
if raw.get("taxPrice") is not None
|
||||
else Decimal(str(raw["taxAmount"])) / quantity
|
||||
)
|
||||
if not quantity.is_finite() or quantity <= 0 or not price.is_finite() or price < 0:
|
||||
continue
|
||||
except (ValueError, ArithmeticError, TypeError, KeyError):
|
||||
continue
|
||||
key = str(raw.get("idinventory"))
|
||||
order = day, int(head.source_id), int(record.source_id)
|
||||
if not day or (key in latest and order <= latest[key][0]):
|
||||
continue
|
||||
if (
|
||||
quantity > Decimal("999999999999.99")
|
||||
or quantity.normalize().as_tuple().exponent < -2
|
||||
or price > Decimal("9999999999.9999")
|
||||
):
|
||||
# 无法以当前产品档案精度无损保存的记录保持待核对,不截断数量。
|
||||
continue
|
||||
latest[key] = (
|
||||
order,
|
||||
{
|
||||
"quantity": str(quantity),
|
||||
"price": str(price.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)),
|
||||
"date": day,
|
||||
"source_no": head.source_no,
|
||||
"source_id": head.source_id,
|
||||
"line_id": record.source_id,
|
||||
"source_database": source,
|
||||
},
|
||||
)
|
||||
return {key: value[1] for key, value in latest.items()}
|
||||
|
||||
|
||||
def source_statement_inventory_filter(ids):
|
||||
from app.models import TPlusSyncRecord
|
||||
|
||||
return TPlusSyncRecord.payload_json["idinventory"].as_integer().in_([int(v) for v in ids])
|
||||
|
||||
|
||||
def candidates(session, keyword="", page_num=1, page_size=20):
|
||||
condition = BusinessItem.product_id.is_(None)
|
||||
if keyword:
|
||||
condition &= or_(
|
||||
BusinessItem.product_name.contains(keyword, autoescape=True),
|
||||
Business.business_no.contains(keyword, autoescape=True),
|
||||
)
|
||||
rows = session.execute(
|
||||
select(BusinessItem, Business.business_no, Business.business_date)
|
||||
.join(Business, Business.id == BusinessItem.business_id)
|
||||
.where(condition)
|
||||
.order_by(BusinessItem.id.desc())
|
||||
.offset((page_num - 1) * page_size)
|
||||
.limit(page_size + 1)
|
||||
).all()
|
||||
return {
|
||||
"rows": [
|
||||
{
|
||||
"id": row.id,
|
||||
"business_no": number,
|
||||
"business_date": str(day) if day else None,
|
||||
"product_name": row.product_name,
|
||||
"spec_model": row.spec_model,
|
||||
"unit": row.unit,
|
||||
"product_category": row.product_category,
|
||||
"quantity": str(row.quantity),
|
||||
"sale_price": str(row.sale_price) if row.sale_price is not None else None,
|
||||
}
|
||||
for row, number, day in rows[:page_size]
|
||||
],
|
||||
"has_more": len(rows) > page_size,
|
||||
"page_num": page_num,
|
||||
}
|
||||
|
||||
|
||||
def extraction_plan(session, ids, *, lock=False):
|
||||
if len(ids) != len(set(ids)):
|
||||
raise BadRequestError("不能重复选择同一业务明细")
|
||||
statement = (
|
||||
select(BusinessItem, Business.business_date)
|
||||
.join(Business, Business.id == BusinessItem.business_id)
|
||||
.where(BusinessItem.id.in_(ids))
|
||||
.order_by(BusinessItem.id)
|
||||
)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
source = session.execute(statement).all()
|
||||
if len(source) != len(ids):
|
||||
raise BadRequestError("所选业务明细不存在或已变化")
|
||||
groups = defaultdict(list)
|
||||
for item, day in source:
|
||||
groups[(item.product_name.strip(), (item.spec_model or "").strip())].append((item, day))
|
||||
rows = []
|
||||
for (name, spec), members in groups.items():
|
||||
target_statement = select(Product).where(
|
||||
Product.product_name == name, Product.spec_model == spec
|
||||
)
|
||||
if lock:
|
||||
target_statement = target_statement.with_for_update().execution_options(
|
||||
populate_existing=True
|
||||
)
|
||||
target = session.scalar(target_statement)
|
||||
units = {m.unit or "" for m, _ in members}
|
||||
categories = {m.product_category for m, _ in members}
|
||||
conflict = "所选明细单位或核算分类不一致" if len(units) > 1 or len(categories) > 1 else None
|
||||
if target and (
|
||||
target.product_category not in categories or (target.unit or "") not in units
|
||||
):
|
||||
conflict = "同名同规格档案的单位或核算分类不同"
|
||||
newest, day = max(
|
||||
members, key=lambda pair: (str(pair[1] or ""), pair[0].business_id, pair[0].id)
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"product_name": name,
|
||||
"spec_model": spec,
|
||||
"unit": newest.unit,
|
||||
"product_category": newest.product_category,
|
||||
"quantity": str(newest.quantity),
|
||||
"sale_price": str(newest.sale_price) if newest.sale_price is not None else None,
|
||||
"item_ids": [m.id for m, _ in members],
|
||||
"target_id": target.id if target else None,
|
||||
"action": "CONFLICT" if conflict else "LINK" if target else "CREATE",
|
||||
"reason": conflict,
|
||||
"already_linked": [m.product_id for m, _ in members],
|
||||
"target_fingerprint": digest(
|
||||
{c.name: getattr(target, c.name) for c in target.__table__.columns}
|
||||
)
|
||||
if target
|
||||
else None,
|
||||
"date": str(day) if day else None,
|
||||
}
|
||||
)
|
||||
return {"rows": rows, "fingerprint": digest(rows)}
|
||||
|
||||
|
||||
def extract(session, payload, actor, meta=None):
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=actor,
|
||||
record=AuditRecord(
|
||||
module="产品管理",
|
||||
action="提取产品",
|
||||
summary=f"从{len(payload.item_ids)}条业务明细提取产品",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
plan = extraction_plan(session, payload.item_ids, lock=True)
|
||||
if payload.fingerprint != plan["fingerprint"]:
|
||||
raise BadRequestError("业务明细或产品档案已变化,请重新预览")
|
||||
for row in plan["rows"]:
|
||||
if row["action"] == "CONFLICT":
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
target = (
|
||||
session.get(Product, row["target_id"])
|
||||
if row["target_id"]
|
||||
else Product(
|
||||
product_name=row["product_name"],
|
||||
spec_model=row["spec_model"],
|
||||
unit=row["unit"],
|
||||
product_category=row["product_category"],
|
||||
stock_quantity=Decimal(row["quantity"]),
|
||||
sale_price=Decimal(row["sale_price"])
|
||||
if row["sale_price"] is not None
|
||||
else None,
|
||||
status=1,
|
||||
quantity_price_manual=1,
|
||||
)
|
||||
)
|
||||
session.add(target)
|
||||
session.flush()
|
||||
row["target_id"] = target.id
|
||||
for item_id in row["item_ids"]:
|
||||
item = session.get(BusinessItem, item_id)
|
||||
# 仅建立档案关联,不更改历史冗余名称、金额或冻结核算资料。
|
||||
if item.product_id is None:
|
||||
item.product_id = target.id
|
||||
elif item.product_id != target.id:
|
||||
raise BadRequestError("业务明细已关联不同产品,请重新核对")
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
row["action"], row["reason"] = "CONFLICT", "并发建立了同名档案,请重新预览"
|
||||
session.commit()
|
||||
return plan
|
||||
|
||||
|
||||
def restore_source(session, product_id, actor, meta=None):
|
||||
product = session.get(Product, product_id, with_for_update=True)
|
||||
if product is None:
|
||||
raise NotFoundError("产品不存在")
|
||||
if not product.source_trade_json:
|
||||
raise BadRequestError("没有可恢复的有效发货来源")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=actor,
|
||||
record=AuditRecord(
|
||||
module="产品管理", action="恢复源值", summary=f"产品#{product_id}恢复最近有效发货参照"
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
product.quantity_price_manual = 0
|
||||
product.stock_quantity = Decimal(product.source_trade_json["quantity"])
|
||||
product.sale_price = Decimal(product.source_trade_json["price"])
|
||||
session.commit()
|
||||
return {"id": product_id, "restored": True}
|
||||
@@ -53,13 +53,15 @@ def _to_out(product: Product) -> ProductOut:
|
||||
product_name=product.product_name,
|
||||
spec_model=product.spec_model,
|
||||
unit=product.unit,
|
||||
stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None,
|
||||
sale_price=str(product.sale_price) if product.sale_price is not None else None,
|
||||
stock_quantity=(product.source_trade_json.get("quantity") if product.source_trade_json and not product.quantity_price_manual else str(product.stock_quantity) if product.stock_quantity is not None else None),
|
||||
sale_price=(product.source_trade_json.get("price") if product.source_trade_json and not product.quantity_price_manual else str(product.sale_price) if product.sale_price is not None else None),
|
||||
product_category=product.product_category,
|
||||
status=product.status,
|
||||
remark=product.remark,
|
||||
create_time=product.create_time,
|
||||
update_time=product.update_time,
|
||||
source_trade_json=product.source_trade_json,
|
||||
quantity_price_manual=product.quantity_price_manual or 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,6 +154,7 @@ def create_product(
|
||||
raise BadRequestError("已存在同名称同规格的产品")
|
||||
|
||||
product = Product(
|
||||
quantity_price_manual=1 if payload.stock_quantity is not None or payload.sale_price is not None else 0,
|
||||
product_name=payload.product_name,
|
||||
spec_model=spec_model,
|
||||
unit=payload.unit,
|
||||
@@ -208,6 +211,9 @@ def update_product(
|
||||
product.product_name = payload.product_name
|
||||
product.spec_model = payload.spec_model or ""
|
||||
product.unit = payload.unit
|
||||
old = _to_out(product)
|
||||
if _to_decimal(payload.stock_quantity) != _to_decimal(old.stock_quantity) or _to_decimal(payload.sale_price) != _to_decimal(old.sale_price):
|
||||
product.quantity_price_manual = 1
|
||||
product.stock_quantity = _to_decimal(payload.stock_quantity)
|
||||
product.sale_price = _to_decimal(payload.sale_price)
|
||||
product.product_category = payload.product_category
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""正式核算与试算共用计提规则;比例保持 Fraction,产品覆盖存于参数版本。"""
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
|
||||
PERSONAL_FIELDS = ("first", "start", "slope", "floor", "boundary")
|
||||
BASE_CODES = {
|
||||
"first": "salesperson.rate.start",
|
||||
"start": "salesperson.rate.start",
|
||||
"slope": "salesperson.rate.slope",
|
||||
"floor": "salesperson.rate.floor",
|
||||
"boundary": "salesperson.days.boundary",
|
||||
}
|
||||
|
||||
|
||||
def personal_values(snapshot, category):
|
||||
"""未维护产品专属项时,继续使用同日期的通用参数,不复制初始值。"""
|
||||
result = {}
|
||||
for field in PERSONAL_FIELDS:
|
||||
code = f"coefficient.personal.{field}"
|
||||
overrides = snapshot._values.get(code, {})
|
||||
result[field] = overrides.get(
|
||||
category, overrides.get("", snapshot.get(BASE_CODES[field], role_code="salesperson"))
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def decide(params, *, category, customer_type, role_code, days, prepayment=False):
|
||||
"""返回纯比例和计算基数类型。经理个人户排除优先于预收/企业户。"""
|
||||
if role_code == "sales_manager" or customer_type == "manager_personal":
|
||||
return Fraction(0), "converted", "经理本人/经理个人户不单笔计提"
|
||||
if role_code == "info_officer":
|
||||
return Fraction(str(params.get("info", "1"))) / 1000, "sales", "信息员按销售额计提"
|
||||
if prepayment:
|
||||
personal = params.get("personal", {}).get(category, params)
|
||||
maximum = max(
|
||||
Fraction(str(personal.get(key, personal["start"])))
|
||||
for key in ("first", "start", "floor")
|
||||
)
|
||||
return maximum / 1000, "converted", "预收发货后按最高比例计提"
|
||||
if customer_type == "enterprise":
|
||||
value = params.get("enterprise_by_category", {}).get(category, params["enterprise"])
|
||||
return Fraction(str(value)) / 1000, "converted", "企业户按产品分类固定计提"
|
||||
values = params.get("personal", {}).get(category, params)
|
||||
if days <= 1:
|
||||
rate = Fraction(str(values.get("first", values["start"]))) / 1000
|
||||
else:
|
||||
rate = formula.salesperson_rate(
|
||||
days,
|
||||
start=Fraction(str(values["start"])),
|
||||
slope=Fraction(str(values["slope"])),
|
||||
floor=Fraction(str(values["floor"])),
|
||||
boundary_days=int(Fraction(str(values.get("boundary", 360)))),
|
||||
)
|
||||
return rate, "converted", "个人户按产品分类及回款阶段计提"
|
||||
@@ -135,6 +135,17 @@ def build_workbook(
|
||||
operator_name=operator_name,
|
||||
)
|
||||
buffer = BytesIO()
|
||||
if department.get("salary_rows"):
|
||||
salary = workbook.create_sheet("工资分项快照")
|
||||
salary.append(["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"])
|
||||
for row in department["salary_rows"]:
|
||||
salary.append([row["personnel_name"], *[Decimal(row[key]) if row.get(key) is not None else None for key in
|
||||
("basic_amount", "normal_payable_sum", "deferred_sum", "probation_payment_sum", "payable_sum", "net_amount")]])
|
||||
if salary.cell(salary.max_row, 1).data_type == "f":
|
||||
salary.cell(salary.max_row, 1).data_type = "s"
|
||||
salary.freeze_panes = "A2"
|
||||
for column in salary.columns:
|
||||
salary.column_dimensions[column[0].column_letter].width = 24
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@@ -206,8 +206,13 @@ def department_summary(session, query) -> dict:
|
||||
company_share_amount = _company_share(company_base, share.company_pct)
|
||||
company_share_count = sum(1 for team in teams if team["has_aggregate"])
|
||||
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
|
||||
from app.services.salary_completion import frozen_rows
|
||||
|
||||
visible_people = {member["personnel_id"] for team in teams for member in team["members"]} | {team["manager_id"] for team in teams}
|
||||
salary_rows = frozen_rows(session, month, visible_people if query.manager_id is not None else None)
|
||||
return {
|
||||
"settlement_month": month,
|
||||
"salary_rows": salary_rows,
|
||||
"params": {
|
||||
"team_text": share.team_text,
|
||||
"company_text": share.company_text,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""完整工资按人汇总:基本工资只计一次,试用期缓发及年度释放分列。"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import BusinessRole, Personnel, ProbationPayment, SettlementItem
|
||||
from app.services import formula, probation_service
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
ZERO = Decimal("0")
|
||||
|
||||
|
||||
def build_items(session, rows, month):
|
||||
from app.services import personnel_salary
|
||||
from app.services.settlement_service import _salary_mode_of
|
||||
|
||||
end = month_end(month)
|
||||
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
|
||||
people = {
|
||||
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
|
||||
}
|
||||
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
|
||||
profiles = {pid: personnel_salary.resolve(person, end) for pid, person in people.items()}
|
||||
buckets = {}
|
||||
|
||||
def bucket(person):
|
||||
return buckets.setdefault(
|
||||
person.id,
|
||||
{
|
||||
"person": person,
|
||||
"performance": ZERO,
|
||||
"normal": ZERO,
|
||||
"deferred": ZERO,
|
||||
"ledger_ids": [],
|
||||
"annual": [],
|
||||
"details": [],
|
||||
},
|
||||
)
|
||||
|
||||
for person in people.values():
|
||||
if (
|
||||
personnel_salary.employed(person, profiles[person.id], end)
|
||||
and profiles[person.id].salary_standard is not None
|
||||
):
|
||||
bucket(person)
|
||||
for row in rows:
|
||||
person = people.get(row.personnel_id)
|
||||
if person is None:
|
||||
continue
|
||||
item = bucket(person)
|
||||
item["performance"] += row.performance_amount or ZERO
|
||||
key = "deferred" if row.is_probation == 1 else "normal"
|
||||
item[key] += row.payable_amount or ZERO
|
||||
item["ledger_ids"].append(row.id)
|
||||
item["details"].append(
|
||||
{
|
||||
"ledger_id": row.id,
|
||||
"calc_type": row.calc_type,
|
||||
"performance": str(row.performance_amount or ZERO),
|
||||
"payable": str(row.payable_amount or ZERO),
|
||||
}
|
||||
)
|
||||
probation_service.record(session, row)
|
||||
session.flush()
|
||||
preview = probation_service.preview(session, month, lock=True)
|
||||
for annual in preview["rows"]:
|
||||
person = people.get(annual["personnel_id"])
|
||||
if person is None or not Decimal(annual["delta"]):
|
||||
continue
|
||||
later = session.scalars(
|
||||
select(ProbationPayment).where(
|
||||
ProbationPayment.personnel_id == person.id,
|
||||
ProbationPayment.year == annual["year"],
|
||||
ProbationPayment.month > month,
|
||||
)
|
||||
)
|
||||
if any(probation_service.payment_state(session, payment, True) != "ignored" for payment in later):
|
||||
from app.core.exceptions import BadRequestError
|
||||
|
||||
raise BadRequestError("该年度已有后续月份结清记录,请按月份顺序处理差额")
|
||||
bucket(person)["annual"].append(annual)
|
||||
result = []
|
||||
for pid, values in sorted(buckets.items()):
|
||||
person = values["person"]
|
||||
profile = profiles[pid]
|
||||
role = roles.get(profile.business_role_id, "salesperson")
|
||||
mode = _salary_mode_of(session, role, month)
|
||||
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
|
||||
normal = values["normal"]
|
||||
basic = profile.salary_standard if personnel_salary.employed(person, profile, end) else ZERO
|
||||
trial = probation_service.on_date(profile, end)
|
||||
net = (
|
||||
((basic or ZERO) + normal + annual)
|
||||
if trial
|
||||
else formula.monthly_salary(normal + annual, basic, mode)
|
||||
)
|
||||
result.append(
|
||||
SettlementItem(
|
||||
personnel_id=pid,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role,
|
||||
calc_type=2 if role == "sales_manager" else 1,
|
||||
performance_sum=values["performance"].quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
),
|
||||
payable_sum=(normal + annual).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
normal_payable_sum=normal,
|
||||
deferred_sum=values["deferred"],
|
||||
probation_payment_sum=annual,
|
||||
basic_amount=basic,
|
||||
salary_mode="INCLUDE" if trial else mode,
|
||||
net_amount=net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if net is not None
|
||||
else None,
|
||||
is_probation=int(trial),
|
||||
probation_note="试用期绩效进入缓发台账;本月仅发基本工资及符合条件的年度补发"
|
||||
if trial
|
||||
else None,
|
||||
snapshot_json={
|
||||
"ledger_ids": values["ledger_ids"],
|
||||
"calculation_details": values["details"],
|
||||
"probation_settlements": values["annual"],
|
||||
},
|
||||
)
|
||||
)
|
||||
return result, [row.id for row in rows]
|
||||
|
||||
|
||||
def frozen_rows(session, month, personnel_ids=None):
|
||||
from app.models import MonthlySettlement
|
||||
|
||||
statement = (
|
||||
select(SettlementItem)
|
||||
.join(MonthlySettlement)
|
||||
.where(MonthlySettlement.settlement_month == month)
|
||||
)
|
||||
if personnel_ids is not None:
|
||||
statement = statement.where(SettlementItem.personnel_id.in_(personnel_ids))
|
||||
return [
|
||||
{
|
||||
"personnel_id": item.personnel_id,
|
||||
"personnel_name": item.personnel_name,
|
||||
"basic_amount": str(item.basic_amount) if item.basic_amount is not None else None,
|
||||
"normal_payable_sum": str(item.normal_payable_sum),
|
||||
"deferred_sum": str(item.deferred_sum),
|
||||
"probation_payment_sum": str(item.probation_payment_sum),
|
||||
"payable_sum": str(item.payable_sum),
|
||||
"net_amount": str(item.net_amount) if item.net_amount is not None else None,
|
||||
}
|
||||
for item in session.scalars(statement)
|
||||
]
|
||||
|
||||
|
||||
def export_settlement(session, month):
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
book = Workbook()
|
||||
sheet = book.active
|
||||
sheet.title = "工资分项"
|
||||
sheet.append(
|
||||
["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"]
|
||||
)
|
||||
for row in frozen_rows(session, month):
|
||||
sheet.append(
|
||||
[
|
||||
row["personnel_name"],
|
||||
*[
|
||||
Decimal(row[key]) if row[key] is not None else None
|
||||
for key in (
|
||||
"basic_amount",
|
||||
"normal_payable_sum",
|
||||
"deferred_sum",
|
||||
"probation_payment_sum",
|
||||
"payable_sum",
|
||||
"net_amount",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
if sheet.cell(sheet.max_row, 1).data_type == "f":
|
||||
sheet.cell(sheet.max_row, 1).data_type = "s"
|
||||
sheet.freeze_panes = "A2"
|
||||
for col in sheet.columns:
|
||||
sheet.column_dimensions[col[0].column_letter].width = 24
|
||||
output = BytesIO()
|
||||
book.save(output)
|
||||
return output.getvalue()
|
||||
@@ -90,7 +90,9 @@ def generate(
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
if not ledger_rows:
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||
|
||||
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||
@@ -98,6 +100,11 @@ def generate(
|
||||
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||
|
||||
settlement_repository.replace_items(session, settlement.id, items)
|
||||
if not items:
|
||||
raise BadRequestError(f"{month} 无可发薪人员或核算记录")
|
||||
from app.services.probation_service import persist_payments
|
||||
|
||||
persist_payments(session, settlement, items)
|
||||
_apply_totals(settlement, items)
|
||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||
@@ -156,6 +163,11 @@ def _release_previous_round(
|
||||
"""
|
||||
if existing is None:
|
||||
return
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models import ProbationPayment
|
||||
|
||||
session.execute(delete(ProbationPayment).where(ProbationPayment.settlement_id == existing.id))
|
||||
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||
|
||||
@@ -182,6 +194,12 @@ def _build_items(
|
||||
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||
) -> tuple[list[SettlementItem], list[int]]:
|
||||
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
from app.services.salary_completion import build_items
|
||||
|
||||
return build_items(session, ledger_rows + aggregate_rows, month)
|
||||
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||
@@ -420,6 +438,7 @@ def _aggregate_row(
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
from app.services.probation_service import on_date
|
||||
|
||||
amount = manager_team_share(
|
||||
bucket["performance"],
|
||||
@@ -449,7 +468,7 @@ def _aggregate_row(
|
||||
barter_applied=0,
|
||||
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
settlement_month=share.month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
is_probation=int(on_date(manager, share.param_date)),
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
|
||||
@@ -148,7 +148,9 @@ def _cycle_current(session, state):
|
||||
|
||||
def tick(session, *, settings=None, now=None):
|
||||
settings, now = settings or get_settings(), now or local_now()
|
||||
if not settings.t_plus_automation_enabled:
|
||||
from app.services.tplus.automation_config import configuration
|
||||
|
||||
if not configuration(session, settings)["enabled"]:
|
||||
return {"status": "DISABLED", "queued": False}
|
||||
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
|
||||
return {"status": "STOP_REQUESTED", "queued": False}
|
||||
@@ -243,10 +245,22 @@ def overview(session, *, settings=None, now=None):
|
||||
base = {"enabled": settings.t_plus_automation_enabled, "weekday": settings.t_plus_schedule_weekday,
|
||||
"time": settings.t_plus_schedule_time, "timezone": "Asia/Shanghai", "worker_phase": "UNKNOWN",
|
||||
"heartbeat_at": None, "last_success_at": None, "next_retry_at": None, "alerts": []}
|
||||
from app.services.tplus.automation_config import configuration
|
||||
|
||||
base.update(configuration(session, settings))
|
||||
base["stop_requested"] = bool(settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists())
|
||||
try:
|
||||
key, database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
database = task_service.history_source_database(session)
|
||||
if database:
|
||||
base["last_success_at"] = session.scalar(
|
||||
select(func.max(TPlusSyncBatch.finished_at)).where(
|
||||
TPlusSyncBatch.source_database == database,
|
||||
TPlusSyncBatch.status == "SUCCEEDED",
|
||||
TPlusSyncBatch.sync_mode == "FULL",
|
||||
)
|
||||
)
|
||||
return base
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
|
||||
base["alerts"] = [{"code": "MIGRATION_REQUIRED", "message": "运行状态结构尚未升级"}]
|
||||
@@ -265,7 +279,7 @@ def overview(session, *, settings=None, now=None):
|
||||
alerts.append({"code": "QUEUE_OVERDUE", "message": "采集任务排队超过15分钟", "batch_no": queued.batch_no})
|
||||
last_success = session.scalar(select(func.max(TPlusSyncBatch.finished_at)).where(TPlusSyncBatch.source_database == database, TPlusSyncBatch.status == "SUCCEEDED", TPlusSyncBatch.sync_mode == "FULL"))
|
||||
base["last_success_at"] = last_success
|
||||
if settings.t_plus_automation_enabled and not base["stop_requested"]:
|
||||
if base["enabled"] and not base["stop_requested"]:
|
||||
if last_success is None or now - last_success > timedelta(days=8):
|
||||
alerts.append({"code": "SYNC_STALE", "message": "尚无成功采集或超过8天未成功同步"})
|
||||
if state is None or state.heartbeat_at is None or now - state.heartbeat_at > timedelta(minutes=2) or state.worker_phase == "STOPPED":
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""源级自动采集设置;页面、周期维护共用,不修改进程环境配置。"""
|
||||
|
||||
from sqlalchemy import inspect, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.models import TPlusAutomationConfig
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus import task_service
|
||||
|
||||
|
||||
def configuration(session, settings):
|
||||
from app.services.tplus.automation import source_identity
|
||||
|
||||
result = {"enabled": settings.t_plus_automation_enabled, "version": 0,
|
||||
"editable": False, "configuration_origin": "ENVIRONMENT"}
|
||||
try:
|
||||
key, _database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
return result
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
|
||||
return result
|
||||
result["editable"] = True
|
||||
row = session.scalar(select(TPlusAutomationConfig).where(
|
||||
TPlusAutomationConfig.source_key == key
|
||||
).execution_options(populate_existing=True))
|
||||
if row is not None:
|
||||
result.update(enabled=bool(row.enabled), version=row.version, configuration_origin="SAVED")
|
||||
return result
|
||||
|
||||
|
||||
def save(session, payload, actor):
|
||||
from app.services.tplus.automation import source_identity
|
||||
|
||||
require_permission(session, actor.user, "tplus:collect")
|
||||
settings = task_service.get_settings()
|
||||
try:
|
||||
key, database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
raise BadRequestError("请先配置可用的 T+ 数据源") from None
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationConfig.__tablename__):
|
||||
raise BadRequestError("自动采集设置尚未升级,请联系维护人员")
|
||||
data = {"enabled": payload.enabled, "expected_version": payload.expected_version}
|
||||
with task_service._audit(session, actor, "修改自动采集", data):
|
||||
row = session.scalar(select(TPlusAutomationConfig).where(TPlusAutomationConfig.source_key == key))
|
||||
if row is None:
|
||||
if payload.expected_version != 0:
|
||||
raise ConflictError()
|
||||
session.add(TPlusAutomationConfig(source_key=key, source_database=database,
|
||||
enabled=int(payload.enabled), version=1, updated_by=actor.user.id))
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise ConflictError() from None
|
||||
else:
|
||||
changed = session.execute(update(TPlusAutomationConfig).where(
|
||||
TPlusAutomationConfig.source_key == key,
|
||||
TPlusAutomationConfig.version == payload.expected_version,
|
||||
).values(enabled=int(payload.enabled), version=payload.expected_version + 1,
|
||||
updated_by=actor.user.id))
|
||||
if changed.rowcount != 1:
|
||||
session.rollback()
|
||||
raise ConflictError()
|
||||
session.commit()
|
||||
from app.services.tplus.automation import overview
|
||||
|
||||
return overview(session, settings=settings)
|
||||
@@ -22,8 +22,8 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.services import payroll_months
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.no_generator import next_no
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
from app.services.tplus.calculation_preview import execute_preview
|
||||
from app.services.tplus.fifo import money
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, get_run
|
||||
@@ -367,13 +367,13 @@ def _create(session, run, row, frozen):
|
||||
|
||||
def execute_business(session, run):
|
||||
operator = session.get(User, run.requested_by)
|
||||
ensure_payroll_writer(operator)
|
||||
require_permission(session, operator, "tplus:import" if run.kind == "BUSINESS_APPLY" else "tplus:preview")
|
||||
request = run
|
||||
if run.kind == "BUSINESS_APPLY":
|
||||
request = get_run(session, run.input_json["preview_no"], run.source_database)
|
||||
if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise WorkbenchError("PREVIEW_REQUIRED")
|
||||
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator)
|
||||
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator, require_maker=False)
|
||||
rows, frozen, fingerprint = _build(session, request)
|
||||
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
|
||||
raise WorkbenchError("PREVIEW_STALE")
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import Customer, Product, TPlusSourceLink, TPlusSyncRecord, TPlusWorkLine
|
||||
from app.services import param_service
|
||||
from app.services.rate_rules import personal_values
|
||||
from app.services.tplus.fifo import START, amount_text, money, preview_group
|
||||
from app.services.tplus.snapshots import source_records, source_statement
|
||||
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
|
||||
@@ -20,11 +21,24 @@ def _parameters(session, days):
|
||||
for day in sorted(days):
|
||||
snap = param_service.resolve_snapshot(session, date.fromisoformat(day))
|
||||
values[day] = {
|
||||
"start": snap.get("salesperson.rate.start"),
|
||||
"slope": snap.get("salesperson.rate.slope"),
|
||||
"floor": snap.get("salesperson.rate.floor"),
|
||||
"boundary": snap.integer("salesperson.days.boundary"),
|
||||
"parameter_snapshot_hash": digest([
|
||||
(code, dimension, value, str(snap._effective.get((code, dimension))))
|
||||
for code, dimension, value in sorted(snap.iter_items())
|
||||
]),
|
||||
"start": snap.get("salesperson.rate.start", role_code="salesperson"),
|
||||
"slope": snap.get("salesperson.rate.slope", role_code="salesperson"),
|
||||
"floor": snap.get("salesperson.rate.floor", role_code="salesperson"),
|
||||
"boundary": snap.integer("salesperson.days.boundary", role_code="salesperson"),
|
||||
"enterprise": snap.get("enterprise.rate"),
|
||||
"enterprise_by_category": {
|
||||
category: snap.get("enterprise.rate", product_category=category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
},
|
||||
"info": snap.get("info.rate", role_code="info_officer"),
|
||||
"personal": {
|
||||
category: personal_values(snap, category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
},
|
||||
"thresholds": {
|
||||
category: snap.get("product.threshold", product_category=category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.exc import TimeoutError as PoolTimeoutError
|
||||
|
||||
TRANSIENT_CODES = frozenset({"SOURCE_CONNECTION_LOST", "SOURCE_TIMEOUT"})
|
||||
MESSAGES = {
|
||||
"TASK_AUTHORIZATION_REVOKED": "请求账号已停用或权限已撤销,请重新授权后创建任务",
|
||||
"SOURCE_CONNECTION_LOST": "源连接中断,请检查网络和只读连接",
|
||||
"SOURCE_TIMEOUT": "源连接或查询超时",
|
||||
"SOURCE_AUTH_ERROR": "源账号认证失败,请核对只读账号配置",
|
||||
@@ -15,6 +16,10 @@ MESSAGES = {
|
||||
|
||||
|
||||
def classify_source_error(exc, fallback="READ_ERROR"):
|
||||
from app.core.exceptions import ForbiddenError
|
||||
|
||||
if isinstance(exc, ForbiddenError):
|
||||
return "TASK_AUTHORIZATION_REVOKED"
|
||||
if isinstance(exc, (TimeoutError, PoolTimeoutError)):
|
||||
return "SOURCE_TIMEOUT"
|
||||
if isinstance(exc, ModuleNotFoundError):
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
from app.services.rate_rules import decide
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
ZERO = Decimal("0")
|
||||
@@ -110,20 +111,12 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
)
|
||||
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
|
||||
manager_own = bool(original_person and original_person["role_code"] == "sales_manager")
|
||||
if manager_own or person["role_code"] == "sales_manager" or customer_type == "manager_personal":
|
||||
rate = Fraction(0)
|
||||
elif _day(receipt["date"]) < _day(shipment["date"]):
|
||||
rate = Fraction(str(rate_params["start"])) / 1000
|
||||
elif customer_type == "enterprise":
|
||||
rate = Fraction(str(rate_params["enterprise"])) / 1000
|
||||
else:
|
||||
rate = formula.salesperson_rate(
|
||||
days,
|
||||
start=Fraction(str(rate_params["start"])),
|
||||
slope=Fraction(str(rate_params["slope"])),
|
||||
floor=Fraction(str(rate_params["floor"])),
|
||||
boundary_days=int(rate_params["boundary"]),
|
||||
)
|
||||
rate, basis, rule = decide(
|
||||
rate_params, category=category, customer_type=customer_type,
|
||||
role_code="sales_manager" if manager_own else person["role_code"],
|
||||
days=days, prepayment=is_prepayment,
|
||||
)
|
||||
calculation_base = matched if basis == "sales" else converted
|
||||
return {
|
||||
"status": "READY",
|
||||
"reasons": [],
|
||||
@@ -145,7 +138,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
+ "%",
|
||||
"parameters": rate_params,
|
||||
"parameter_date": parameter_date.isoformat(),
|
||||
"performance": amount_text(formula.performance_amount(converted, rate)),
|
||||
"performance": amount_text(formula.performance_amount(calculation_base, rate)),
|
||||
"calculation_base": amount_text(calculation_base),
|
||||
"basis": basis,
|
||||
"rule": rule,
|
||||
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -32,7 +33,7 @@ def _target_snapshot(target):
|
||||
return {c.name: getattr(target, c.name) for c in target.__table__.columns}
|
||||
|
||||
|
||||
def _master_values(entity, record, facts, session):
|
||||
def _master_values(entity, record, facts, session, trades=None):
|
||||
row = record.payload_json
|
||||
if entity == "personnel":
|
||||
code = str(row.get("code") or "").strip()
|
||||
@@ -59,11 +60,15 @@ def _master_values(entity, record, facts, session):
|
||||
unit = row.get("unit_name")
|
||||
if not unit or unit != fact.data_json["unit"]:
|
||||
return None, "PRODUCT_UNIT_PENDING"
|
||||
trade = (trades or {}).get(record.source_id)
|
||||
return {
|
||||
"product_name": row["name"],
|
||||
"spec_model": row.get("specification") or "",
|
||||
"unit": unit,
|
||||
"product_category": fact.data_json["category"],
|
||||
"source_trade_json": trade,
|
||||
"stock_quantity": Decimal(trade["quantity"]) if trade else None,
|
||||
"sale_price": Decimal(trade["price"]) if trade else None,
|
||||
}, None
|
||||
if row.get("partnerType") != CUSTOMER_ONLY:
|
||||
return None, "NOT_CUSTOMER_ONLY"
|
||||
@@ -77,12 +82,14 @@ def _master_values(entity, record, facts, session):
|
||||
}, None
|
||||
|
||||
|
||||
def _protected_update(entity, values):
|
||||
def _protected_update(entity, values, target=None):
|
||||
allowed = {
|
||||
"personnel": {"personnel_name"},
|
||||
"products": {"product_name", "spec_model", "unit"},
|
||||
"products": {"product_name", "spec_model", "unit", "source_trade_json"},
|
||||
"customers": {"company_name"},
|
||||
}[entity]
|
||||
if entity == "products" and target is not None and not target.quantity_price_manual:
|
||||
allowed = allowed | {"stock_quantity", "sale_price"}
|
||||
return {k: v for k, v in values.items() if k in allowed}
|
||||
|
||||
|
||||
@@ -121,6 +128,9 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
records = source_records(
|
||||
session, run.source_database, entity, run.input_json.get("source_ids") or None
|
||||
)
|
||||
from app.services.product_completion import recent_trades
|
||||
|
||||
trades = recent_trades(session, run.source_database, [r.source_id for r in records]) if entity == "products" else {}
|
||||
for record in records:
|
||||
link = links.get((entity, record.source_id))
|
||||
target = (
|
||||
@@ -133,7 +143,7 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
if link
|
||||
else None
|
||||
)
|
||||
values, reason = _master_values(entity, record, facts, session)
|
||||
values, reason = _master_values(entity, record, facts, session, trades)
|
||||
action = "PENDING" if reason else "CREATE"
|
||||
if reason in ("NOT_IN_PERSONNEL_ROSTER", "NOT_CUSTOMER_ONLY"):
|
||||
action = "SKIP"
|
||||
@@ -141,7 +151,7 @@ def build_master_plan(session, run, *, lock_targets=False):
|
||||
action, reason = "CONFLICT", "TARGET_MISSING"
|
||||
elif values:
|
||||
if target:
|
||||
values = _protected_update(entity, values)
|
||||
values = _protected_update(entity, values, target)
|
||||
action = (
|
||||
"UPDATE"
|
||||
if any(getattr(target, k) != v for k, v in values.items())
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""批次读取进度元数据;只暴露类别和数量,不携带业务原始载荷。"""
|
||||
import copy
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
|
||||
ENTITIES = frozenset(mapping.entity_type for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS))
|
||||
|
||||
|
||||
def write(batch, *, phase=None, totals=None, active=None, read=None, done=None, failed=None):
|
||||
report = copy.deepcopy(batch.audit_result_json or {})
|
||||
value = report.setdefault("progress", {"phase": "COUNTING", "entities": {}})
|
||||
if phase:
|
||||
value["phase"] = phase
|
||||
if totals is not None:
|
||||
value["total_records"] = sum(totals.values())
|
||||
for entity, count in totals.items():
|
||||
value["entities"][entity] = {"total": count, "processed": 0, "status": "WAITING"}
|
||||
if active is not None:
|
||||
value["active_entity"] = active
|
||||
value["entities"].setdefault(active, {"total": None, "processed": 0})["status"] = "READING"
|
||||
if read is not None and value.get("active_entity"):
|
||||
value["entities"][value["active_entity"]]["processed"] = read
|
||||
if done:
|
||||
value["entities"][done]["status"] = "COMPLETED"
|
||||
if failed:
|
||||
value["entities"].setdefault(failed, {"total": None, "processed": 0})["status"] = "FAILED"
|
||||
value["updated_at"] = datetime.now().isoformat()
|
||||
batch.audit_result_json = report
|
||||
|
||||
|
||||
def public(batch, report=None):
|
||||
"""旧任务总量未知时不给百分比;成功必须以批次终态为准。"""
|
||||
report = report or batch.__dict__.get("audit_result_json") or {}
|
||||
value = report.get("progress") or {}
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
total = value.get("total_records")
|
||||
total = total if isinstance(total, int) and not isinstance(total, bool) and total >= 0 else None
|
||||
processed = batch.source_record_count
|
||||
status = batch.status
|
||||
percentage = None
|
||||
if status == "SUCCEEDED":
|
||||
total, percentage = processed, 100.0
|
||||
elif total and processed >= 0:
|
||||
percentage = min(99.9, round(processed * 100 / total, 1))
|
||||
entities = []
|
||||
observed = report.get("observed_entities", [])
|
||||
for entity in batch.entity_types_json or []:
|
||||
if not isinstance(entity, str) or entity not in ENTITIES:
|
||||
continue
|
||||
details = value.get("entities", {}).get(entity, {})
|
||||
count = details.get("total")
|
||||
entities.append({"entity_type": entity, "total_records": count,
|
||||
"processed_records": details.get("processed"),
|
||||
"status": details.get("status", "COMPLETED" if entity in observed else "WAITING")})
|
||||
updated = value.get("updated_at")
|
||||
try:
|
||||
updated = datetime.fromisoformat(updated) if updated else None
|
||||
except (ValueError, TypeError):
|
||||
updated = None
|
||||
elapsed = max(0, int(((batch.finished_at or datetime.now()) - batch.started_at).total_seconds())) if batch.started_at else 0
|
||||
phase = "COMPLETED" if status == "SUCCEEDED" else "FAILED" if status in ("FAILED", "PARTIAL") else value.get("phase", status)
|
||||
return {"phase": phase, "total_records": total, "processed_records": processed,
|
||||
"percentage": percentage, "active_entity": value.get("active_entity")
|
||||
if value.get("active_entity") in ENTITIES else None,
|
||||
"updated_at": updated, "elapsed_seconds": elapsed, "entities": entities}
|
||||
@@ -10,7 +10,7 @@ from app.schemas.tplus import (
|
||||
TPlusErrorOut,
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus import progress, task_service
|
||||
from app.services.tplus.automation import overview as automation_overview
|
||||
from app.services.tplus.errors import MESSAGES
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
@@ -27,7 +27,7 @@ _ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS
|
||||
ERROR_MESSAGES.update(MESSAGES)
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
def batch_out(batch, active_parents: set[str], report=None) -> TPlusBatchOut:
|
||||
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
|
||||
entities = batch.entity_types_json
|
||||
scope_valid = (
|
||||
@@ -56,6 +56,7 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
else None,
|
||||
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
|
||||
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
|
||||
progress=progress.public(batch, report),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ def get_batch(session, number: str) -> TPlusBatchDetailOut:
|
||||
out = batch_out(batch, repository.active_retry_parents(session, [number]))
|
||||
return TPlusBatchDetailOut(
|
||||
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
|
||||
if batch.sync_mode == "AUDIT" else None
|
||||
)
|
||||
|
||||
|
||||
@@ -74,8 +76,9 @@ def list_batches(session, query):
|
||||
"""分页批次并批量查询重试状态。"""
|
||||
rows, total = repository.list_batches(session, query)
|
||||
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
|
||||
reports = repository.progress_reports(session, [row.batch_no for row in rows])
|
||||
return PageResult[TPlusBatchOut](
|
||||
records=[batch_out(row, active) for row in rows],
|
||||
records=[batch_out(row, active, reports.get(row.batch_no)) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@@ -112,17 +115,25 @@ def list_errors(session, number, query):
|
||||
|
||||
def overview(session) -> TPlusOverviewOut:
|
||||
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
|
||||
settings = task_service.get_settings()
|
||||
try:
|
||||
resolve_tplus_settings(task_service.get_settings())
|
||||
resolve_tplus_settings(settings)
|
||||
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
|
||||
except RuntimeError:
|
||||
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
|
||||
configured = False
|
||||
message = (
|
||||
"源读取未开启,仍可查看已有采集记录和资料"
|
||||
if not settings.t_plus_enabled
|
||||
else "T+ 只读配置未就绪,请联系管理员配置"
|
||||
)
|
||||
latest = repository.latest_audit(session)
|
||||
current = repository.current_batch(session)
|
||||
return TPlusOverviewOut(
|
||||
is_configured=configured,
|
||||
configuration_message=message,
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
current_batch=batch_out(current, set()) if current else None,
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
automation=automation_overview(session, settings=task_service.get_settings()),
|
||||
)
|
||||
@@ -34,6 +34,20 @@ class TPlusReader:
|
||||
def _default_query_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "db" / "queries" / "tplus"
|
||||
|
||||
def count_entities(self, entity_types) -> dict[str, int]:
|
||||
"""只统计选定的白名单范围,保留读取 SQL 的同一源过滤语义。"""
|
||||
entities = tuple(dict.fromkeys(entity_types))
|
||||
for entity in entities:
|
||||
get_mapping(entity)
|
||||
if not entities:
|
||||
return {}
|
||||
statement = text((self.query_dir / "progress" / "counts.sql").read_text(encoding="utf-8")).bindparams(
|
||||
bindparam("entities", expanding=True)
|
||||
)
|
||||
return {row.entity_type: int(row.total_records) for row in self.session.execute(
|
||||
statement, {"entities": entities}
|
||||
)}
|
||||
|
||||
def iter_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""T+ 只读快照同步:批量 staging、幂等去重和失败隔离。"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from itertools import batched
|
||||
@@ -11,6 +12,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import get_settings
|
||||
from app.core.tplus_lock import sync_lock
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
from app.services.tplus import progress
|
||||
from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error
|
||||
from app.services.tplus.mapping import TPlusMapping, get_mapping
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
@@ -18,6 +20,8 @@ from app.services.tplus.source_heads import observe_rows
|
||||
from app.services.tplus.staging import stage_rows
|
||||
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TPlusSyncService:
|
||||
"""按分页提交原始快照,业务表始终不参与本服务。"""
|
||||
@@ -76,6 +80,17 @@ class TPlusSyncService:
|
||||
if batch.status != "RUNNING" or not batch.entity_types_json:
|
||||
raise ValueError("任务尚未领取或读取范围不完整")
|
||||
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
|
||||
progress.write(batch, phase="COUNTING")
|
||||
self.target_session.commit()
|
||||
try:
|
||||
totals = self.reader.count_entities(batch.entity_types_json)
|
||||
if isinstance(totals, dict) and all(isinstance(v, int) and v >= 0 for v in totals.values()) and set(totals) == set(batch.entity_types_json):
|
||||
progress.write(batch, phase="READING", totals=totals)
|
||||
except Exception as exc:
|
||||
# 总量统计不可用时仍继续读源;页面显示未知总量,不伪造百分比。
|
||||
self.source_session.rollback()
|
||||
logger.warning("T+总量统计暂不可用,继续读取(%s)", type(exc).__name__)
|
||||
self.target_session.commit()
|
||||
interrupted = False
|
||||
for mapping in mappings:
|
||||
try:
|
||||
@@ -84,6 +99,7 @@ class TPlusSyncService:
|
||||
self.target_session.rollback()
|
||||
code = classify_source_error(exc)
|
||||
self._record_error(batch, mapping, {}, code, type(exc).__name__)
|
||||
progress.write(batch, failed=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
if code in TRANSIENT_CODES:
|
||||
interrupted = True
|
||||
@@ -105,17 +121,23 @@ class TPlusSyncService:
|
||||
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
|
||||
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
||||
batch.finished_at = datetime.now()
|
||||
progress.write(batch, phase="COMPLETED" if batch.status == "SUCCEEDED" else "FAILED")
|
||||
batch.watermark_to = None
|
||||
self.target_session.commit()
|
||||
return batch
|
||||
|
||||
def _read_entity(self, batch: TPlusSyncBatch, mapping: TPlusMapping, size: int) -> None:
|
||||
"""批量写入一类数据;每页提交后即使进程中断也可幂等重跑。"""
|
||||
progress.write(batch, phase="READING", active=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
read = 0
|
||||
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
|
||||
for chunk in batched(source, size):
|
||||
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
|
||||
observe_rows(self.target_session, batch, mapping, list(chunk))
|
||||
batch.source_record_count += len(chunk)
|
||||
read += len(chunk)
|
||||
progress.write(batch, read=read)
|
||||
batch.staged_record_count += inserted
|
||||
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
||||
for row, reason in errors:
|
||||
@@ -127,6 +149,7 @@ class TPlusSyncService:
|
||||
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
|
||||
)
|
||||
batch.audit_result_json = report
|
||||
progress.write(batch, done=mapping.entity_type)
|
||||
self.target_session.commit()
|
||||
|
||||
def _record_error(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__
|
||||
)
|
||||
|
||||
@@ -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("任务排队后的源账套配置已改变")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user