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
@@ -58,6 +58,10 @@ class UserBrief(BaseModel):
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
can_write_payroll: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
authorization_revision: int = 0
|
||||
is_payroll_maker: bool = False
|
||||
maker_required_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""授权请求版本化,禁止匿名覆盖他人的修改。"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PermissionOverrideIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_code: str = Field(min_length=1, max_length=128)
|
||||
effect: Literal["ALLOW", "DENY"]
|
||||
|
||||
|
||||
class AuthorizationPreviewIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
role_codes: list[str] = Field(default_factory=list, max_length=100)
|
||||
# 保留空字段兼容旧客户端;不再允许个人允许/禁止覆盖角色授权。
|
||||
overrides: list[PermissionOverrideIn] = Field(default_factory=list, max_length=0)
|
||||
|
||||
|
||||
class UserAuthorizationIn(AuthorizationPreviewIn):
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def reason_required(cls, value):
|
||||
if not value.strip():
|
||||
raise ValueError("修改原因不能为空")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class RolePermissionsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission_codes: list[str] = Field(max_length=300)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
|
||||
|
||||
class AuthorizationSettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
maker_user_id: int | None = Field(default=None, ge=1)
|
||||
expected_revision: int = Field(ge=1)
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
|
||||
@@ -107,6 +107,15 @@ class BusinessItemIn(BaseModel):
|
||||
return _check_amount(value, "金额", AMOUNT_MAX)
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _derive_guided_cost(self):
|
||||
from app.services.cost_rules import guided_total
|
||||
|
||||
total = guided_total(self.quantity, self.guided_price, self.cost_amount)
|
||||
self.cost_amount = str(total) if total is not None else None
|
||||
return self
|
||||
|
||||
|
||||
class BusinessItemOut(BaseModel):
|
||||
"""业务产品明细行响应。金额/数量一律 str。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""计提公式模板的请求/响应模型。"""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# 模板编码与历史 TPL_ 编码一致:大写字母开头,大写字母/数字/下划线,3–32 位。
|
||||
TEMPLATE_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,31}$")
|
||||
|
||||
|
||||
class TemplateParamOut(BaseModel):
|
||||
"""模板骨架参数角色映射(角色 → 参数编码)。"""
|
||||
|
||||
role: str
|
||||
param_code: str
|
||||
|
||||
|
||||
class CalcTemplateOut(BaseModel):
|
||||
"""模板列表/详情响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
template_code: str
|
||||
template_name: str
|
||||
formula_type: str
|
||||
params: dict[str, str]
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
status: int = 1
|
||||
is_system: int = 0
|
||||
role_count: int = 0
|
||||
|
||||
|
||||
class CalcTemplateOptionOut(BaseModel):
|
||||
"""岗位表单下拉项(仅启用模板,含骨架与参数绑定说明)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
formula_type: str
|
||||
description: str
|
||||
params: list[TemplateParamOut]
|
||||
|
||||
|
||||
class FormulaOptionOut(BaseModel):
|
||||
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
roles: list[str]
|
||||
|
||||
|
||||
class CalcTemplateCreateIn(BaseModel):
|
||||
"""新增模板。编码与骨架创建后不可修改。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_code: str = Field(min_length=3, max_length=32, description="模板编码,创建后不可修改")
|
||||
template_name: str = Field(min_length=1, max_length=64, description="模板名称")
|
||||
formula_type: str = Field(min_length=1, max_length=32, description="公式骨架类型")
|
||||
params: dict[str, str] = Field(default_factory=dict, description="骨架参数角色到参数编码映射")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("template_code")
|
||||
@classmethod
|
||||
def _check_code(cls, value: str) -> str:
|
||||
if not TEMPLATE_CODE_PATTERN.match(value):
|
||||
raise ValueError("模板编码须以大写字母开头,仅可含大写字母、数字与下划线")
|
||||
return value
|
||||
|
||||
|
||||
class CalcTemplateUpdateIn(BaseModel):
|
||||
"""编辑模板。编码与骨架锁定;参数绑定仍按骨架角色校验。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
template_name: str = Field(min_length=1, max_length=64)
|
||||
params: dict[str, str] = Field(default_factory=dict)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""系数表与 Excel 导入共用的边界模型。"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CoefficientRowIn(BaseModel):
|
||||
product_category: Literal["municipal_water", "rail_transit"]
|
||||
customer_type: Literal["personal", "enterprise"]
|
||||
stage: Literal["first", "linear", "after", "fixed"]
|
||||
value: str = Field(min_length=1, max_length=64)
|
||||
slope: str | None = Field(default=None, max_length=64)
|
||||
boundary: int | None = Field(default=None, ge=1, le=3660)
|
||||
|
||||
|
||||
class CoefficientChangeIn(BaseModel):
|
||||
effective_date: date
|
||||
base_date: date | None = None
|
||||
remark: str = Field(default="系数表维护", max_length=255)
|
||||
fingerprint: str = Field(min_length=64, max_length=64)
|
||||
rows: list[CoefficientRowIn] = Field(min_length=1, max_length=16)
|
||||
@@ -9,8 +9,7 @@
|
||||
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
|
||||
|
||||
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
|
||||
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
|
||||
不返回伪造的 0 条。
|
||||
同步记录、读取开关和自动采集开关分别返回,不用当前开关覆盖历史采集结果。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@@ -80,7 +79,7 @@ class TodoBlock(BaseModel):
|
||||
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
|
||||
unassigned_receipt_total: int = 0
|
||||
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
|
||||
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
|
||||
sync_available: bool = Field(default=False, description="同步记录是否可查询")
|
||||
sync_failure_total: int = 0
|
||||
|
||||
|
||||
@@ -154,6 +153,17 @@ class ActivityPageOut(BaseModel):
|
||||
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
|
||||
|
||||
|
||||
class SyncBlock(BaseModel):
|
||||
"""源采集历史与当前运行配置,仅公开汇总状态。"""
|
||||
|
||||
available: bool = False
|
||||
enabled: bool = False
|
||||
automation_enabled: bool = False
|
||||
latest_status: str | None = None
|
||||
last_success_at: datetime | None = None
|
||||
historical_failure_total: int = 0
|
||||
|
||||
|
||||
class DashboardOut(BaseModel):
|
||||
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
|
||||
|
||||
@@ -161,3 +171,4 @@ class DashboardOut(BaseModel):
|
||||
overview: OverviewBlock
|
||||
todos: TodoBlock
|
||||
warnings: WarningBlock
|
||||
sync: SyncBlock = Field(default_factory=SyncBlock)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""监控接口只暴露固定白名单,不传输内部异常或连接配置。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
Status = Literal["normal", "warning", "error", "unknown"]
|
||||
Hours = Annotated[
|
||||
Literal[1, 24, 168],
|
||||
BeforeValidator(lambda value: int(value) if value in ("1", "24", "168") else value),
|
||||
]
|
||||
|
||||
|
||||
class MonitorWindow(BaseModel):
|
||||
hours: Hours = 1
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class EventQuery(PageQuery):
|
||||
hours: Hours = 24
|
||||
end: datetime | None = None
|
||||
route: str | None = Field(default=None, max_length=256)
|
||||
kind: Literal["HEALTH", "REQUEST_ERROR", "SLOW_REQUEST", "RUNTIME_ERROR"] | None = None
|
||||
request_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
active_only: bool = False
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
instance_id: str
|
||||
kind: str
|
||||
code: str
|
||||
component: str
|
||||
level: str
|
||||
message: str
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
route: str | None = None
|
||||
status_code: int | None = None
|
||||
duration_ms: float | None = None
|
||||
batch_no: str | None = None
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
occurrences: int
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class DependencyOut(BaseModel):
|
||||
name: str
|
||||
status: Status
|
||||
message: str
|
||||
checked_at: datetime | None = None
|
||||
duration_ms: float | None = None
|
||||
|
||||
|
||||
class OverviewOut(BaseModel):
|
||||
status: Status
|
||||
environment: str
|
||||
version: str
|
||||
instance_id: str
|
||||
uptime_seconds: int
|
||||
collected_at: datetime | None
|
||||
last_persisted_at: datetime | None
|
||||
collection_status: Status
|
||||
collection_message: str
|
||||
resources: dict
|
||||
requests: dict
|
||||
dependencies: list[DependencyOut]
|
||||
alerts: list[EventOut]
|
||||
tasks: dict
|
||||
dropped_events: int
|
||||
retention_days: int = 7
|
||||
@@ -191,8 +191,6 @@ class ParamPreviewIn(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_dates(self) -> "ParamPreviewIn":
|
||||
if self.receipt_date < self.ship_date:
|
||||
raise ValueError("回款到账日不得早于发货日")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
@@ -46,6 +46,9 @@ class PersonnelOut(BaseModel):
|
||||
manager_name: str | None = None
|
||||
phone: str | None = None
|
||||
hire_date: date | None = None
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = 0
|
||||
salary_standard: str | None = None
|
||||
status: int = 1
|
||||
@@ -53,6 +56,7 @@ class PersonnelOut(BaseModel):
|
||||
|
||||
|
||||
class PersonnelDetailOut(PersonnelOut):
|
||||
salary_history_json: list[dict] | None = None
|
||||
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
|
||||
|
||||
regions: list[PersonnelRegionOut] = Field(default_factory=list)
|
||||
@@ -60,6 +64,7 @@ class PersonnelDetailOut(PersonnelOut):
|
||||
|
||||
|
||||
class PersonnelCreateIn(BaseModel):
|
||||
salary_effective_date: date | None = None
|
||||
"""新增人员。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
@@ -69,6 +74,9 @@ class PersonnelCreateIn(BaseModel):
|
||||
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
|
||||
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
|
||||
probation_start_date: date | None = None
|
||||
regularization_date: date | None = None
|
||||
resignation_date: date | None = None
|
||||
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
|
||||
salary_standard: str | None = Field(
|
||||
default=None, description="月工资标准(元),字符串形式的数字"
|
||||
@@ -100,6 +108,13 @@ class PersonnelCreateIn(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_probation_dates(self):
|
||||
if self.probation_start_date and self.regularization_date and self.regularization_date <= self.probation_start_date:
|
||||
raise ValueError("转正生效日必须晚于试用期开始日")
|
||||
return self
|
||||
|
||||
|
||||
class PersonnelUpdateIn(PersonnelCreateIn):
|
||||
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
|
||||
|
||||
@@ -108,6 +123,7 @@ class PersonnelStatusIn(BaseModel):
|
||||
"""在职/离职切换。"""
|
||||
|
||||
status: int = Field(ge=0, le=1, description="1在职 0离职")
|
||||
effective_date: date | None = None
|
||||
|
||||
|
||||
class RegionItemIn(BaseModel):
|
||||
|
||||
@@ -28,6 +28,8 @@ class ProductOut(BaseModel):
|
||||
remark: str | None = None
|
||||
create_time: datetime
|
||||
update_time: datetime
|
||||
source_trade_json: dict | None = None
|
||||
quantity_price_manual: int = 0
|
||||
|
||||
|
||||
class ProductCreateIn(BaseModel):
|
||||
@@ -95,3 +97,15 @@ class ProductQuery(PageQuery):
|
||||
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
|
||||
|
||||
class ProductExtractIn(BaseModel):
|
||||
item_ids: list[int] = Field(min_length=1, max_length=200)
|
||||
fingerprint: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
@field_validator("item_ids")
|
||||
@classmethod
|
||||
def _ids(cls, value):
|
||||
if any(v <= 0 for v in value) or len(value) != len(set(value)):
|
||||
raise ValueError("明细ID必须为不重复的正整数")
|
||||
return value
|
||||
@@ -284,6 +284,7 @@ class DepartmentSummaryOut(BaseModel):
|
||||
"""部门绩效工资汇总响应(FR-030)。"""
|
||||
|
||||
settlement_month: str
|
||||
salary_rows: list[dict] = Field(default_factory=list, description="已生成工资的基本工资、正常绩效、缓发及年度补发快照")
|
||||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||||
unassigned: list[DepartmentMemberRow] = Field(
|
||||
|
||||
@@ -33,6 +33,7 @@ class RoleManageOut(RoleOut):
|
||||
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
|
||||
|
||||
user_count: int = 0
|
||||
permission_count: int = 0
|
||||
|
||||
|
||||
class RoleCreateIn(BaseModel):
|
||||
@@ -44,6 +45,8 @@ class RoleCreateIn(BaseModel):
|
||||
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
|
||||
permission_codes: list[str] | None = Field(default=None, max_length=300)
|
||||
reason: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
|
||||
@field_validator("role_code")
|
||||
@classmethod
|
||||
|
||||
@@ -48,6 +48,10 @@ class SettlementItemOut(BaseModel):
|
||||
net_amount: Decimal | None = None
|
||||
is_probation: int
|
||||
probation_note: str | None = None
|
||||
normal_payable_sum: Decimal = Decimal("0")
|
||||
deferred_sum: Decimal = Decimal("0")
|
||||
probation_payment_sum: Decimal = Decimal("0")
|
||||
snapshot_json: dict | None = None
|
||||
|
||||
|
||||
class SettlementOut(BaseModel):
|
||||
|
||||
@@ -60,6 +60,24 @@ class TPlusErrorQuery(PageQuery):
|
||||
entity_type: EntityType | None = None
|
||||
|
||||
|
||||
class TPlusEntityProgress(BaseModel):
|
||||
entity_type: str
|
||||
total_records: int | None = None
|
||||
processed_records: int | None = None
|
||||
status: str = "WAITING"
|
||||
|
||||
|
||||
class TPlusProgressOut(BaseModel):
|
||||
phase: str
|
||||
total_records: int | None = None
|
||||
processed_records: int = 0
|
||||
percentage: float | None = None
|
||||
active_entity: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
elapsed_seconds: int = 0
|
||||
entities: list[TPlusEntityProgress] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TPlusBatchOut(BaseModel):
|
||||
"""批次列表不包含原始数据、源配置或数据库异常。"""
|
||||
|
||||
@@ -79,6 +97,7 @@ class TPlusBatchOut(BaseModel):
|
||||
error_summary: str | None = None
|
||||
trigger_type: str = "MANUAL"
|
||||
automatic_attempt: int = 0
|
||||
progress: TPlusProgressOut | None = None
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
@@ -105,8 +124,16 @@ class TPlusAutomationAlert(BaseModel):
|
||||
batch_no: str | None = None
|
||||
|
||||
|
||||
class TPlusAutomationUpdate(BaseModel):
|
||||
enabled: bool = Field(strict=True)
|
||||
expected_version: int = Field(ge=0, strict=True)
|
||||
|
||||
|
||||
class TPlusAutomationOverview(BaseModel):
|
||||
enabled: bool = False
|
||||
version: int = 0
|
||||
editable: bool = False
|
||||
configuration_origin: str = "ENVIRONMENT"
|
||||
weekday: int = 0
|
||||
time: str = "02:00"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
@@ -125,6 +152,7 @@ class TPlusOverviewOut(BaseModel):
|
||||
configuration_message: str
|
||||
status_counts: dict[str, int]
|
||||
total_staged_records: int = 0
|
||||
current_batch: TPlusBatchOut | None = None
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
|
||||
Reference in new issue
Block a user