Compare commits
10
Commits
409e26614f
...
74f1ef1cf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f1ef1cf9 | ||
|
|
8ef26e22fb | ||
|
|
680661a80b | ||
|
|
efed29647c | ||
|
|
e95dc4b2de | ||
|
|
acf87fdfdc | ||
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 |
No files matched your search
@@ -59,3 +59,8 @@ frontend/e2e/*.png
|
||||
backup/
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
@@ -0,0 +1,436 @@
|
||||
"""V6 系统设置/二次分配/审批表与角色参数种子
|
||||
|
||||
Revision ID: 79bcc5aa98d4
|
||||
Revises: 8efc189b131f
|
||||
Create Date: 2026-10-15 15:42:13.467447
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "79bcc5aa98d4"
|
||||
down_revision: str | Sequence[str] | None = "8efc189b131f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移。"""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"company_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="公司名称(登录页与导出页眉展示)",
|
||||
),
|
||||
sa.Column(
|
||||
"logo_path",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_system_settings")),
|
||||
comment="系统设置表(公司信息,全系统单行)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"approval_orders",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"order_no",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||
),
|
||||
sa.Column(
|
||||
"order_type",
|
||||
mysql.TINYINT(),
|
||||
nullable=False,
|
||||
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||
),
|
||||
sa.Column(
|
||||
"ref_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||
),
|
||||
sa.Column(
|
||||
"refer_no",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="引用对象单号(冗余固化,列表页免联查)",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||
),
|
||||
sa.Column(
|
||||
"submitter_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="提交人ID(账号删除后置空)",
|
||||
),
|
||||
sa.Column(
|
||||
"submitter_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="提交人用户名(冗余固化)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["submitter_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_approval_orders_submitter_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_orders")),
|
||||
sa.UniqueConstraint("order_no", name=op.f("uk_approval_orders_order_no")),
|
||||
comment="审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_approval_orders_type_status", "approval_orders", ["order_type", "status"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"approval_steps",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"order_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||
),
|
||||
sa.Column(
|
||||
"node",
|
||||
mysql.TINYINT(),
|
||||
nullable=False,
|
||||
comment="审批节点:0撤回留痕 1财务复核 2领导审核",
|
||||
),
|
||||
sa.Column(
|
||||
"action", mysql.TINYINT(), nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||
),
|
||||
sa.Column(
|
||||
"opinion", sa.String(length=255), nullable=True, comment="审批意见(驳回时填写)"
|
||||
),
|
||||
sa.Column(
|
||||
"operator_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="操作人ID(自动通过/系统动作置空)",
|
||||
),
|
||||
sa.Column(
|
||||
"operator_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="操作人用户名(冗余固化;系统动作记「系统」)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["operator_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_approval_steps_operator_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["order_id"],
|
||||
["approval_orders.id"],
|
||||
name=op.f("fk_approval_steps_order_id"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_steps")),
|
||||
comment="审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index("idx_approval_steps_order_id", "approval_steps", ["order_id"], unique=False)
|
||||
op.create_table(
|
||||
"business_secondary_allocations",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"business_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="业务ID(businesses.id;业务删除随删)",
|
||||
),
|
||||
sa.Column(
|
||||
"personnel_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||
),
|
||||
sa.Column(
|
||||
"personnel_name",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="参与业务员姓名(冗余固化,离职后回显有名)",
|
||||
),
|
||||
sa.Column(
|
||||
"ratio",
|
||||
sa.Numeric(precision=5, scale=2),
|
||||
nullable=False,
|
||||
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["business_id"],
|
||||
["businesses.id"],
|
||||
name=op.f("fk_business_secondary_allocations_business_id"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["personnel_id"],
|
||||
["personnel.id"],
|
||||
name=op.f("fk_business_secondary_allocations_personnel_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
|
||||
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_business_secondary_allocations_business_id",
|
||||
"business_secondary_allocations",
|
||||
["business_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_business_secondary_allocations_personnel_id",
|
||||
"business_secondary_allocations",
|
||||
["personnel_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# ---- 种子数据(生产与开发同源;写法沿用 V1/V3 迁移先例)----
|
||||
|
||||
# 三个审批职能登录角色(2026-10-15 复核审批补充需求;roles 表无 is_builtin
|
||||
# 列,内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"roles",
|
||||
sa.column("role_code", sa.String),
|
||||
sa.column("role_name", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"role_code": "marketing_ops",
|
||||
"role_name": "营销后勤",
|
||||
"description": "提交与录入(审批链第一节点:月结/二次分配提交)",
|
||||
"sort_order": 3,
|
||||
},
|
||||
{
|
||||
"role_code": "finance",
|
||||
"role_name": "财务",
|
||||
"description": "复核(审批链第二节点)",
|
||||
"sort_order": 4,
|
||||
},
|
||||
{
|
||||
"role_code": "leader",
|
||||
"role_name": "领导",
|
||||
"description": "审核(审批链第三节点;全览只读)",
|
||||
"sort_order": 5,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# 审批开关参数定义(param_definitions id 沿用 V3 种子的显式 id 方案接续 17/18)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_definitions",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_code", sa.String),
|
||||
sa.column("param_name", sa.String),
|
||||
sa.column("param_group", sa.String),
|
||||
sa.column("value_type", sa.String),
|
||||
sa.column("unit", sa.String),
|
||||
sa.column("dimension_type", sa.String),
|
||||
sa.column("options_text", sa.String),
|
||||
sa.column("default_value", sa.String),
|
||||
sa.column("value_min", sa.String),
|
||||
sa.column("value_max", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
sa.column("status", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 17,
|
||||
"param_code": "approval.secondary_allocation.enabled",
|
||||
"param_name": "二次分配审批开关",
|
||||
"param_group": "审批开关",
|
||||
"value_type": "BOOL",
|
||||
"unit": None,
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "0",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": (
|
||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||||
),
|
||||
"sort_order": 17,
|
||||
"status": 1,
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"param_code": "approval.monthly_settlement.enabled",
|
||||
"param_name": "月度结算审批开关",
|
||||
"param_group": "审批开关",
|
||||
"value_type": "BOOL",
|
||||
"unit": None,
|
||||
"dimension_type": "NONE",
|
||||
"options_text": None,
|
||||
"default_value": "0",
|
||||
"value_min": None,
|
||||
"value_max": None,
|
||||
"description": (
|
||||
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||||
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||||
),
|
||||
"sort_order": 18,
|
||||
"status": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# 审批开关联动批次条目(batch_id=1 即 V3 的「初始版本」批次;BOOL 无维度行,
|
||||
# 生效日期 2026-01-01 对齐其他支付环节种子)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"param_values",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("param_id", sa.BigInteger),
|
||||
sa.column("dimension_value", sa.String),
|
||||
sa.column("effective_date", sa.Date),
|
||||
sa.column("value_text", sa.String),
|
||||
sa.column("batch_id", sa.BigInteger),
|
||||
),
|
||||
[
|
||||
{
|
||||
"id": 20,
|
||||
"param_id": 17,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "0",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"param_id": 18,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "0",
|
||||
"batch_id": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"system_settings",
|
||||
sa.column("id", sa.BigInteger),
|
||||
sa.column("company_name", sa.String),
|
||||
sa.column("logo_path", sa.String),
|
||||
),
|
||||
[
|
||||
{"id": 1, "company_name": "衍水集团", "logo_path": None},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移。
|
||||
|
||||
整表回滚只保留 `op.drop_table` —— DROP TABLE 会连带删除索引与外键。
|
||||
autogenerate 原样输出的逐个 `op.drop_index` 会失败:idx_approval_steps_order_id
|
||||
与 idx_business_secondary_allocations_personnel_id 分别是外键的唯一支撑索引,
|
||||
单独删除被 MySQL 拒绝(errno 1553),回滚会中断在半途。
|
||||
删除顺序:先删引用方(approval_steps/二次分配),再删被引用方。
|
||||
行级种子反向清理:roles/param_definitions/param_values 三表由 V1/V3 建、
|
||||
本迁移只插行(roles 三角色、参数 17/18 及其版本条目),drop_table
|
||||
不会带走,须显式 DELETE,否则重放 upgrade 时唯一键冲突。
|
||||
"""
|
||||
op.drop_table("business_secondary_allocations")
|
||||
op.drop_table("approval_steps")
|
||||
op.drop_table("approval_orders")
|
||||
op.drop_table("system_settings")
|
||||
op.execute("DELETE FROM param_values WHERE id IN (20, 21)")
|
||||
op.execute("DELETE FROM param_definitions WHERE id IN (17, 18)")
|
||||
op.execute("DELETE FROM roles WHERE role_code IN ('marketing_ops', 'finance', 'leader')")
|
||||
@@ -113,3 +113,22 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
||||
|
||||
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str):
|
||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||
|
||||
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||
|
||||
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||
"""
|
||||
|
||||
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
codes = set(user_repository.get_role_codes(current_user))
|
||||
if not codes & set(role_codes):
|
||||
raise ForbiddenError("无权访问审批管理")
|
||||
return current_user
|
||||
|
||||
return _checker
|
||||
@@ -15,10 +15,12 @@ from app.api.v1.business import businesses as business_businesses
|
||||
from app.api.v1.business import ratings as business_ratings
|
||||
from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import params as system_params
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import settings as system_settings
|
||||
from app.api.v1.system import users as system_users
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -36,6 +38,8 @@ system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_business_roles.router)
|
||||
system_router.include_router(system_params.router)
|
||||
system_router.include_router(system_settings.router)
|
||||
system_router.include_router(system_approvals.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
system_router.include_router(system_logs.login_router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""审批管理接口(2026-10-15 复核审批补充需求)。
|
||||
|
||||
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
|
||||
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
|
||||
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
|
||||
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.approval import (
|
||||
ApprovalActionIn,
|
||||
ApprovalOrderDetailOut,
|
||||
ApprovalOrderOut,
|
||||
ApprovalQuery,
|
||||
)
|
||||
from app.services import approval_service
|
||||
|
||||
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
|
||||
|
||||
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
|
||||
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
|
||||
def list_orders(
|
||||
session: DbSession,
|
||||
_: _ApprovalUser,
|
||||
query: Annotated[ApprovalQuery, Query()],
|
||||
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
|
||||
"""分页查询审批单。"""
|
||||
return ok(approval_service.list_orders(session, query))
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
|
||||
def get_order(
|
||||
session: DbSession,
|
||||
_: _ApprovalUser,
|
||||
order_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||
"""审批单详情(含节点时间线)。"""
|
||||
return ok(approval_service.get_order(session, order_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{order_id}/action",
|
||||
response_model=ApiResponse[ApprovalOrderDetailOut],
|
||||
summary="节点动作(通过/驳回)",
|
||||
)
|
||||
def act_on_order(
|
||||
payload: ApprovalActionIn,
|
||||
session: DbSession,
|
||||
current_user: _ApprovalUser,
|
||||
meta: RequestMetaDep,
|
||||
order_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
|
||||
return ok(
|
||||
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
|
||||
message="已提交",
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""系统设置接口(公司信息)。
|
||||
|
||||
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
|
||||
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
|
||||
仅暴露两个非敏感展示字段。
|
||||
|
||||
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
|
||||
(二进制响应套 JSON 信封无意义)。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||||
from app.services import system_setting_service as service
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
||||
|
||||
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
||||
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
|
||||
"""取公司信息(管理端)。"""
|
||||
return ok(service.to_out(service.get_setting(session)))
|
||||
|
||||
|
||||
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
|
||||
def update_settings(
|
||||
payload: SettingUpdateIn,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""更新公司名称(审计留痕)。"""
|
||||
return ok(
|
||||
service.update_company(session, payload, operator=current_user, meta=meta),
|
||||
message="已保存",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
|
||||
async def upload_logo(
|
||||
file: UploadFile,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。"""
|
||||
content = await file.read()
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||
return ok(
|
||||
service.save_logo(session, content, ext, operator=current_user, meta=meta),
|
||||
message="logo 已更新",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||||
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||||
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||
response.headers["Cache-Control"] = "public, max-age=300"
|
||||
return ok(service.to_public_out(service.get_setting(session)))
|
||||
|
||||
|
||||
@router.get("/logo/public", summary="logo 图片(匿名)")
|
||||
def get_logo_public(session: DbSession) -> FileResponse:
|
||||
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
|
||||
path, ext = service.read_logo(session)
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
@@ -92,6 +92,15 @@ class Settings(BaseSettings):
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> Path:
|
||||
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||
|
||||
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||
"""
|
||||
return BACKEND_DIR / "uploads"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
|
||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_USER = "user"
|
||||
# 2026-10-15 复核审批补充需求新增职能登录角色:营销后勤(提交+录入)、
|
||||
# 财务(复核)、领导(审核)。roles 表无 is_builtin 列,内置保护用代码
|
||||
# 白名单判定(role_service 两处内置校验改查此集合);
|
||||
# permissions/role_permissions 细粒度激活待业务权限清单(待澄清 #6)。
|
||||
ROLE_MARKETING_OPS = "marketing_ops"
|
||||
ROLE_FINANCE = "finance"
|
||||
ROLE_LEADER = "leader"
|
||||
BUILTIN_ROLE_CODES = {
|
||||
ROLE_ADMIN,
|
||||
ROLE_MARKETING_OPS,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
}
|
||||
|
||||
# ===== 密码策略 =====
|
||||
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||
@@ -70,6 +83,8 @@ MODULE_BUSINESS = "业务管理"
|
||||
MODULE_SHIPMENT = "发货管理"
|
||||
MODULE_RECEIPT = "回款管理"
|
||||
MODULE_RATING = "客户评级"
|
||||
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -77,6 +92,7 @@ NO_PREFIX_BUSINESS = "YW"
|
||||
NO_PREFIX_SHIPMENT = "SH"
|
||||
NO_PREFIX_RECEIPT = "HK"
|
||||
NO_PREFIX_PARAM = "P"
|
||||
NO_PREFIX_APPROVAL = "AP"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
@@ -84,3 +100,5 @@ ACTION_DELETE = "删除"
|
||||
ACTION_RESET_PASSWORD = "重置密码"
|
||||
ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
@@ -9,8 +9,9 @@
|
||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||
"""
|
||||
|
||||
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.business import Business, BusinessItem, BusinessStatusLog
|
||||
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
|
||||
from app.models.business_role import BusinessRole
|
||||
from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
@@ -22,9 +23,12 @@ from app.models.rating import CustomerRatingGrade
|
||||
from app.models.receipt import Receipt
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"Base",
|
||||
"Business",
|
||||
"BusinessItem",
|
||||
@@ -46,8 +50,10 @@ __all__ = [
|
||||
"Receipt",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"SecondaryAllocation",
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""审批模型:审批单与节点记录(2026-10-15 复核审批补充需求)。
|
||||
|
||||
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数体系开关驱动
|
||||
(`approval.*.enabled` BOOL):关闭=提交即生成「自动通过」节点留痕,
|
||||
留痕不断链(开发计划 3.2(三));开启=按节点人工审核,一驳全驳。
|
||||
|
||||
依赖说明:节点准入按登录角色编码判定(ROLE_FINANCE/ROLE_LEADER),
|
||||
不动 `permissions`/`role_permissions` 闲置表 —— 细粒度权限清单待业务方
|
||||
答复(待澄清 #6)后再激活。
|
||||
|
||||
ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=2
|
||||
(月结预留)将指向 ④ 尚未落地的表,外键无法统一指向;单据删除保护由
|
||||
服务层承担(详见 approval_service)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
# 审批单状态(approval_orders.status;流转机见 approval_service)
|
||||
APPROVAL_STATUS_PENDING_FINANCE = 1 # 待财务复核
|
||||
APPROVAL_STATUS_PENDING_LEADER = 2 # 待领导审核
|
||||
APPROVAL_STATUS_APPROVED = 3 # 已通过(终态)
|
||||
APPROVAL_STATUS_REJECTED = 4 # 已驳回(终态)
|
||||
APPROVAL_STATUS_WITHDRAWN = 5 # 已撤回(被引用方重新提交时旧在途单注销)
|
||||
|
||||
# 节点(approval_steps.node;0 仅用于撤回留痕)
|
||||
APPROVAL_NODE_FINANCE = 1
|
||||
APPROVAL_NODE_LEADER = 2
|
||||
APPROVAL_NODE_WITHDRAW = 0
|
||||
APPROVAL_NODES = {
|
||||
APPROVAL_STATUS_PENDING_FINANCE: APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER: APPROVAL_NODE_LEADER,
|
||||
}
|
||||
|
||||
# 动作(approval_steps.action)
|
||||
APPROVAL_ACTION_APPROVE = 1 # 人工通过
|
||||
APPROVAL_ACTION_REJECT = 2 # 人工驳回
|
||||
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
||||
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
||||
|
||||
# 审批对象类型(approval_orders.order_type;2 为 ④ 月结预留)
|
||||
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||
|
||||
|
||||
class ApprovalOrder(Base, TimestampMixin):
|
||||
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
|
||||
|
||||
__tablename__ = "approval_orders"
|
||||
__table_args__ = mysql_table_args(
|
||||
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||
)
|
||||
order_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算(预留)"
|
||||
)
|
||||
ref_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
comment="引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)",
|
||||
)
|
||||
refer_no: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回",
|
||||
)
|
||||
submitter_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
comment="提交人ID(账号删除后置空)",
|
||||
)
|
||||
submitter_name: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="提交人用户名(冗余固化)"
|
||||
)
|
||||
|
||||
|
||||
class ApprovalStep(Base):
|
||||
"""审批节点记录表(append-only):每次动作只增不改,构成时间线。
|
||||
|
||||
不继承 TimestampMixin(留痕无更新语义,同 business_status_logs 先例)。
|
||||
"""
|
||||
|
||||
__tablename__ = "approval_steps"
|
||||
__table_args__ = mysql_table_args(
|
||||
"审批节点记录表(append-only;自动通过/撤回均有留痕)",
|
||||
Index("idx_approval_steps_order_id", "order_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
order_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("approval_orders.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="审批单ID(approval_orders.id;单删记录随删)",
|
||||
)
|
||||
node: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="审批节点:0撤回留痕 1财务复核 2领导审核"
|
||||
)
|
||||
action: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="动作:1通过 2驳回 3自动通过 4撤回"
|
||||
)
|
||||
opinion: Mapped[str | None] = mapped_column(String(255), comment="审批意见(驳回时填写)")
|
||||
operator_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
comment="操作人ID(自动通过/系统动作置空)",
|
||||
)
|
||||
operator_name: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="操作人用户名(冗余固化;系统动作记「系统」)"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
@@ -33,6 +33,58 @@ BUSINESS_STATUS_DONE = 2
|
||||
BUSINESS_STATUS_TERMINATED = 3
|
||||
|
||||
|
||||
class SecondaryAllocation(Base, TimestampMixin):
|
||||
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
|
||||
|
||||
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
|
||||
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
|
||||
终态门槛先行承担「修改不影响已结算月份」)
|
||||
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||||
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||||
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||||
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
||||
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
||||
|
||||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||||
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
||||
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
||||
"""
|
||||
|
||||
__tablename__ = "business_secondary_allocations"
|
||||
__table_args__ = mysql_table_args(
|
||||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||||
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
business_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="业务ID(businesses.id;业务删除随删)",
|
||||
)
|
||||
personnel_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||||
)
|
||||
personnel_name: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
|
||||
)
|
||||
ratio: Mapped[Decimal] = mapped_column(
|
||||
Numeric(5, 2),
|
||||
nullable=False,
|
||||
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||||
)
|
||||
|
||||
|
||||
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ from app.models.base import (
|
||||
mysql_table_args,
|
||||
)
|
||||
|
||||
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
|
||||
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
|
||||
PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||
"""营销人员表(需求 2.1.1)。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""系统设置模型:公司信息单例行。
|
||||
|
||||
与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期
|
||||
时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列
|
||||
value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在
|
||||
`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见
|
||||
system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。
|
||||
"""
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class SystemSetting(Base, TimestampMixin):
|
||||
"""系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。"""
|
||||
|
||||
__tablename__ = "system_settings"
|
||||
__table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
company_name: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)"
|
||||
)
|
||||
logo_path: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)",
|
||||
)
|
||||
@@ -18,9 +18,11 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import ownership_log_repository
|
||||
|
||||
|
||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||
@@ -197,6 +199,35 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem])
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||||
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(SecondaryAllocation)
|
||||
.where(SecondaryAllocation.business_id == business_id)
|
||||
.order_by(SecondaryAllocation.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def replace_allocations(
|
||||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||||
) -> None:
|
||||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||||
|
||||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.flush()
|
||||
for row in rows:
|
||||
row.business_id = business_id
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
@@ -229,41 +260,28 @@ def count_status_logs(session: Session, business_id: int) -> int:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
|
||||
"""追加归属变更留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
"""追加归属变更留痕(共享实现,不 commit)。"""
|
||||
ownership_log_repository.add(session, log)
|
||||
|
||||
|
||||
def list_ownership_logs(
|
||||
session: Session, business_id: int, *, offset: int, limit: int
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。"""
|
||||
old_p = aliased(Personnel)
|
||||
new_p = aliased(Personnel)
|
||||
return list(
|
||||
session.execute(
|
||||
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name)
|
||||
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id)
|
||||
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||
OwnershipChangeLog.business_id == business_id,
|
||||
)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(OwnershipChangeLog)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||
OwnershipChangeLog.business_id == business_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
)
|
||||
@@ -4,17 +4,13 @@
|
||||
"""
|
||||
|
||||
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Customer, OwnershipChangeLog, Personnel
|
||||
from app.models.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.query_utils import fuzzy
|
||||
|
||||
# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出,
|
||||
# 人员被删除后姓名为 null,行仍完整可读
|
||||
_old_owner = aliased(Personnel)
|
||||
_new_owner = aliased(Personnel)
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
@@ -102,33 +98,25 @@ def list_ownership_logs(
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||
|
||||
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条
|
||||
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。
|
||||
"""
|
||||
stmt = (
|
||||
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name)
|
||||
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id)
|
||||
.outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
|
||||
OwnershipChangeLog.customer_id == customer_id,
|
||||
)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
|
||||
OwnershipChangeLog.customer_id == customer_id,
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _base_select() -> Select:
|
||||
@@ -186,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
return log
|
||||
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
|
||||
return ownership_log_repository.add(session, log)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
|
||||
|
||||
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
|
||||
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
|
||||
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import OwnershipChangeLog, Personnel
|
||||
|
||||
|
||||
def list_by_ref(
|
||||
session: Session,
|
||||
*,
|
||||
owner_type: int,
|
||||
ref_column,
|
||||
ref_id: int,
|
||||
offset: int,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||
|
||||
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
|
||||
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
|
||||
保证翻页稳定(与操作日志同一理由)。
|
||||
"""
|
||||
old_person = aliased(Personnel)
|
||||
new_person = aliased(Personnel)
|
||||
stmt = (
|
||||
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
|
||||
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
|
||||
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
|
||||
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
|
||||
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == owner_type,
|
||||
ref_column == ref_id,
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
return log
|
||||
@@ -0,0 +1,66 @@
|
||||
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class ApprovalStepOut(BaseModel):
|
||||
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
node: int
|
||||
action: int
|
||||
opinion: str | None = None
|
||||
operator_id: int | None = None
|
||||
operator_name: str
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ApprovalOrderOut(BaseModel):
|
||||
"""审批单列表行。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
order_no: str
|
||||
order_type: int
|
||||
ref_id: int
|
||||
refer_no: str
|
||||
status: int
|
||||
submitter_id: int | None = None
|
||||
submitter_name: str
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||
"""审批单详情:附节点时间线。"""
|
||||
|
||||
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApprovalQuery(PageQuery):
|
||||
"""审批单列表查询条件。"""
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
||||
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
||||
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
|
||||
|
||||
|
||||
class ApprovalActionIn(BaseModel):
|
||||
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
action: int = Field(ge=1, le=2, description="1通过 2驳回")
|
||||
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_opinion(self) -> "ApprovalActionIn":
|
||||
if self.action == 2 and not self.opinion:
|
||||
raise ValueError("驳回必须填写审批意见")
|
||||
return self
|
||||
@@ -151,10 +151,53 @@ class BusinessOut(BaseModel):
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class SecondaryAllocationIn(BaseModel):
|
||||
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
|
||||
|
||||
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
|
||||
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
|
||||
字符串入参、两位小数、超限拒绝不静默舍入。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
|
||||
ratio: str = Field(description="分摊比例(0-100,两位小数)")
|
||||
|
||||
@field_validator("ratio")
|
||||
@classmethod
|
||||
def _check_ratio(cls, value: str) -> str:
|
||||
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
|
||||
|
||||
|
||||
class SecondaryAllocationOut(BaseModel):
|
||||
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
ratio: str
|
||||
|
||||
|
||||
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
|
||||
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
|
||||
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
|
||||
seen: set[int] = set()
|
||||
for item in items:
|
||||
if item.personnel_id in seen:
|
||||
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
|
||||
seen.add(item.personnel_id)
|
||||
|
||||
|
||||
class BusinessDetailOut(BusinessOut):
|
||||
"""业务详情:附产品明细行。"""
|
||||
"""业务详情:附产品明细行与二次分配方案。"""
|
||||
|
||||
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
|
||||
allocation_hint: str | None = Field(
|
||||
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
|
||||
)
|
||||
|
||||
|
||||
class BusinessCreateIn(BaseModel):
|
||||
@@ -175,6 +218,9 @@ class BusinessCreateIn(BaseModel):
|
||||
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
|
||||
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
|
||||
)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -184,9 +230,14 @@ class BusinessCreateIn(BaseModel):
|
||||
raise ValueError("特殊业务必须填写说明")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_allocations(self) -> "BusinessCreateIn":
|
||||
_ensure_allocations_unique(self.secondary_allocations)
|
||||
return self
|
||||
|
||||
|
||||
class BusinessUpdateIn(BaseModel):
|
||||
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
@@ -207,6 +258,9 @@ class BusinessUpdateIn(BaseModel):
|
||||
items: list[BusinessItemIn] = Field(
|
||||
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
||||
)
|
||||
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
|
||||
)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -215,6 +269,11 @@ class BusinessUpdateIn(BaseModel):
|
||||
raise ValueError("特殊业务必须填写说明")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_allocations(self) -> "BusinessUpdateIn":
|
||||
_ensure_allocations_unique(self.secondary_allocations)
|
||||
return self
|
||||
|
||||
|
||||
class BusinessStatusIn(BaseModel):
|
||||
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
"""公司信息(管理端)。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingPublicOut(BaseModel):
|
||||
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
"""公司信息更新。"""
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
@@ -0,0 +1,330 @@
|
||||
"""审批流业务编排(2026-10-15 复核审批补充需求)。
|
||||
|
||||
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
|
||||
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
|
||||
|
||||
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
|
||||
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
|
||||
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
|
||||
即整体驳回(status=4),一期不做退回重走(简单状态机)。
|
||||
|
||||
红线:
|
||||
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
|
||||
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
|
||||
回滚不完整(见 audit_service 模块 docstring);
|
||||
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
|
||||
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
|
||||
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
|
||||
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_APPROVE,
|
||||
ACTION_REJECT,
|
||||
MODULE_APPROVAL,
|
||||
NO_PREFIX_APPROVAL,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, User
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_APPROVE,
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
APPROVAL_ACTION_WITHDRAW,
|
||||
APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_NODE_LEADER,
|
||||
APPROVAL_NODE_WITHDRAW,
|
||||
APPROVAL_NODES,
|
||||
APPROVAL_STATUS_APPROVED,
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
APPROVAL_STATUS_REJECTED,
|
||||
APPROVAL_STATUS_WITHDRAWN,
|
||||
)
|
||||
from app.repositories import user_repository
|
||||
from app.schemas.approval import (
|
||||
ApprovalActionIn,
|
||||
ApprovalOrderDetailOut,
|
||||
ApprovalOrderOut,
|
||||
ApprovalQuery,
|
||||
ApprovalStepOut,
|
||||
)
|
||||
from app.services import audit_service, param_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 开关参数编码(与 param_registry 种子同源)
|
||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
1: "approval.secondary_allocation.enabled",
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
}
|
||||
|
||||
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||
|
||||
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
|
||||
_NODE_ROLE_BY_STATUS = {
|
||||
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
|
||||
}
|
||||
|
||||
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
|
||||
|
||||
|
||||
def is_enabled(session: Session, order_type: int) -> bool:
|
||||
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
|
||||
code = ENABLE_CODE_BY_TYPE.get(order_type)
|
||||
if code is None:
|
||||
raise BadRequestError("未知的审批对象类型")
|
||||
return param_service.resolve_snapshot(session, date.today()).boolean(code)
|
||||
|
||||
|
||||
def submit_order(
|
||||
session: Session,
|
||||
*,
|
||||
order_type: int,
|
||||
ref_id: int,
|
||||
refer_no: str,
|
||||
submitter: User,
|
||||
retry_on_conflict: bool,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||
(全节点 action=3 留痕)。
|
||||
|
||||
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部
|
||||
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环,
|
||||
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把
|
||||
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
|
||||
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
|
||||
全局 handler 收敛 400,用户重试即可。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
order = ApprovalOrder(
|
||||
order_no=next_no(
|
||||
session,
|
||||
model=ApprovalOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_APPROVAL,
|
||||
),
|
||||
order_type=order_type,
|
||||
ref_id=ref_id,
|
||||
refer_no=refer_no,
|
||||
submitter_id=submitter.id,
|
||||
submitter_name=submitter.username,
|
||||
)
|
||||
session.add(order)
|
||||
|
||||
if is_enabled(session, order_type):
|
||||
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
logger.info("审批单待审 | order=%s", order.order_no)
|
||||
return order
|
||||
|
||||
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||
order.status = APPROVAL_STATUS_APPROVED
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
session.add_all(
|
||||
[
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node,
|
||||
action=APPROVAL_ACTION_AUTO_APPROVE,
|
||||
operator_name="系统",
|
||||
)
|
||||
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
|
||||
return order
|
||||
|
||||
|
||||
def act(
|
||||
session: Session,
|
||||
order_id: int,
|
||||
payload: ApprovalActionIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> ApprovalOrderDetailOut:
|
||||
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
|
||||
|
||||
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
|
||||
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
||||
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
||||
"""
|
||||
codes = set(user_repository.get_role_codes(operator))
|
||||
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_APPROVAL,
|
||||
action=verb,
|
||||
summary=f"审批单 #{order_id} {verb}",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
order = session.get(ApprovalOrder, order_id, with_for_update=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]}角色操作")
|
||||
|
||||
node_now = APPROVAL_NODES[order.status]
|
||||
if payload.action == APPROVAL_ACTION_APPROVE:
|
||||
order.status = (
|
||||
APPROVAL_STATUS_PENDING_LEADER
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
else:
|
||||
order.status = APPROVAL_STATUS_REJECTED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node_now,
|
||||
action=payload.action,
|
||||
opinion=payload.opinion,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return _detail_of(session, order)
|
||||
|
||||
|
||||
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
||||
filters = _order_filters(query)
|
||||
rows = (
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.where(*filters)
|
||||
.order_by(ApprovalOrder.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
total = session.execute(
|
||||
select(func.count()).select_from(ApprovalOrder).where(*filters)
|
||||
).scalar_one()
|
||||
return PageResult[ApprovalOrderOut](
|
||||
records=[ApprovalOrderOut.model_validate(r) for r in rows],
|
||||
total=int(total),
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
|
||||
"""审批单详情(附节点时间线)。"""
|
||||
order = session.get(ApprovalOrder, order_id)
|
||||
if order is None:
|
||||
raise NotFoundError("审批单不存在")
|
||||
return _detail_of(session, order)
|
||||
|
||||
|
||||
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
|
||||
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
|
||||
pending = (
|
||||
session.execute(
|
||||
select(ApprovalOrder).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for order in pending:
|
||||
order.status = APPROVAL_STATUS_WITHDRAWN
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=APPROVAL_NODE_WITHDRAW,
|
||||
action=APPROVAL_ACTION_WITHDRAW,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
if pending:
|
||||
session.flush()
|
||||
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||
|
||||
|
||||
def _flush_order(session: Session, order: ApprovalOrder, *, retry_on_conflict: bool) -> None:
|
||||
"""flush 提单;撞 AP 单号唯一键时按 retry_on_conflict 决定重取或抛出。
|
||||
|
||||
rollback 会连业务写路径的未提交变更一并回滚 —— 见 submit_order 的
|
||||
retry_on_conflict 说明(create 才允许内部重取)。
|
||||
"""
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
session.flush()
|
||||
return
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if is_duplicate_entry(exc) and retry_on_conflict and attempt < _NO_RETRY:
|
||||
order.id = None # rollback 后实例回 transient,主键重新分配
|
||||
order.order_no = next_no(
|
||||
session,
|
||||
model=ApprovalOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_APPROVAL,
|
||||
)
|
||||
session.add(order)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线)。"""
|
||||
steps = (
|
||||
session.execute(
|
||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||
return detail
|
||||
|
||||
|
||||
def _order_filters(query: ApprovalQuery) -> tuple:
|
||||
filters = []
|
||||
if query.keyword:
|
||||
like = f"%{query.keyword}%"
|
||||
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like))
|
||||
if query.status is not None:
|
||||
filters.append(ApprovalOrder.status == query.status)
|
||||
if query.order_type is not None:
|
||||
filters.append(ApprovalOrder.order_type == query.order_type)
|
||||
return tuple(filters)
|
||||
@@ -41,15 +41,17 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
User,
|
||||
)
|
||||
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
|
||||
from app.models.business import (
|
||||
BUSINESS_STATUS_ACTIVE,
|
||||
BUSINESS_STATUS_DONE,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.repositories import business_repository, customer_repository
|
||||
from app.schemas.business import (
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
@@ -65,8 +67,10 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
GrossProfitRowOut,
|
||||
OwnershipChangeLogOut,
|
||||
SecondaryAllocationIn,
|
||||
SecondaryAllocationOut,
|
||||
)
|
||||
from app.services import audit_service, param_service
|
||||
from app.services import approval_service, audit_service, param_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import converted_profit, profit_rate
|
||||
from app.services.no_generator import next_no
|
||||
@@ -77,9 +81,6 @@ logger = logging.getLogger(__name__)
|
||||
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
|
||||
_NO_RETRY = 1
|
||||
|
||||
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(row: tuple) -> BusinessOut:
|
||||
"""(business, company_name, owner_name, assistant_name) → 响应模型。
|
||||
@@ -214,11 +215,8 @@ def _generate_no(session: Session) -> str:
|
||||
|
||||
|
||||
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
|
||||
"""归属人员须存在且在职(人员侧复用校验文案口径)。"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||
return person
|
||||
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label=label)
|
||||
|
||||
|
||||
def _ensure_customer(session: Session, customer_id: int) -> Customer:
|
||||
@@ -263,7 +261,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
|
||||
|
||||
|
||||
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
"""业务详情(附明细行,供编辑抽屉回显)。
|
||||
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
@@ -274,9 +272,53 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
out.items = [
|
||||
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
||||
]
|
||||
allocations = business_repository.list_allocations(session, business_id)
|
||||
out.secondary_allocations = [
|
||||
SecondaryAllocationOut(
|
||||
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
|
||||
)
|
||||
for a in allocations
|
||||
]
|
||||
out.allocation_hint = _allocation_hint(allocations)
|
||||
return out
|
||||
|
||||
|
||||
def _build_allocation_rows(
|
||||
session: Session, business_id: int, items: list[SecondaryAllocationIn]
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||
|
||||
:raises BadRequestError: 参与人员不存在或已离职
|
||||
"""
|
||||
rows: list[SecondaryAllocation] = []
|
||||
for item in items:
|
||||
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
|
||||
rows.append(
|
||||
SecondaryAllocation(
|
||||
business_id=business_id,
|
||||
personnel_id=item.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal(item.ratio),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不强制合计满额是需求原文;余额归属待澄清 #2)。
|
||||
|
||||
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
|
||||
"""
|
||||
if not allocations:
|
||||
return None
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if total == Decimal("100"):
|
||||
return f"二次分配合计 {total_text}%"
|
||||
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"二次分配合计 {total_text}%,余额 {rest}%(归属待业务确认)"
|
||||
|
||||
|
||||
def create_business(
|
||||
session: Session,
|
||||
payload: BusinessCreateIn,
|
||||
@@ -341,6 +383,25 @@ def create_business(
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
|
||||
# 放在重试循环内——rollback 后方案行随业务一并重来
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business.id,
|
||||
_build_allocation_rows(session, business.id, payload.secondary_allocations),
|
||||
)
|
||||
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
|
||||
# retry_on_conflict=True:AP 编号撞键可内部重取(外层编号
|
||||
# 重试循环兜底整体重来)
|
||||
if payload.secondary_allocations:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business.id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
retry_on_conflict=True,
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||
@@ -414,6 +475,24 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business_id,
|
||||
_build_allocation_rows(session, business_id, payload.secondary_allocations),
|
||||
)
|
||||
# 方案非空提单(自动撤回旧在途单);retry_on_conflict=False:
|
||||
# 本路径无外层重试,内部 rollback 会回掉业务行修改造成静默丢失
|
||||
if payload.secondary_allocations:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
retry_on_conflict=False,
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
|
||||
@@ -40,15 +40,11 @@ from app.schemas.customer import (
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被
|
||||
# 客户归属引用;人员侧抽出共享常量后此处替换
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
@@ -95,17 +91,8 @@ def _ensure_customer_type(customer_type: str) -> None:
|
||||
|
||||
|
||||
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
|
||||
"""归属人员必须存在且在职,返回人员行。
|
||||
|
||||
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要
|
||||
向调用方区分的信息(同登录防枚举的思路)。
|
||||
|
||||
:raises BadRequestError: 归属人员不存在或已离职
|
||||
"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError("归属人员不存在或已离职")
|
||||
return person
|
||||
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
||||
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
@@ -256,6 +256,40 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||
sort_order=16,
|
||||
),
|
||||
ParamSpec(
|
||||
code="approval.secondary_allocation.enabled",
|
||||
name="二次分配审批开关",
|
||||
group="审批开关",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||||
),
|
||||
sort_order=17,
|
||||
),
|
||||
ParamSpec(
|
||||
code="approval.monthly_settlement.enabled",
|
||||
name="月度结算审批开关",
|
||||
group="审批开关",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||||
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||||
),
|
||||
sort_order=18,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.schemas.param import (
|
||||
ParamCatalogItemOut,
|
||||
ParamChangeBatchOut,
|
||||
ParamChangeIn,
|
||||
ParamChangeItemIn,
|
||||
ParamDiffOut,
|
||||
ParamDimensionValueOut,
|
||||
ParamHistoryOut,
|
||||
@@ -188,6 +189,67 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
|
||||
# ==================== 变更与版本管理 ====================
|
||||
|
||||
|
||||
def _normalize_items(
|
||||
session: Session, items: list[ParamChangeItemIn]
|
||||
) -> list[tuple[ParamDefinition, str, str]]:
|
||||
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
|
||||
|
||||
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
|
||||
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
|
||||
|
||||
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
|
||||
"""
|
||||
codes = list({item.param_code for item in items})
|
||||
definitions = param_repository.list_definitions_by_codes(session, codes)
|
||||
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
|
||||
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
|
||||
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
|
||||
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
|
||||
roles = {
|
||||
code.casefold(): role
|
||||
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
|
||||
}
|
||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in items:
|
||||
definition = definitions.get(item.param_code)
|
||||
if definition is None:
|
||||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
|
||||
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
|
||||
# 取值链静默不命中(按错误参数算钱且无报错)
|
||||
spec = get_spec(definition.param_code)
|
||||
dimension = item.dimension_value
|
||||
if spec.dimension_type == "NONE" and dimension != "":
|
||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
|
||||
role = roles.get(dimension.casefold())
|
||||
if role is None:
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||
)
|
||||
dimension = role.role_code
|
||||
if (
|
||||
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||
and dimension
|
||||
and dimension not in PRODUCT_CATEGORIES
|
||||
):
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||
)
|
||||
value = validate_and_normalize(spec, item.value)
|
||||
key = (item.param_code, dimension)
|
||||
if key in seen:
|
||||
raise BadRequestError(
|
||||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||
)
|
||||
seen.add(key)
|
||||
normalized.append((definition, dimension, value))
|
||||
return normalized
|
||||
|
||||
|
||||
def submit_change(
|
||||
session: Session,
|
||||
payload: ParamChangeIn,
|
||||
@@ -218,61 +280,8 @@ def submit_change(
|
||||
if payload.change_type == 2 and effective < date.today():
|
||||
raise BadRequestError("指定生效日期不得早于今日")
|
||||
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)。
|
||||
# 定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||
# 岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按
|
||||
# 维度类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤
|
||||
codes = list({item.param_code for item in payload.items})
|
||||
definitions = param_repository.list_definitions_by_codes(session, codes)
|
||||
dimension_values = list(
|
||||
{item.dimension_value for item in payload.items if item.dimension_value}
|
||||
)
|
||||
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
|
||||
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
|
||||
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
|
||||
roles = {
|
||||
code.casefold(): role
|
||||
for code, role in business_role_repository.list_by_codes(
|
||||
session, dimension_values
|
||||
).items()
|
||||
}
|
||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in payload.items:
|
||||
definition = definitions.get(item.param_code)
|
||||
if definition is None:
|
||||
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
|
||||
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
|
||||
# 取值链静默不命中(按错误参数算钱且无报错)
|
||||
spec = get_spec(definition.param_code)
|
||||
dimension = item.dimension_value
|
||||
if spec.dimension_type == "NONE" and dimension != "":
|
||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
|
||||
role = roles.get(dimension.casefold())
|
||||
if role is None:
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||
)
|
||||
dimension = role.role_code
|
||||
if (
|
||||
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||
and dimension
|
||||
and dimension not in PRODUCT_CATEGORIES
|
||||
):
|
||||
raise BadRequestError(
|
||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||
)
|
||||
value = validate_and_normalize(spec, item.value)
|
||||
key = (item.param_code, dimension)
|
||||
if key in seen:
|
||||
raise BadRequestError(
|
||||
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
|
||||
)
|
||||
seen.add(key)
|
||||
normalized.append((definition, dimension, value))
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||
normalized = _normalize_items(session, payload.items)
|
||||
|
||||
try:
|
||||
batch = param_repository.create_batch(
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.core.constants import (
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
|
||||
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
|
||||
from app.repositories import business_role_repository, personnel_repository
|
||||
from app.schemas.personnel import (
|
||||
PersonnelCreateIn,
|
||||
@@ -51,6 +52,22 @@ logger = logging.getLogger(__name__)
|
||||
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
|
||||
|
||||
|
||||
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
|
||||
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
|
||||
|
||||
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
|
||||
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
|
||||
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
|
||||
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
|
||||
|
||||
:raises BadRequestError: {label}人员不存在或已离职
|
||||
"""
|
||||
person = personnel_repository.get_by_id(session, personnel_id)
|
||||
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
|
||||
raise BadRequestError(f"{label}人员不存在或已离职")
|
||||
return person
|
||||
|
||||
|
||||
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
rows = personnel_repository.list_personnel(
|
||||
|
||||
@@ -217,7 +217,10 @@ def update_grade(
|
||||
raise NotFoundError("评级等级不存在")
|
||||
|
||||
_ensure_no_overlap(
|
||||
rating_repository.list_all(session, for_update=True), new_min, new_max, exclude_id=grade_id
|
||||
rating_repository.list_all(session, for_update=True),
|
||||
new_min,
|
||||
new_max,
|
||||
exclude_id=grade_id,
|
||||
)
|
||||
existed = rating_repository.get_by_name(session, payload.grade_name)
|
||||
if existed is not None and existed.id != grade_id:
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""角色管理业务编排。
|
||||
|
||||
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
|
||||
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
|
||||
待业务方给出权限清单后再启用)。
|
||||
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||
|
||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||
|
||||
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
|
||||
该编码存在,删除会使系统无法自举
|
||||
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
|
||||
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
|
||||
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
|
||||
在途审批流失去可操作人
|
||||
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||
"""
|
||||
@@ -27,8 +30,8 @@ from app.core.constants import (
|
||||
ACTION_DISABLE,
|
||||
ACTION_ENABLE,
|
||||
ACTION_UPDATE,
|
||||
BUILTIN_ROLE_CODES,
|
||||
MODULE_ROLE,
|
||||
ROLE_ADMIN,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
@@ -177,10 +180,10 @@ def set_status(
|
||||
|
||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||
内置管理员角色不可停用。
|
||||
内置角色不可停用。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图停用内置管理员角色
|
||||
:raises ForbiddenError: 试图停用内置角色
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id)
|
||||
verb = "启用" if status == 1 else "禁用"
|
||||
@@ -198,8 +201,8 @@ def set_status(
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN and status == 0:
|
||||
raise ForbiddenError("内置管理员角色不可停用")
|
||||
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
|
||||
raise ForbiddenError("内置角色不可停用")
|
||||
|
||||
role.status = status
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
@@ -217,12 +220,12 @@ def delete_role(
|
||||
) -> None:
|
||||
"""删除角色。
|
||||
|
||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
||||
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
:raises ForbiddenError: 试图删除内置管理员角色
|
||||
:raises ForbiddenError: 试图删除内置角色
|
||||
:raises BadRequestError: 角色下仍有绑定的用户
|
||||
"""
|
||||
role = role_repository.get_by_id(session, role_id, for_update=True)
|
||||
@@ -239,8 +242,8 @@ def delete_role(
|
||||
if role is None:
|
||||
raise NotFoundError("角色不存在")
|
||||
|
||||
if role.role_code == ROLE_ADMIN:
|
||||
raise ForbiddenError("内置管理员角色不可删除")
|
||||
if role.role_code in BUILTIN_ROLE_CODES:
|
||||
raise ForbiddenError("内置角色不可删除")
|
||||
|
||||
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
||||
user_count = _user_count(session, role_id)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""系统设置(公司信息)业务编排。
|
||||
|
||||
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
|
||||
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
|
||||
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
|
||||
|
||||
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
||||
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
||||
覆盖任意文件。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import SystemSetting, User
|
||||
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始
|
||||
_MAGIC = {
|
||||
"png": b"\x89PNG\r\n\x1a\n",
|
||||
"jpg": b"\xff\xd8\xff",
|
||||
}
|
||||
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
|
||||
|
||||
SETTINGS_ID = 1 # 全系统单行主键
|
||||
|
||||
|
||||
def get_setting(session: Session) -> SystemSetting:
|
||||
"""取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。"""
|
||||
setting = session.get(SystemSetting, SETTINGS_ID)
|
||||
if setting is None:
|
||||
setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团")
|
||||
session.add(setting)
|
||||
session.flush()
|
||||
return setting
|
||||
|
||||
|
||||
def to_out(setting: SystemSetting) -> SettingOut:
|
||||
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
|
||||
return SettingOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
||||
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
||||
return SettingPublicOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def _logo_url(setting: SystemSetting) -> str | None:
|
||||
if not setting.logo_path:
|
||||
return None
|
||||
version = int(setting.update_time.timestamp()) if setting.update_time else 0
|
||||
return f"/api/v1/system/settings/logo/public?v={version}"
|
||||
|
||||
|
||||
def update_company(
|
||||
session: Session,
|
||||
payload: SettingUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""更新公司名称(审计留痕)。"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"修改公司名称为「{payload.company_name}」",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
setting.company_name = payload.company_name.strip()
|
||||
session.commit()
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def save_logo(
|
||||
session: Session,
|
||||
content: bytes,
|
||||
ext: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
||||
|
||||
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
||||
"""
|
||||
ext = ext.lower()
|
||||
if ext not in _MAGIC:
|
||||
raise BadRequestError("logo 仅支持 png/jpg 格式")
|
||||
if len(content) > LOGO_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
if not content.startswith(_MAGIC[ext]):
|
||||
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
||||
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary="更新公司 logo",
|
||||
request_data={"ext": ext, "size": len(content)},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
old_path = setting.logo_path
|
||||
|
||||
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
|
||||
# 刚落盘的新文件一并删掉(回读 404)
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
_cleanup_old_logo(old_ext)
|
||||
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def read_logo(session: Session) -> tuple[Path, str]:
|
||||
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||
setting = get_setting(session)
|
||||
if not setting.logo_path:
|
||||
raise NotFoundError("尚未上传 logo")
|
||||
path = get_settings().upload_dir / setting.logo_path
|
||||
if not path.is_file():
|
||||
raise NotFoundError("logo 文件不存在,请重新上传")
|
||||
return path, path.suffix.lstrip(".")
|
||||
|
||||
|
||||
def _logo_dir() -> Path:
|
||||
directory = get_settings().upload_dir / "logo"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _cleanup_old_logo(old_ext: str) -> None:
|
||||
if old_ext in _MAGIC:
|
||||
old_file = _logo_dir() / f"logo.{old_ext}"
|
||||
if old_file.exists():
|
||||
old_file.unlink(missing_ok=True)
|
||||
@@ -35,6 +35,7 @@ dependencies = [
|
||||
"captcha>=0.6",
|
||||
"pillow>=11.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"python-multipart>=0.0.32",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -126,6 +126,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
|
||||
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
@@ -150,6 +151,16 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
|
||||
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
|
||||
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
|
||||
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
|
||||
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
|
||||
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
|
||||
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
|
||||
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
|
||||
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
|
||||
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
|
||||
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
@@ -37,6 +38,8 @@ from app.models import (
|
||||
CustomerRatingGrade,
|
||||
OperationLog,
|
||||
OwnershipChangeLog,
|
||||
ParamChangeBatch,
|
||||
ParamValue,
|
||||
Personnel,
|
||||
PersonnelPlan,
|
||||
PersonnelRegion,
|
||||
@@ -168,6 +171,18 @@ def _purge_test_data() -> None:
|
||||
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
||||
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
||||
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
||||
|
||||
# ---- M3:审批单 / 二次分配 / 参数开关基线 ----
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21
|
||||
# (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0)
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 21))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""集成测试共用工厂:客户 → 业务 → 明细的最小造数链。
|
||||
|
||||
回款/发货/业务三个测试文件此前各持一份逐字相同的工厂(口径漂移已经
|
||||
发生:同一 _customer 三份、payload 细节靠复制维持),收口后「t_ 前缀、
|
||||
默认明细行、业务日期取今日」单点维护;conftest 兜底清理按 t_ 前缀与
|
||||
YW/SH/HK 编号前缀覆盖本模块全部造数。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
|
||||
|
||||
def customer(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def business_item(**overrides: object) -> dict:
|
||||
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def business_payload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [business_item()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(发货/回款须先有业务)。"""
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""审批流集成测试(二次分配方案为首个接入对象)。
|
||||
|
||||
守的是(计划 T4 验收矩阵):
|
||||
- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点
|
||||
action=3 留痕(留痕不断链,关闭=直接生效的需求口径)
|
||||
- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3
|
||||
- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400)
|
||||
- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403);
|
||||
普通用户/营销后勤进不了模块(403)
|
||||
- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建
|
||||
- 方案为空不提单
|
||||
- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定)
|
||||
|
||||
角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。
|
||||
开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_APPROVALS = "/api/v1/system/approvals"
|
||||
_PARAMS = "/api/v1/system/params"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_审批_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _any_role_id(),
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _any_role_id() -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict:
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict:
|
||||
resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = resp.json()["data"]["list"]
|
||||
assert rows, f"未找到审批单:{refer_no}"
|
||||
detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers)
|
||||
return detail.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None:
|
||||
"""经参数变更批次把二次分配审批开关置值(立即生效)。"""
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"测试切换审批开关={value}",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.secondary_allocation.enabled",
|
||||
"dimension_value": "",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finance(make_user, auth_headers) -> dict:
|
||||
"""财务角色账号(审批第二节点)。"""
|
||||
user = make_user(["finance"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leader(make_user, auth_headers) -> dict:
|
||||
"""领导角色账号(审批第三节点)。"""
|
||||
user = make_user(["leader"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def switched_on(client: TestClient, admin: dict) -> None:
|
||||
"""把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。"""
|
||||
_set_switch(client, admin["headers"], "1")
|
||||
|
||||
|
||||
class TestSwitchOff:
|
||||
"""开关关闭(种子默认):提单即自动通过。"""
|
||||
|
||||
def test_创建带方案_自动通过全节点留痕(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
assert order["status"] == 3
|
||||
assert order["order_no"].startswith("AP")
|
||||
auto_steps = [s for s in order["steps"] if s["action"] == 3]
|
||||
assert len(auto_steps) == 2
|
||||
assert {s["node"] for s in auto_steps} == {1, 2}
|
||||
assert all(s["operator_name"] == "系统" for s in auto_steps)
|
||||
|
||||
def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None:
|
||||
cust = customer(client, admin["headers"])
|
||||
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"])
|
||||
assert resp.status_code == 200
|
||||
rows = client.get(
|
||||
_APPROVALS,
|
||||
params={"keyword": resp.json()["data"]["business_no"]},
|
||||
headers=finance["headers"],
|
||||
).json()["data"]["list"]
|
||||
assert rows == []
|
||||
|
||||
|
||||
class TestSwitchOn:
|
||||
"""开关开启:两节点人工流转。"""
|
||||
|
||||
@pytest.fixture
|
||||
def switched_on(self, client: TestClient, admin: dict) -> None:
|
||||
_set_switch(client, admin["headers"], "1")
|
||||
|
||||
def test_两节点流转至通过(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
assert order["status"] == 1
|
||||
|
||||
# 财务通过 → 待领导
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 1, "opinion": "同意"},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 2
|
||||
|
||||
# 领导通过 → 已通过
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=leader["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 3
|
||||
manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1]
|
||||
assert {s["node"] for s in manual} == {1, 2}
|
||||
|
||||
def test_财务驳回_整体驳回且意见留痕(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
_ = leader
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 2, "opinion": "比例分配不合理"},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 4
|
||||
rejected = [s for s in out["steps"] if s["action"] == 2]
|
||||
assert rejected and rejected[0]["opinion"] == "比例分配不合理"
|
||||
|
||||
def test_驳回缺意见被拒(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 2},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_节点准入_领导不能审财务节点_admin不代办(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
order = _latest_order(client, leader["headers"], data["business_no"])
|
||||
|
||||
# status=1 是财务节点:领导不行,admin 也不代办
|
||||
for headers in (leader["headers"], admin["headers"]):
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_重复操作已通过单被拒(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||
)
|
||||
client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
|
||||
)
|
||||
resp = client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestResubmit:
|
||||
"""再编辑撤旧建新。"""
|
||||
|
||||
def test_在途单撤回并新建(
|
||||
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||
) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||
assert first["status"] == 1
|
||||
|
||||
# 再编辑(仍带方案)→ 旧在途单撤回、新单待审
|
||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||
payload = business_payload(
|
||||
detail["customer_id"],
|
||||
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
|
||||
)
|
||||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = client.get(
|
||||
_APPROVALS,
|
||||
params={"keyword": data["business_no"], "pageSize": 10},
|
||||
headers=finance["headers"],
|
||||
).json()["data"]["list"]
|
||||
statuses = {row["status"] for row in rows}
|
||||
assert 5 in statuses and 1 in statuses, f"未见撤回+新建:{statuses}"
|
||||
old_detail = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()
|
||||
withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4]
|
||||
assert withdraw_steps and withdraw_steps[0]["node"] == 0
|
||||
|
||||
|
||||
class TestModuleAccess:
|
||||
"""模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。"""
|
||||
|
||||
def test_普通用户403(self, client: TestClient, normal: dict) -> None:
|
||||
assert client.get(_APPROVALS, headers=normal["headers"]).status_code == 403
|
||||
|
||||
def test_营销后勤403_暂不可见(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
ops = make_user(["marketing_ops"])
|
||||
ops["headers"] = auth_headers(ops)
|
||||
assert client.get(_APPROVALS, headers=ops["headers"]).status_code == 403
|
||||
|
||||
def test_财务与领导可见(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
for code in ("finance", "leader"):
|
||||
user = make_user([code])
|
||||
user["headers"] = auth_headers(user)
|
||||
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
|
||||
@@ -152,8 +152,10 @@ class TestAuditRetry:
|
||||
second = client.post(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
# 未捕获 IntegrityError 由全局异常兜底收敛 500
|
||||
assert second.status_code == 500, second.text
|
||||
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||
assert second.status_code == 400, second.text
|
||||
assert "完整性冲突" in second.json()["message"]
|
||||
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
|
||||
@@ -86,9 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
||||
class TestCatalog:
|
||||
"""参数目录:种子完整性与时点化回溯。"""
|
||||
|
||||
def test_目录共16项且覆盖七个分组(self, client: TestClient, admin: dict) -> None:
|
||||
def test_目录共18项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V6 起含审批开关组(16 计提项 + 2 BOOL 开关)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 16
|
||||
assert len(items) == 18
|
||||
groups = {item["param_group"] for item in items}
|
||||
assert groups == {
|
||||
"业务员个人户",
|
||||
@@ -98,6 +99,7 @@ class TestCatalog:
|
||||
"支付环节",
|
||||
"产品阈值",
|
||||
"月工资口径",
|
||||
"审批开关",
|
||||
}
|
||||
|
||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||
|
||||
@@ -26,57 +26,13 @@ from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog, Receipt
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
|
||||
|
||||
def _customer(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _bitem(**overrides: object) -> dict:
|
||||
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [_bitem()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(回款/发货单挂在业务上)。"""
|
||||
customer = _customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _sitem(**overrides: object) -> dict:
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestSchemaSync:
|
||||
class TestSeedData:
|
||||
"""字典数据与版本标记。"""
|
||||
|
||||
@pytest.mark.parametrize("role_code", ["admin", "user"])
|
||||
@pytest.mark.parametrize("role_code", ["admin", "user", "marketing_ops", "finance", "leader"])
|
||||
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
||||
migration_sql = "\n".join(
|
||||
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
|
||||
|
||||
守的是(计划 T3 验收):
|
||||
- 创建带方案 → 详情回显含 personnel_name 冗余与比例
|
||||
- 编辑全量替换(空数组=清空)
|
||||
- 离职/不存在人员 → 400「分摊参与人员不存在或已离职」
|
||||
- 终态业务禁改方案(与编辑终态门槛同一口径)
|
||||
- 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2)
|
||||
- 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离)
|
||||
- 人员删除后方案回显仍有名(personnel_name 固化的意义)
|
||||
|
||||
人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict, **overrides: object) -> dict:
|
||||
role_id = overrides.pop("business_role_id", None)
|
||||
body = {
|
||||
"personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": role_id or _any_role_id(),
|
||||
"salary_standard": "3000.00",
|
||||
**overrides,
|
||||
}
|
||||
resp = client.post(_PERSONNEL, json=body, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _any_role_id() -> int:
|
||||
"""种子营销员岗位 ID(内置启用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict:
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(cust["id"], secondary_allocations=allocations),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
|
||||
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
class TestCreateAndEcho:
|
||||
"""创建带方案与详情回显。"""
|
||||
|
||||
def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None:
|
||||
p1 = _person(client, admin["headers"])
|
||||
p2 = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client,
|
||||
admin["headers"],
|
||||
[
|
||||
{"personnel_id": p1["id"], "ratio": "30.00"},
|
||||
{"personnel_id": p2["id"], "ratio": "20.00"},
|
||||
],
|
||||
)
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
assert len(detail["secondary_allocations"]) == 2
|
||||
names = {a["personnel_name"] for a in detail["secondary_allocations"]}
|
||||
assert p1["personnel_name"] in names and p2["personnel_name"] in names
|
||||
ratios = sorted(a["ratio"] for a in detail["secondary_allocations"])
|
||||
assert ratios == ["20.00", "30.00"]
|
||||
# 合计 50%,余额 50% —— 只提示不落库(待澄清 #2)
|
||||
assert "50.00" in (detail["allocation_hint"] or "")
|
||||
assert "余额" in (detail["allocation_hint"] or "")
|
||||
|
||||
def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None:
|
||||
data = _create_with_allocations(client, admin["headers"], [])
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
assert detail["secondary_allocations"] == []
|
||||
assert detail["allocation_hint"] is None
|
||||
|
||||
def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None:
|
||||
p1 = _person(client, admin["headers"])
|
||||
p2 = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client,
|
||||
admin["headers"],
|
||||
[
|
||||
{"personnel_id": p1["id"], "ratio": "60"},
|
||||
{"personnel_id": p2["id"], "ratio": "40.00"},
|
||||
],
|
||||
)
|
||||
hint = _detail(client, admin["headers"], data["id"])["allocation_hint"]
|
||||
assert hint is not None and "余额" not in hint
|
||||
|
||||
|
||||
class TestGuards:
|
||||
"""校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。"""
|
||||
|
||||
def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cust = customer(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
|
||||
),
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "分摊参与" in resp.json()["message"]
|
||||
|
||||
def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "100"}]
|
||||
)
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}/status",
|
||||
json={"status": 2},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
payload = business_payload(detail["customer_id"], secondary_allocations=[])
|
||||
for key in ("business_no", "status", "owner_personnel_id", "total_sale_amount"):
|
||||
payload.pop(key, None)
|
||||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
assert "禁止编辑" in resp.json()["message"]
|
||||
|
||||
def test_编辑全量替换与清空(self, client: TestClient, admin: dict) -> None:
|
||||
p1 = _person(client, admin["headers"])
|
||||
p2 = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p1["id"], "ratio": "40.00"}]
|
||||
)
|
||||
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
payload = business_payload(
|
||||
detail["customer_id"],
|
||||
secondary_allocations=[{"personnel_id": p2["id"], "ratio": "70.00"}],
|
||||
)
|
||||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
replaced = _detail(client, admin["headers"], data["id"])
|
||||
assert len(replaced["secondary_allocations"]) == 1
|
||||
assert replaced["secondary_allocations"][0]["personnel_id"] == p2["id"]
|
||||
|
||||
payload = business_payload(detail["customer_id"], secondary_allocations=[])
|
||||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _detail(client, admin["headers"], data["id"])["secondary_allocations"] == []
|
||||
|
||||
|
||||
class TestDeleteProtection:
|
||||
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
|
||||
|
||||
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
)
|
||||
resp = client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
# 方案仍在,回显有名(RESTRICT 保证未静默剥离)
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
|
||||
|
||||
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
)
|
||||
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text # 方案 CASCADE 随业务删除
|
||||
assert client.delete(f"{_PERSONNEL}/{p['id']}", headers=admin["headers"]).status_code == 200
|
||||
@@ -0,0 +1,137 @@
|
||||
"""系统设置(公司信息)接口测试。
|
||||
|
||||
守的是:
|
||||
- 单行语义:全系统恒一行,PUT 只改名称不增行
|
||||
- public 端点无鉴权可用但**白名单字段**——多一个敏感字段都是匿名泄漏
|
||||
- logo 上传三重校验(后缀/magic bytes/大小)——仅看后缀可被伪造
|
||||
- 审计留痕:系统设置属敏感配置,改必须有日志
|
||||
|
||||
真图片构造:PNG 用 8 字节签名 + 最小 IHDR;JPG 用 SOI 头 + 填充字节。
|
||||
仅校验 magic bytes 前缀,无需完整合法图片(解析由前端 img 完成)。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SETTINGS = "/api/v1/system/settings"
|
||||
|
||||
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
|
||||
_JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
|
||||
_TXT_BYTES = b"not an image at all"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestCompanyInfo:
|
||||
"""公司名称读取与更新。"""
|
||||
|
||||
def test_初始值与更新生效(self, client: TestClient, admin: dict) -> None:
|
||||
got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"]
|
||||
assert got["company_name"]
|
||||
# logo_url 允许为 None(未上传)或历史遗留的 URL——单行库可复用,
|
||||
# 断言不依赖「干净库」前提;上传行为由 TestLogoUpload 专类覆盖
|
||||
assert got["logo_url"] is None or "/logo/public" in got["logo_url"]
|
||||
|
||||
new_name = f"测试公司_{uuid.uuid4().hex[:6]}"
|
||||
put = client.put(_SETTINGS, json={"company_name": new_name}, headers=admin["headers"])
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["data"]["company_name"] == new_name
|
||||
assert (
|
||||
client.get(_SETTINGS, headers=admin["headers"]).json()["data"]["company_name"]
|
||||
== new_name
|
||||
)
|
||||
|
||||
def test_名称为空被拒(self, client: TestClient, admin: dict) -> None:
|
||||
assert (
|
||||
client.put(_SETTINGS, json={"company_name": ""}, headers=admin["headers"]).status_code
|
||||
== 400
|
||||
)
|
||||
|
||||
def test_非管理员改名称被拒(self, client: TestClient, normal: dict) -> None:
|
||||
resp = client.put(_SETTINGS, json={"company_name": "越权公司"}, headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_修改留审计日志(self, client: TestClient, admin: dict) -> None:
|
||||
client.put(
|
||||
_SETTINGS,
|
||||
json={"company_name": f"审计公司_{uuid.uuid4().hex[:6]}"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
logs = client.get(
|
||||
"/api/v1/system/operation-logs",
|
||||
params={"module": "系统设置"},
|
||||
headers=admin["headers"],
|
||||
).json()["data"]["list"]
|
||||
assert logs, "修改公司名称未产生操作日志"
|
||||
|
||||
|
||||
class TestPublicEndpoint:
|
||||
"""匿名端点:白名单字段 + 可用性。"""
|
||||
|
||||
def test_无令牌可读且仅白名单字段(self, client: TestClient, admin: dict) -> None:
|
||||
name = f"公开公司_{uuid.uuid4().hex[:6]}"
|
||||
client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"])
|
||||
|
||||
resp = client.get(f"{_SETTINGS}/public")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界"
|
||||
assert data["company_name"] == name
|
||||
assert resp.headers.get("cache-control") is not None
|
||||
|
||||
def test_写操作匿名不可用(self, client: TestClient) -> None:
|
||||
assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in {
|
||||
401,
|
||||
405,
|
||||
}
|
||||
|
||||
|
||||
class TestLogoUpload:
|
||||
"""logo 上传校验链。"""
|
||||
|
||||
def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes):
|
||||
return client.post(
|
||||
f"{_SETTINGS}/logo",
|
||||
files={"file": (filename, content, "application/octet-stream")},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
def test_合法png上传成功且回读(self, client: TestClient, admin: dict) -> None:
|
||||
resp = self._upload(client, admin, "logo.png", _PNG_BYTES)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["logo_url"] and "/logo/public" in out["logo_url"]
|
||||
|
||||
img = client.get(f"{_SETTINGS}/logo/public")
|
||||
assert img.status_code == 200
|
||||
assert img.headers["content-type"] == "image/png"
|
||||
|
||||
def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None:
|
||||
resp = self._upload(client, admin, "fake.png", _TXT_BYTES)
|
||||
assert resp.status_code == 400
|
||||
assert "不符" in resp.json()["message"]
|
||||
|
||||
def test_非法格式被拒(self, client: TestClient, admin: dict) -> None:
|
||||
assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400
|
||||
|
||||
def test_超限被拒(self, client: TestClient, admin: dict) -> None:
|
||||
big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024)
|
||||
resp = self._upload(client, admin, "big.png", big)
|
||||
assert resp.status_code == 400
|
||||
assert "2MB" in resp.json()["message"]
|
||||
|
||||
def test_匿名上传被拒(self, client: TestClient) -> None:
|
||||
resp = client.post(
|
||||
f"{_SETTINGS}/logo",
|
||||
files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import select
|
||||
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import OperationLog
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -32,51 +33,6 @@ _BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
_CUSTOMERS = "/api/v1/base/customers"
|
||||
|
||||
|
||||
def _customer(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_CUSTOMERS,
|
||||
json={
|
||||
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _bitem(**overrides: object) -> dict:
|
||||
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||
body: dict = {
|
||||
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||
body: dict = {
|
||||
"customer_id": customer_id,
|
||||
"business_date": str(date.today()),
|
||||
"items": [_bitem()],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||
"""建一名 t_ 客户并挂一笔默认业务(业务须先存在,发货单挂在业务上)。"""
|
||||
customer = _customer(client, headers)
|
||||
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _item(**overrides: object) -> dict:
|
||||
|
||||
@@ -20,6 +20,10 @@ EXPECTED_TABLES = {
|
||||
"login_logs",
|
||||
"operation_logs",
|
||||
"business_roles",
|
||||
"system_settings",
|
||||
"approval_orders",
|
||||
"approval_steps",
|
||||
"business_secondary_allocations",
|
||||
"param_definitions",
|
||||
"param_change_batches",
|
||||
"param_values",
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
|
||||
# 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@@ -36,14 +36,16 @@ EXPECTED_CODES = {
|
||||
"salary.basic.mode",
|
||||
"probation.annual.threshold",
|
||||
"probation.salary.basic",
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共16项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 16
|
||||
def test_注册表共18项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 18
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
@@ -61,10 +63,27 @@ class TestRegistry:
|
||||
continue
|
||||
if spec.value_type in {"DECIMAL", "INT"}:
|
||||
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
|
||||
elif spec.value_type == "BOOL":
|
||||
assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1"
|
||||
elif spec.value_type == "ENUM":
|
||||
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
||||
assert spec.default_value in spec.options
|
||||
|
||||
def test_BOOL开关_仅接受0与1(self) -> None:
|
||||
"""V6 首例 BOOL 注册的两个审批开关:合法值与非法值边界。"""
|
||||
for code in (
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
):
|
||||
spec = PARAM_REGISTRY[code]
|
||||
assert spec.value_type == "BOOL"
|
||||
assert validate_and_normalize(spec, "0") == "0"
|
||||
assert validate_and_normalize(spec, "1") == "1"
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "true")
|
||||
with pytest.raises(BadRequestError):
|
||||
validate_and_normalize(spec, "2")
|
||||
|
||||
def test_取值域上下限可解析(self) -> None:
|
||||
for spec in PARAM_REGISTRY.values():
|
||||
if spec.value_min is not None:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""二次分配方案入参校验(schema 层,秒级)。
|
||||
|
||||
守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与
|
||||
参与人去重——uk(business_id, personnel_id) 的友好前置校验。
|
||||
明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.business import SecondaryAllocationIn
|
||||
|
||||
|
||||
def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict:
|
||||
return {"personnel_id": personnel_id, "ratio": ratio}
|
||||
|
||||
|
||||
class TestRatioBoundary:
|
||||
"""比例边界:0 / 满额 / 超限 / 精度 / 非数字。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ratio", "ok"),
|
||||
[
|
||||
("0", False), # 必须大于 0
|
||||
("0.01", True),
|
||||
("33.33", True),
|
||||
("100", True),
|
||||
("100.00", True),
|
||||
("100.01", False), # 超上限
|
||||
("-5", False), # 负数
|
||||
("33.333", False), # 三位小数:拒绝不静默舍入
|
||||
("abc", False), # 非数字
|
||||
("", False), # 空串
|
||||
],
|
||||
)
|
||||
def test_比例边界(self, ratio: str, *, ok: bool) -> None:
|
||||
if ok:
|
||||
assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio
|
||||
else:
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(ratio=ratio))
|
||||
|
||||
def test_人员id必须为正整数(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(personnel_id=0))
|
||||
with pytest.raises(ValidationError):
|
||||
SecondaryAllocationIn(**_make(personnel_id=-1))
|
||||
|
||||
|
||||
class TestDuplicate:
|
||||
"""参与人去重(Create/Update 两个入参模型同款校验)。"""
|
||||
|
||||
def test_创建入参_重复参与人被拒(self) -> None:
|
||||
from app.schemas.business import BusinessCreateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [
|
||||
_make(personnel_id=5, ratio="30.00"),
|
||||
_make(personnel_id=5, ratio="20.00"),
|
||||
],
|
||||
}
|
||||
with pytest.raises(ValidationError, match="重复"):
|
||||
BusinessCreateIn(**payload)
|
||||
|
||||
def test_编辑入参_重复参与人被拒(self) -> None:
|
||||
from app.schemas.business import BusinessUpdateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)],
|
||||
}
|
||||
with pytest.raises(ValidationError, match="重复"):
|
||||
BusinessUpdateIn(**payload)
|
||||
|
||||
def test_不同参与人合法(self) -> None:
|
||||
from app.schemas.business import BusinessCreateIn
|
||||
|
||||
payload = {
|
||||
"customer_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"product_name": "t_产品",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "1.00",
|
||||
"sale_price": "10.00",
|
||||
}
|
||||
],
|
||||
"secondary_allocations": [
|
||||
_make(personnel_id=5, ratio="30.00"),
|
||||
_make(personnel_id=6, ratio="20.00"),
|
||||
],
|
||||
}
|
||||
model = BusinessCreateIn(**payload)
|
||||
assert len(model.secondary_allocations) == 2
|
||||
Generated
+11
@@ -435,6 +435,7 @@ dependencies = [
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymysql", extra = ["rsa"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "starlette" },
|
||||
@@ -462,6 +463,7 @@ requires-dist = [
|
||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||
{ name = "pyjwt", specifier = ">=2.10" },
|
||||
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
||||
{ name = "starlette", specifier = ">=1.7,<2" },
|
||||
@@ -656,6 +658,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
||||
+85
-3
@@ -16,6 +16,10 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `approval_steps`;
|
||||
DROP TABLE IF EXISTS `approval_orders`;
|
||||
DROP TABLE IF EXISTS `business_secondary_allocations`;
|
||||
DROP TABLE IF EXISTS `system_settings`;
|
||||
DROP TABLE IF EXISTS `param_values`;
|
||||
DROP TABLE IF EXISTS `param_change_batches`;
|
||||
DROP TABLE IF EXISTS `param_definitions`;
|
||||
@@ -408,6 +412,14 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
||||
|
||||
-- 2026-10-15 复核审批补充需求:三个职能登录角色(与 V6 迁移种子一致;
|
||||
-- 内置保护由 role_service 的 BUILTIN_ROLE_CODES 白名单承担)
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
|
||||
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
|
||||
@@ -436,7 +448,9 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
|
||||
(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13),
|
||||
(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14),
|
||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
||||
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
||||
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16),
|
||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17),
|
||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||
@@ -457,7 +471,9 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
||||
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
|
||||
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
|
||||
(18, 15, '', '2026-01-01', '18000', 1),
|
||||
(19, 16, '', '2026-01-01', '3000', 1);
|
||||
(19, 16, '', '2026-01-01', '3000', 1),
|
||||
(20, 17, '', '2026-01-01', '0', 1),
|
||||
(21, 18, '', '2026-01-01', '0', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||
@@ -663,6 +679,72 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
|
||||
('C', 60, 74, 3, '合格'),
|
||||
('D', 0, 59, 4, '待改进');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE system_settings (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
|
||||
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_system_settings PRIMARY KEY (id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE approval_orders (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
|
||||
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
||||
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
||||
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
|
||||
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
|
||||
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no),
|
||||
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
|
||||
|
||||
CREATE TABLE approval_steps (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
||||
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
|
||||
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
|
||||
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
|
||||
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
|
||||
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
|
||||
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
|
||||
|
||||
CREATE TABLE business_secondary_allocations (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
||||
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
||||
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
|
||||
|
||||
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -671,4 +753,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4');
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Running upgrade 8efc189b131f -> 79bcc5aa98d4
|
||||
|
||||
CREATE TABLE system_settings (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)',
|
||||
logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_system_settings PRIMARY KEY (id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE approval_orders (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}',
|
||||
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
||||
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
||||
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
|
||||
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_approval_orders PRIMARY KEY (id),
|
||||
CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
|
||||
|
||||
CREATE TABLE approval_steps (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
||||
node TINYINT NOT NULL COMMENT '审批节点:0撤回留痕 1财务复核 2领导审核',
|
||||
action TINYINT NOT NULL COMMENT '动作:1通过 2驳回 3自动通过 4撤回',
|
||||
opinion VARCHAR(255) COMMENT '审批意见(驳回时填写)',
|
||||
operator_id BIGINT UNSIGNED COMMENT '操作人ID(自动通过/系统动作置空)',
|
||||
operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化;系统动作记「系统」)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_approval_steps PRIMARY KEY (id),
|
||||
CONSTRAINT fk_approval_steps_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_approval_steps_order_id FOREIGN KEY(order_id) REFERENCES approval_orders (id) ON DELETE CASCADE
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批节点记录表(append-only;自动通过/撤回均有留痕)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
|
||||
|
||||
CREATE TABLE business_secondary_allocations (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
||||
ratio NUMERIC(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
||||
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('marketing_ops', '营销后勤', '提交与录入(审批链第一节点:月结/二次分配提交)', 3);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('finance', '财务', '复核(审批链第二节点)', 4);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('leader', '领导', '审核(审批链第三节点;全览只读)', 5);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17, 1);
|
||||
|
||||
INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18, 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (20, 17, '', '2026-01-01', '0', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (21, 18, '', '2026-01-01', '0', 1);
|
||||
|
||||
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
|
||||
|
||||
UPDATE alembic_version SET version_num='79bcc5aa98d4' WHERE alembic_version.version_num = '8efc189b131f';
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
|
||||
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
|
||||
|
||||
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。
|
||||
**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。2026-10-15 功能清单核查补充(二次分配方案、工作台、复核审批、月结与调整单、备份恢复运维、公司信息配置)已并入路线图(1.1/1.3/1.4)并收录为第三章。
|
||||
|
||||
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
|
||||
|
||||
@@ -40,11 +40,17 @@
|
||||
### 1.1 推荐顺序与依赖
|
||||
|
||||
```text
|
||||
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板
|
||||
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游)
|
||||
③ 业务数据管理 ← 引用 ② 人员/产品/客户
|
||||
① 系统设置与参数体系(#28)✅ ← 地基:岗位角色 + 参数 + 公式模板
|
||||
│ 增量(2026-10-15):公司信息配置;审批开关走参数体系
|
||||
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约
|
||||
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
||||
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
||||
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
||||
```
|
||||
|
||||
### 1.2 顺序论证(要点)
|
||||
@@ -59,32 +65,37 @@
|
||||
|
||||
| 块 | 范围界定 | 验收标准(一句话) |
|
||||
| --- | --- | --- |
|
||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 |
|
||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 |
|
||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||
|
||||
### 1.4 里程碑(交付批次)
|
||||
|
||||
| 批次 | 内容 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 |
|
||||
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 |
|
||||
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 |
|
||||
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 |
|
||||
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
||||
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||
|
||||
### 1.5 需求依据对照
|
||||
|
||||
| 块 | 依据 | 关联作废条款(不引用) |
|
||||
| --- | --- | --- |
|
||||
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
|
||||
| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24;增量见 3.1(五)(六) | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) |
|
||||
| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) |
|
||||
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
|
||||
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — |
|
||||
| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25;增量见 3.1(一) | 26 天示例;含运费公式(2.2.4/2.3.3 整段) |
|
||||
| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
||||
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
||||
| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) |
|
||||
|
||||
---
|
||||
|
||||
@@ -599,6 +610,118 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
||||
|
||||
---
|
||||
|
||||
## 第三章 2026-10-15 功能清单核查补充(新增需求)
|
||||
|
||||
> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。
|
||||
|
||||
### 3.1 需求原文(照录)
|
||||
|
||||
#### (一)二次分配方案
|
||||
|
||||
- 二次分配只出现在合同刚建立时。
|
||||
- 合同建立时可手动选择参与二次分配的业务员。
|
||||
- 分配比例不要求合计 100%。
|
||||
- 审批流程可增加开关配置;关闭时直接生效,开启时走审批。
|
||||
- 修改二次分配方案不影响已经结算的月份。
|
||||
- 已结算月份如需调整,只能通过调整单或后续月份扣补处理。
|
||||
|
||||
#### (二)工作台
|
||||
|
||||
- 经营绩效总览
|
||||
- 待办事项
|
||||
- 风险预警
|
||||
- 最近同步/计算/审批动态
|
||||
|
||||
#### (三)复核审批
|
||||
|
||||
- 营销后勤提交
|
||||
- 财务复核
|
||||
- 领导审核
|
||||
- 审批开关配置
|
||||
- 审批意见与版本
|
||||
|
||||
#### (四)月结、审批、报表
|
||||
|
||||
- 月度结算按确认回款和计算快照生成。
|
||||
- 支持负数工资、扣补说明和调整单。
|
||||
- 结算后锁定,修改源数据不影响已结算结果,只产生提示、历史记录和后续调整。
|
||||
- 导出模板围绕财务复核和领导签字设计。
|
||||
|
||||
#### (五)备份恢复与运维检查
|
||||
|
||||
- (标题级条目,无细则,用户强调「这个备份恢复很重要」)
|
||||
|
||||
#### (六)系统设置
|
||||
|
||||
- 公司信息配置:公司名称、logo 等基本信息
|
||||
|
||||
#### (七)参数配置强调
|
||||
|
||||
- (用户原话):「业务强调参数配置尽量还是灵活一点,能省去以后很多麻烦」
|
||||
|
||||
### 3.2 模块归属与设计要点(开发侧推演,非需求原文)
|
||||
|
||||
#### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接
|
||||
|
||||
- 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计;
|
||||
- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计;
|
||||
- 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕;
|
||||
- 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。
|
||||
|
||||
#### (二) 工作台 → ⑧ 新页面(升级现有 Bento 仪表盘占位)
|
||||
|
||||
| 区块 | 数据源(推演) |
|
||||
| --- | --- |
|
||||
| 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) |
|
||||
| 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) |
|
||||
| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 |
|
||||
| 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 |
|
||||
|
||||
#### (三) 复核审批 → ⑦ 新模块
|
||||
|
||||
- 三级节点:营销后勤提交 → 财务复核 → 领导审核;
|
||||
- 审批对象:月度结算((四) 联动);二次分配方案审批((一) 开关联动);
|
||||
- 「审批开关配置」→ 进 ① 参数体系(BOOL 参数,按审批对象各一);「关闭时直接生效」= 提交即生成自动通过的审批记录(留痕不断链);
|
||||
- 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖;
|
||||
- **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。
|
||||
|
||||
#### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板
|
||||
|
||||
- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3);
|
||||
- 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径;
|
||||
- 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态);
|
||||
- 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据;
|
||||
- 导出模板含财务复核栏、领导签字栏、公司信息页眉(依赖 (六) 公司信息配置)。
|
||||
|
||||
#### (五) 备份恢复与运维检查 → ⑨(原文无细则,范围由开发定义后交用户确认)
|
||||
|
||||
- 备份:MySQL 定时全量(mysqldump,命名卷外置)+ 保留策略 + **恢复演练文档与脚本**(备份不演练 = 没有备份);
|
||||
- 运维检查:容器状态、磁盘空间、备份新鲜度、Redis 键 TTL 抽查、同步日志异常、登录日志异常 → 脚本化健康报告;
|
||||
- 用户强调重要 → 建议 M3 期间并行尽早交付(不依赖业务模块)。
|
||||
|
||||
#### (六) 系统设置-公司信息 + 参数灵活性 → ① 增量
|
||||
|
||||
- 公司信息配置:公司名称、logo(登录页与 ⑤ 导出页眉消费);
|
||||
- 参数灵活性(用户重申方向):新增审批开关、二次分配、月结相关配置**一律进参数注册表**(16 项清单将扩充),沿用「公式模板(代码内有限集合)+ 参数绑定」既定路线;新增公式形态仍需加模板(一期接受,与「不引入规则引擎」一致)。
|
||||
|
||||
### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清)
|
||||
|
||||
| # | 事项 | 影响模块 | 建议默认处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 |
|
||||
| 2 | 二次分配审批开关默认值(开/关) | ⑦ 参数种子 | 按「支付环节按需规默认值参数化」先例:默认关闭(直接生效),可开 —— 待业务确认 |
|
||||
| 3 | 「确认回款」的定义:登记即确认 / T+ 同步即确认 / 需人工确认动作? | ④ 月结取数口径 | 建议需人工确认动作(可在回款确认状态上扩展),待确认 |
|
||||
| 4 | 审批角色权限清单:营销后勤/财务/领导的账号归属、可见菜单、操作权限点 | ⑦ 与系统管理 | 开工前业务方给出清单;技术侧激活 permissions/role_permissions |
|
||||
| 5 | 风险预警规则:哪些情形预警、阈值多少 | ⑧ | ④⑤ 落地后与业务确认首批规则,先落参数化容器 |
|
||||
| 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 |
|
||||
| 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 |
|
||||
|
||||
### 3.4 对路线图的影响(本次已修订)
|
||||
|
||||
1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -35,12 +35,19 @@ from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
E2EFailure,
|
||||
click_query,
|
||||
collect_console_errors,
|
||||
confirm_message_box,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
fill_filter,
|
||||
login,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
toast_text,
|
||||
visible_dialog,
|
||||
wait_toast,
|
||||
)
|
||||
|
||||
@@ -68,65 +75,6 @@ console_errors: list[str] = []
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str, group: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开指定分组。
|
||||
|
||||
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.locator(".el-menu-item", has_text=title).first
|
||||
if not item.is_visible():
|
||||
menu.locator(".el-sub-menu__title", has_text=group).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 同页存在多个表单实例(隐藏未销毁)时,
|
||||
不带可见性过滤会命中隐藏元素。
|
||||
"""
|
||||
form_item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项(发货/回款页按业务筛选)。"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def confirm_text(page: Page) -> str:
|
||||
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
|
||||
deadline = time.monotonic() + 5
|
||||
|
||||
@@ -156,3 +156,66 @@ def login(page: Page, username: str, password: str) -> None:
|
||||
page.wait_for_load_state("networkidle")
|
||||
finally:
|
||||
page.remove_listener("response", on_response)
|
||||
|
||||
|
||||
# ==================== 列表页 UI 操作(base_data/system_management 共用) ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str, group: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开指定分组。
|
||||
|
||||
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.locator(".el-menu-item", has_text=title).first
|
||||
if not item.is_visible():
|
||||
menu.locator(".el-sub-menu__title", has_text=group).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」;
|
||||
选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时
|
||||
会命中隐藏元素。
|
||||
"""
|
||||
form_item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项。"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
@@ -29,6 +29,13 @@ import time
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
click_query,
|
||||
confirm_message_box,
|
||||
fill_filter,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
visible_dialog,
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
@@ -56,66 +63,6 @@ console_errors: list[str] = []
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开「系统管理」分组。
|
||||
|
||||
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.get_by_text(title, exact=True)
|
||||
if not item.is_visible():
|
||||
menu.get_by_text("系统管理", exact=True).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
|
||||
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
|
||||
"""
|
||||
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项。
|
||||
|
||||
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
|
||||
"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def create_user_via_ui(
|
||||
page: Page, username: str, real_name: str, expect_success: bool = True
|
||||
) -> None:
|
||||
@@ -154,7 +101,7 @@ def find_user_row(page: Page, username: str):
|
||||
def step_user_create(page: Page, admin_password: str) -> None:
|
||||
"""登录、菜单导航、新增用户与重名负例。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
if current_path(page) != "/system/users":
|
||||
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
|
||||
@@ -217,7 +164,7 @@ def step_user_edit_and_status(page: Page) -> None:
|
||||
|
||||
def step_role_management(page: Page) -> None:
|
||||
"""角色保护与角色 CRUD。"""
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
if current_path(page) != "/system/roles":
|
||||
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
|
||||
|
||||
@@ -259,7 +206,7 @@ def step_role_management(page: Page) -> None:
|
||||
|
||||
def step_logs_query(page: Page) -> None:
|
||||
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
|
||||
open_menu(page, "日志查询")
|
||||
open_menu(page, "日志查询", "系统管理")
|
||||
if current_path(page) != "/system/logs":
|
||||
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
|
||||
|
||||
@@ -297,7 +244,7 @@ def step_logs_query(page: Page) -> None:
|
||||
|
||||
def step_permission_check(page: Page, admin_password: str) -> None:
|
||||
"""普通用户菜单隐藏与路由守卫。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
@@ -322,7 +269,7 @@ def step_permission_check(page: Page, admin_password: str) -> None:
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
for username in (NEW_USERNAME, VIEWER_USERNAME):
|
||||
row = find_user_row(page, username)
|
||||
if row.count() == 0:
|
||||
@@ -332,7 +279,7 @@ def step_cleanup(page: Page) -> None:
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "已删除", "数据清理")
|
||||
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
fill_filter(page, "关键词", NEW_ROLE_CODE)
|
||||
click_query(page)
|
||||
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import request from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 审批单状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回 */
|
||||
export const APPROVAL_STATUS = {
|
||||
PENDING_FINANCE: 1,
|
||||
PENDING_LEADER: 2,
|
||||
APPROVED: 3,
|
||||
REJECTED: 4,
|
||||
WITHDRAWN: 5,
|
||||
} as const
|
||||
|
||||
export function approvalStatusLabel(status: number): string {
|
||||
return (
|
||||
{
|
||||
1: '待财务复核',
|
||||
2: '待领导审核',
|
||||
3: '已通过',
|
||||
4: '已驳回',
|
||||
5: '已撤回',
|
||||
}[status] ?? String(status)
|
||||
)
|
||||
}
|
||||
|
||||
export function approvalStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' {
|
||||
return (
|
||||
({ 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ??
|
||||
'info'
|
||||
) as 'primary' | 'success' | 'danger' | 'info'
|
||||
}
|
||||
|
||||
/** 节点动作:1通过 2驳回 3自动通过(系统) 4撤回 */
|
||||
export function stepActionLabel(action: number): string {
|
||||
return ({ 1: '通过', 2: '驳回', 3: '自动通过', 4: '撤回' } as const)[action] ?? String(action)
|
||||
}
|
||||
|
||||
/** 节点名:0 仅用于撤回留痕 */
|
||||
export function stepNodeLabel(node: number): string {
|
||||
return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}`
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
id: number
|
||||
node: number
|
||||
action: number
|
||||
opinion: string | null
|
||||
operator_id: number | null
|
||||
operator_name: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface ApprovalOrder {
|
||||
id: number
|
||||
order_no: string
|
||||
/** 1二次分配方案 2月度结算(预留) */
|
||||
order_type: number
|
||||
ref_id: number
|
||||
refer_no: string
|
||||
status: number
|
||||
submitter_id: number | null
|
||||
submitter_name: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface ApprovalOrderDetail extends ApprovalOrder {
|
||||
steps: ApprovalStep[]
|
||||
}
|
||||
|
||||
export interface ApprovalQueryParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
/** 审批单号/引用单号,模糊 */
|
||||
keyword?: string
|
||||
status?: number
|
||||
order_type?: number
|
||||
}
|
||||
|
||||
export function listApprovals(params: ApprovalQueryParams): Promise<PageData<ApprovalOrder>> {
|
||||
return request({ url: '/v1/system/approvals', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getApproval(orderId: number): Promise<ApprovalOrderDetail> {
|
||||
return request({ url: `/v1/system/approvals/${orderId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function actApproval(
|
||||
orderId: number,
|
||||
data: { action: 1 | 2; opinion?: string | null },
|
||||
): Promise<ApprovalOrderDetail> {
|
||||
return request({ url: `/v1/system/approvals/${orderId}/action`, method: 'post', data })
|
||||
}
|
||||
@@ -50,9 +50,20 @@ export interface BusinessItem {
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
/** 业务详情:附产品明细行(编辑弹窗一次回显) */
|
||||
/** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */
|
||||
export interface SecondaryAllocationRow {
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
/** 百分比数字字符串(两位小数) */
|
||||
ratio: string
|
||||
}
|
||||
|
||||
/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */
|
||||
export interface BusinessDetail extends BusinessItem {
|
||||
items: BusinessItemRow[]
|
||||
secondary_allocations: SecondaryAllocationRow[]
|
||||
/** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */
|
||||
allocation_hint: string | null
|
||||
}
|
||||
|
||||
export interface BusinessQueryParams {
|
||||
@@ -96,6 +107,8 @@ export interface BusinessCreateParams {
|
||||
is_special?: number
|
||||
special_note?: string | null
|
||||
items: BusinessItemParams[]
|
||||
/** 二次分配方案(只出现在合同刚建立时;空=不设置) */
|
||||
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
@@ -111,6 +124,8 @@ export interface BusinessUpdateParams {
|
||||
is_special?: number
|
||||
special_note?: string | null
|
||||
items: BusinessItemParams[]
|
||||
/** 二次分配方案(全量替换;空数组=清空) */
|
||||
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import service, { request } from './request'
|
||||
|
||||
/** 公司信息(管理端) */
|
||||
export interface CompanySettings {
|
||||
company_name: string
|
||||
/** 未上传时为 null */
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */
|
||||
export interface PublicSettings {
|
||||
company_name: string
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<CompanySettings> {
|
||||
return request({ url: '/v1/system/settings', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateSettings(companyName: string): Promise<CompanySettings> {
|
||||
return request({
|
||||
url: '/v1/system/settings',
|
||||
method: 'put',
|
||||
data: { company_name: companyName },
|
||||
})
|
||||
}
|
||||
|
||||
/** logo 上传走独立的 FormData(multipart,非 JSON) */
|
||||
export function uploadLogo(file: File): Promise<CompanySettings> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return service.post('/v1/system/settings/logo', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点) */
|
||||
export function getPublicSettings(): Promise<PublicSettings> {
|
||||
return request({ url: '/v1/system/settings/public', method: 'get' })
|
||||
}
|
||||
@@ -31,12 +31,18 @@ export function useAppMenu() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||||
const userRoles = computed(() => userStore.roles)
|
||||
|
||||
const menuNodes = computed<MenuNode[]>(() =>
|
||||
router
|
||||
.getRoutes()
|
||||
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
||||
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
||||
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
|
||||
.filter(
|
||||
(record) =>
|
||||
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
|
||||
)
|
||||
.map((record) => ({
|
||||
path: record.path,
|
||||
title: record.meta.title ?? '',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。
|
||||
*
|
||||
* 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 →
|
||||
* 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。
|
||||
*/
|
||||
export function useDropdownOptions<T>(fetch: () => Promise<T[]>): {
|
||||
options: Ref<T[]>
|
||||
load: () => Promise<void>
|
||||
} {
|
||||
const options = ref([]) as Ref<T[]>
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
options.value = await fetch()
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
return { options, load }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 编辑中的行:editingId 非空时从当前页记录里找出行(回显兜底用)。
|
||||
*
|
||||
* 回款/发货/人员三页此前各持一份逐字相同的 computed(行对象上要展示
|
||||
* 「编辑中」的派生信息,如业务编号、停用岗位回显),收口后口径一处维护。
|
||||
*/
|
||||
export function useEditingRow<T extends { id: number }>(
|
||||
records: Ref<T[]>,
|
||||
editingId: Ref<number | null>,
|
||||
) {
|
||||
return computed(() =>
|
||||
editingId.value === null ? null : (records.value.find((r) => r.id === editingId.value) ?? null),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,9 @@ declare module 'vue-router' {
|
||||
title?: string
|
||||
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
||||
requiresAdmin?: boolean
|
||||
/** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)——
|
||||
* 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */
|
||||
allowedRoles?: string[]
|
||||
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
|
||||
menu?: boolean
|
||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||
@@ -64,49 +67,67 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/params/index.vue'),
|
||||
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
||||
},
|
||||
{
|
||||
path: '/system/settings',
|
||||
name: 'system-settings',
|
||||
component: () => import('@/views/system/settings/index.vue'),
|
||||
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
},
|
||||
{
|
||||
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
||||
path: '/system/approvals',
|
||||
name: 'system-approvals',
|
||||
component: () => import('@/views/system/approvals/index.vue'),
|
||||
meta: {
|
||||
title: '审批管理',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
order: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: () => import('@/views/system/logs/index.vue'),
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 9 },
|
||||
},
|
||||
{
|
||||
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
||||
path: '/base/personnel',
|
||||
name: 'base-personnel',
|
||||
component: () => import('@/views/base/personnel/index.vue'),
|
||||
meta: { title: '人员管理', group: '基础数据', order: 8 },
|
||||
meta: { title: '人员管理', group: '基础数据', order: 10 },
|
||||
},
|
||||
{
|
||||
path: '/base/products',
|
||||
name: 'base-products',
|
||||
component: () => import('@/views/base/products/index.vue'),
|
||||
meta: { title: '产品管理', group: '基础数据', order: 9 },
|
||||
meta: { title: '产品管理', group: '基础数据', order: 11 },
|
||||
},
|
||||
{
|
||||
path: '/base/customers',
|
||||
name: 'base-customers',
|
||||
component: () => import('@/views/base/customers/index.vue'),
|
||||
meta: { title: '客户管理', group: '基础数据', order: 10 },
|
||||
meta: { title: '客户管理', group: '基础数据', order: 12 },
|
||||
},
|
||||
{
|
||||
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
||||
path: '/business/businesses',
|
||||
name: 'business-businesses',
|
||||
component: () => import('@/views/business/businesses/index.vue'),
|
||||
meta: { title: '业务管理', group: '业务数据', order: 11 },
|
||||
meta: { title: '业务管理', group: '业务数据', order: 13 },
|
||||
},
|
||||
{
|
||||
path: '/business/shipments',
|
||||
name: 'business-shipments',
|
||||
component: () => import('@/views/business/shipments/index.vue'),
|
||||
meta: { title: '发货管理', group: '业务数据', order: 12 },
|
||||
meta: { title: '发货管理', group: '业务数据', order: 14 },
|
||||
},
|
||||
{
|
||||
path: '/business/receipts',
|
||||
name: 'business-receipts',
|
||||
component: () => import('@/views/business/receipts/index.vue'),
|
||||
meta: { title: '回款管理', group: '业务数据', order: 13 },
|
||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||
},
|
||||
{
|
||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
||||
@@ -114,7 +135,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/business/rating',
|
||||
name: 'business-rating',
|
||||
component: () => import('@/views/business/rating/index.vue'),
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 14 },
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -163,6 +184,12 @@ router.beforeEach(async (to) => {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
|
||||
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
||||
ElMessage.error('无权访问该页面')
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
|
||||
|
||||
const BYTE_HINT = '(中文一个字符约 3 字节)'
|
||||
|
||||
/**
|
||||
* 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。
|
||||
*
|
||||
* 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份
|
||||
* (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数
|
||||
* 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行
|
||||
* 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。
|
||||
*/
|
||||
/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */
|
||||
export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */
|
||||
export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */
|
||||
export const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
/** 计算 UTF-8 字节数 */
|
||||
export function passwordByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length
|
||||
|
||||
@@ -9,6 +9,8 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -107,9 +109,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
|
||||
const searchRoleOptions = computed(() =>
|
||||
@@ -139,7 +139,7 @@ const rules = computed<FormRules>(() => ({
|
||||
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
|
||||
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||||
salary_standard: [
|
||||
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
{ pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
],
|
||||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -101,15 +102,6 @@ function personnelLabel(option: PersonnelOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||||
|
||||
/** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出
|
||||
// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何
|
||||
// 带小数精度的历史记录在编辑时都会被前端永远拦下
|
||||
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态:数量用它排除「>0」语义,不走 float 比较 */
|
||||
const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -139,6 +131,69 @@ const form = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// ==================== 二次分配方案(合同刚建立时可录入;不强制合计 100%) ====================
|
||||
|
||||
interface AllocationDraft {
|
||||
personnel_id: number | undefined
|
||||
/** 百分比数字字符串(两位小数),与后端 NUMERIC(5,2) 同口径 */
|
||||
ratio: string
|
||||
}
|
||||
|
||||
const allocationRows = ref<AllocationDraft[]>([])
|
||||
|
||||
/** 合计(0-999.99 不做上限:需求原文不要求合计 100%);余额 = 100 - 合计 */
|
||||
const allocationTotal = computed(() => {
|
||||
let total = 0
|
||||
for (const row of allocationRows.value) {
|
||||
const value = Number(row.ratio)
|
||||
if (Number.isFinite(value)) total += value
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const allocationHint = computed(() => {
|
||||
if (allocationRows.value.length === 0) return ''
|
||||
const total = Number(allocationTotal.value.toFixed(2))
|
||||
if (total === 100) return `二次分配合计 ${total.toFixed(2)}%`
|
||||
const rest = Number((100 - total).toFixed(2))
|
||||
return `二次分配合计 ${total.toFixed(2)}%,余额 ${rest.toFixed(2)}%(归属待业务确认)`
|
||||
})
|
||||
|
||||
function addAllocationRow(): void {
|
||||
allocationRows.value.push({ personnel_id: undefined, ratio: '' })
|
||||
}
|
||||
|
||||
function removeAllocationRow(index: number): void {
|
||||
allocationRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 分配行校验:人员非重、比例 0-100 两位小数;空行(未选人员且比例为 0)自动忽略 */
|
||||
function validateAllocationRows(rows: AllocationDraft[]): string | null {
|
||||
const active = rows.filter((r) => r.personnel_id !== undefined || Number(r.ratio) > 0)
|
||||
const seen = new Set<number>()
|
||||
for (let i = 0; i < active.length; i += 1) {
|
||||
const row = active[i]
|
||||
const no = `第 ${i + 1} 个`
|
||||
if (row.personnel_id === undefined) return `${no}参与人未选择`
|
||||
if (seen.has(row.personnel_id)) return `${no}参与人重复`
|
||||
seen.add(row.personnel_id)
|
||||
const value = row.ratio.trim()
|
||||
if (!value) return `${no}分配比例未填写`
|
||||
if (!AMOUNT_PATTERN.test(value) || ZERO_PATTERN.test(value)) {
|
||||
return `${no}分配比例须为大于 0 的数字,最多两位小数`
|
||||
}
|
||||
if (Number(value) > 100) return `${no}分配比例不能超过 100%`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 提交行:空行剔除;人员/比例均按提交格式直出 */
|
||||
function buildAllocationParams(rows: AllocationDraft[]): { personnel_id: number; ratio: string }[] {
|
||||
return rows
|
||||
.filter((r) => r.personnel_id !== undefined)
|
||||
.map((r) => ({ personnel_id: r.personnel_id as number, ratio: r.ratio.trim() }))
|
||||
}
|
||||
|
||||
/** 默认落在首个类别上,避免空类别提交被后端拒 */
|
||||
function emptyItem(): ItemDraft {
|
||||
return {
|
||||
@@ -180,6 +235,7 @@ function openCreate(): void {
|
||||
remark: '',
|
||||
})
|
||||
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
||||
allocationRows.value = []
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
@@ -211,6 +267,10 @@ async function openEdit(row: BusinessItem): Promise<void> {
|
||||
cost_amount: it.cost_amount ?? '',
|
||||
guided_price: it.guided_price ?? '',
|
||||
}))
|
||||
allocationRows.value = (detail.secondary_allocations ?? []).map((a) => ({
|
||||
personnel_id: a.personnel_id,
|
||||
ratio: a.ratio,
|
||||
}))
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
@@ -274,6 +334,11 @@ async function handleSubmit(): Promise<void> {
|
||||
ElMessage.warning(rowError)
|
||||
return
|
||||
}
|
||||
const allocError = validateAllocationRows(allocationRows.value)
|
||||
if (allocError) {
|
||||
ElMessage.warning(allocError)
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -285,6 +350,7 @@ async function handleSubmit(): Promise<void> {
|
||||
is_special: form.is_special,
|
||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||
items: buildItemParams(itemRows.value),
|
||||
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||
remark: form.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
@@ -297,6 +363,7 @@ async function handleSubmit(): Promise<void> {
|
||||
is_special: form.is_special,
|
||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||
items: buildItemParams(itemRows.value),
|
||||
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||
remark: form.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('业务已创建')
|
||||
@@ -429,8 +496,11 @@ function personnelName(id: number): string {
|
||||
// ==================== 详情抽屉 ====================
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerRowId = ref<number | null>(null)
|
||||
const drawerRow = ref<BusinessItem | null>(null)
|
||||
// 单一状态源:drawerRowId 派生自 drawerRow(此前与 ownershipLogsId 三份并存、
|
||||
// openDrawer 手工同步——新增写入路径漏更新其一就会出现「标题是 A 业务、
|
||||
// 留痕表拉到 B 业务」且无报错)
|
||||
const drawerRowId = computed(() => drawerRow.value?.id ?? null)
|
||||
const drawerTitle = computed(() =>
|
||||
drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情',
|
||||
)
|
||||
@@ -467,12 +537,10 @@ async function refreshDrawer(id: number): Promise<void> {
|
||||
|
||||
function openDrawer(row: BusinessItem): void {
|
||||
drawerRow.value = row
|
||||
drawerRowId.value = row.id
|
||||
drawerVisible.value = true
|
||||
detail.value = null
|
||||
statusLogs.value = []
|
||||
gp.value = null
|
||||
ownershipLogsId.value = row.id
|
||||
ownershipLogPageNum.value = 1
|
||||
void loadDrawerData(row.id)
|
||||
void loadOwnershipLogs()
|
||||
@@ -491,7 +559,7 @@ const {
|
||||
onSizeChange: onOwnershipLogSizeChange,
|
||||
} = usePagedList<businessApi.OwnershipLogItem, object>(
|
||||
(params) => {
|
||||
const id = ownershipLogsId.value
|
||||
const id = drawerRowId.value
|
||||
// 抽屉未指定业务时兜底空数据,避免发出无意义请求
|
||||
return id === null
|
||||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||||
@@ -500,8 +568,6 @@ const {
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
const ownershipLogsId = ref<number | null>(null)
|
||||
|
||||
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
|
||||
function ownershipTransition(row: businessApi.OwnershipLogItem): string {
|
||||
const oldOwner = row.old_owner_name ?? '(无)'
|
||||
@@ -761,6 +827,58 @@ onMounted(() => {
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<!-- 二次分配方案:合同刚建立时可录入(编辑为全量替换);不强制合计 100% -->
|
||||
<div class="items-head">
|
||||
<span class="items-title">
|
||||
二次分配方案
|
||||
<span class="alloc-note">(可选;比例不要求合计 100%,分摊计算待业务确认后另批)</span>
|
||||
</span>
|
||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||
</div>
|
||||
<el-table :data="allocationRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="参与业务员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.personnel_id"
|
||||
placeholder="选择参与人"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in personnelOptions"
|
||||
:key="option.id"
|
||||
:label="personnelLabel(option)"
|
||||
:value="option.id"
|
||||
:disabled="option.status !== 1"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分配比例(%)" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.ratio"
|
||||
placeholder="两位小数,0-100"
|
||||
maxlength="6"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeAllocationRow($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="alloc-empty">未设置二次分配 —— 该业务的全部绩效归负责营销人员。</span>
|
||||
</template>
|
||||
</el-table>
|
||||
<div v-if="allocationRows.length > 0" class="alloc-total" :class="{ 'is-full': allocationTotal === 100 }">
|
||||
{{ allocationHint }}
|
||||
</div>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||||
@@ -1218,6 +1336,32 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alloc-note {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.alloc-total {
|
||||
margin: -8px 0 16px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
background: #f4f5f8;
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.alloc-total.is-full {
|
||||
color: var(--ys-indigo);
|
||||
background: rgba(46, 75, 168, 0.07);
|
||||
}
|
||||
|
||||
.alloc-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.items-table {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -63,21 +66,17 @@ interface BusinessOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function businessLabel(option: BusinessOption): string {
|
||||
return option.name ? `${option.no} · ${option.name}` : option.no
|
||||
@@ -85,9 +84,6 @@ function businessLabel(option: BusinessOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
/** 金额形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
|
||||
const shipmentLoading = ref(false)
|
||||
|
||||
/** 编辑中的回款(回显业务编号/批次语义用) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
|
||||
function shipmentLabel(row: ShipmentItem): string {
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -55,22 +58,18 @@ interface BusinessOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
status: b.status,
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
|
||||
function businessLabel(option: BusinessOption): string {
|
||||
@@ -80,13 +79,6 @@ function businessLabel(option: BusinessOption): string {
|
||||
|
||||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||||
|
||||
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
|
||||
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
|
||||
// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位
|
||||
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
|
||||
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
|
||||
const ZERO_PATTERN = /^0+(\.0+)?$/
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
|
||||
interface ItemDraft {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { getPublicSettings } from '@/api/settings'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -23,6 +24,20 @@ const form = reactive({
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
|
||||
const brandName = ref('衍水集团')
|
||||
const brandLogo = ref<string | null>(null)
|
||||
|
||||
async function loadBrand(): Promise<void> {
|
||||
try {
|
||||
const data = await getPublicSettings()
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@@ -72,7 +87,10 @@ async function handleSubmit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshCaptcha)
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
loadBrand()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
actApproval,
|
||||
approvalStatusLabel,
|
||||
approvalStatusTagType,
|
||||
getApproval,
|
||||
listApprovals,
|
||||
stepActionLabel,
|
||||
stepNodeLabel,
|
||||
type ApprovalOrder,
|
||||
type ApprovalOrderDetail,
|
||||
} from '@/api/approvals'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 审批管理(2026-10-15 复核审批补充需求)。
|
||||
*
|
||||
* 提单不在此页:创建/编辑业务带二次分配方案时由后端自动生成审批单
|
||||
* (开关关闭=自动通过留痕,开启=待财务复核)。本页负责查看与两级
|
||||
* 节点动作;节点准入由后端按角色与单据状态双重校验。
|
||||
*
|
||||
* 签名元素是抽屉里的竖排节点时间线 —— 财务复核 → 领导审核的处理链
|
||||
* 一眼可读(节点/动作/操作人/意见按发生顺序排列)。
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
order_type: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
||||
listApprovals,
|
||||
() => ({
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
status: filters.status,
|
||||
order_type: filters.order_type,
|
||||
}),
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
filters.order_type = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 详情抽屉(节点时间线) ====================
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ApprovalOrderDetail | null>(null)
|
||||
|
||||
async function openDetail(row: ApprovalOrder): Promise<void> {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getApproval(row.id)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 节点动作 ====================
|
||||
|
||||
const actingId = ref<number | null>(null)
|
||||
const actionSubmitting = ref(false)
|
||||
const actionForm = reactive({
|
||||
action: 1 as 1 | 2,
|
||||
opinion: '',
|
||||
})
|
||||
|
||||
/** 仅在途单展示动作表单;动作随单据状态定(1 待财务 / 2 待领导) */
|
||||
const canAct = (order: ApprovalOrderDetail): boolean =>
|
||||
order.status === 1 || order.status === 2
|
||||
|
||||
function openAction(order: ApprovalOrderDetail, action: 1 | 2): void {
|
||||
actingId.value = order.id
|
||||
actionForm.action = action
|
||||
actionForm.opinion = ''
|
||||
}
|
||||
|
||||
async function submitAction(): Promise<void> {
|
||||
if (actingId.value === null) return
|
||||
if (actionForm.action === 2 && !actionForm.opinion.trim()) {
|
||||
ElMessage.warning('驳回必须填写审批意见')
|
||||
return
|
||||
}
|
||||
actionSubmitting.value = true
|
||||
try {
|
||||
const run = () =>
|
||||
actApproval(actingId.value as number, {
|
||||
action: actionForm.action,
|
||||
opinion: actionForm.opinion.trim() || null,
|
||||
})
|
||||
await confirmAndRun(run, {
|
||||
successMessage: actionForm.action === 1 ? '已通过' : '已驳回',
|
||||
})
|
||||
// 动作后刷新抽屉与列表(状态可能已流转或终态)
|
||||
detail.value = await getApproval(actingId.value)
|
||||
actingId.value = null
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
actionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="审批单号 / 业务编号"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="待财务复核" :value="1" />
|
||||
<el-option label="待领导审核" :value="2" />
|
||||
<el-option label="已通过" :value="3" />
|
||||
<el-option label="已驳回" :value="4" />
|
||||
<el-option label="已撤回" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="二次分配方案" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="order_no" label="审批单号" min-width="150" />
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
|
||||
{{ approvalStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submitter_name" label="提交人" width="110" />
|
||||
<el-table-column label="提交时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
v-model:page-num="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@change="load"
|
||||
@size-change="onSizeChange"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-no">{{ detail.order_no }}</span>
|
||||
<el-tag
|
||||
:type="approvalStatusTagType(detail.status)"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
{{ approvalStatusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>业务编号:{{ detail.refer_no }}</span>
|
||||
<span>提交人:{{ detail.submitter_name }}</span>
|
||||
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="step in detail.steps"
|
||||
:key="step.id"
|
||||
class="timeline-row"
|
||||
:class="{ 'is-withdraw': step.node === 0 }"
|
||||
>
|
||||
<div class="timeline-rail">
|
||||
<span
|
||||
class="timeline-dot"
|
||||
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
|
||||
></span>
|
||||
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-node">{{ stepNodeLabel(step.node) }}</span>
|
||||
<span class="timeline-action" :class="{ 'is-reject-text': step.action === 2 }">
|
||||
{{ stepActionLabel(step.action) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
{{ step.operator_name }} · {{ formatDateTime(step.create_time) }}
|
||||
</div>
|
||||
<p v-if="step.opinion" class="timeline-opinion">「{{ step.opinion }}」</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detail.steps.length === 0" class="timeline-empty">暂无节点记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 节点动作:仅在途单展示 -->
|
||||
<template v-if="canAct(detail)">
|
||||
<div class="action-bar">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="actionSubmitting && actionForm.action === 1"
|
||||
@click="openAction(detail, 1)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
:loading="actionSubmitting && actionForm.action === 2"
|
||||
@click="openAction(detail, 2)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
|
||||
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
|
||||
<el-input
|
||||
v-model="actionForm.opinion"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
placeholder="审批意见将随节点留痕"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">
|
||||
确认{{ actionForm.action === 1 ? '通过' : '驳回' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
<p v-else class="readonly-note">该单已结束,节点记录只读。</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-no {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||||
.timeline {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
background: var(--ys-indigo);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-dot.is-auto {
|
||||
background: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.timeline-dot.is-reject {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
min-height: 26px;
|
||||
background: rgba(28, 35, 51, 0.14);
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.timeline-action {
|
||||
font-size: 12px;
|
||||
color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.timeline-action.is-reject-text {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.timeline-opinion {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink);
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.is-withdraw .timeline-node {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.action-form {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.readonly-note {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '@/api/params'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -189,25 +190,20 @@ async function submitAll(): Promise<void> {
|
||||
// ==================== 版本管理抽屉 ====================
|
||||
|
||||
const versionVisible = ref(false)
|
||||
const batches = ref<ParamBatch[]>([])
|
||||
const batchesTotal = ref(0)
|
||||
const batchesPage = ref(1)
|
||||
const batchesPageSize = ref(10)
|
||||
const batchesLoading = ref(false)
|
||||
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
batchesLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
|
||||
batches.value = page.list
|
||||
batchesTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
batchesLoading.value = false
|
||||
}
|
||||
}
|
||||
// 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
|
||||
// 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
|
||||
const {
|
||||
records: batches,
|
||||
total: batchesTotal,
|
||||
pageNum: batchesPage,
|
||||
pageSize: batchesPageSize,
|
||||
loading: batchesLoading,
|
||||
load: loadBatches,
|
||||
onPageChange: onBatchesPageChange,
|
||||
onSizeChange: onBatchesSizeChange,
|
||||
} = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
|
||||
|
||||
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
|
||||
if (detailCache[batch.id]) return detailCache[batch.id]
|
||||
@@ -299,11 +295,21 @@ function openVersionDrawer(): void {
|
||||
|
||||
const historyVisible = ref(false)
|
||||
const historyItem = ref<ParamCatalogItem | null>(null)
|
||||
const historyRows = ref<ParamHistory[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const historyPage = ref(1)
|
||||
const historyPageSize = ref(10)
|
||||
const historyLoading = ref(false)
|
||||
|
||||
// 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
|
||||
const {
|
||||
records: historyRows,
|
||||
total: historyTotal,
|
||||
pageNum: historyPage,
|
||||
pageSize: historyPageSize,
|
||||
loading: historyLoading,
|
||||
load: loadHistory,
|
||||
onPageChange: onHistoryPageChange,
|
||||
onSizeChange: onHistorySizeChange,
|
||||
} = usePagedList<ParamHistory, Record<string, never>>(
|
||||
(params) => paramsApi.getParamHistory(historyItem.value!.param_code, params),
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
function openHistory(item: ParamCatalogItem): void {
|
||||
historyItem.value = item
|
||||
@@ -312,23 +318,6 @@ function openHistory(item: ParamCatalogItem): void {
|
||||
void loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
if (!historyItem.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
|
||||
pageNum: historyPage.value,
|
||||
pageSize: historyPageSize.value,
|
||||
})
|
||||
historyRows.value = page.list
|
||||
historyTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 计算预览抽屉 ====================
|
||||
|
||||
const previewVisible = ref(false)
|
||||
@@ -667,8 +656,8 @@ onMounted(() => {
|
||||
:total="batchesTotal"
|
||||
:page-num="batchesPage"
|
||||
:page-size="batchesPageSize"
|
||||
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
||||
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
|
||||
@page-change="onBatchesPageChange"
|
||||
@size-change="onBatchesSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@@ -692,8 +681,8 @@ onMounted(() => {
|
||||
:total="historyTotal"
|
||||
:page-num="historyPage"
|
||||
:page-size="historyPageSize"
|
||||
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
||||
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
|
||||
@page-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import type { UploadRawFile } from 'element-plus'
|
||||
|
||||
import * as settingsApi from '@/api/settings'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
||||
*
|
||||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
||||
*/
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
company_name: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
company_name: [
|
||||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||||
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const logoUrl = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
try {
|
||||
const data = await settingsApi.getSettings()
|
||||
form.company_name = data.company_name
|
||||
logoUrl.value = data.logo_url
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = await settingsApi.updateSettings(form.company_name.trim())
|
||||
form.company_name = data.company_name
|
||||
ElMessage.success('已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function beforeUpload(file: UploadRawFile): boolean {
|
||||
const okType = ['image/png', 'image/jpeg'].includes(file.type)
|
||||
if (!okType) {
|
||||
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
||||
return false
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
ElMessage.warning('logo 文件不能超过 2MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||
uploading.value = true
|
||||
try {
|
||||
const data = await settingsApi.uploadLogo(options.file)
|
||||
logoUrl.value = data.logo_url
|
||||
ElMessage.success('logo 已更新')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
|
||||
const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="settings-grid">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span class="card-title">公司信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="88px"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input
|
||||
v-model="form.company_name"
|
||||
maxlength="64"
|
||||
placeholder="展示在登录页与导出页眉"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="logo">
|
||||
<div class="logo-field">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:before-upload="beforeUpload"
|
||||
accept="image/png,image/jpeg"
|
||||
>
|
||||
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
||||
<span v-else class="logo-plus">+</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
|
||||
<el-card shadow="never" class="preview-card">
|
||||
<template #header>
|
||||
<span class="card-title">登录页预览</span>
|
||||
<span class="preview-note">保存后对所有人生效</span>
|
||||
</template>
|
||||
<div class="login-mock">
|
||||
<div class="mock-brand">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
||||
<div class="mock-name">{{ previewName }}</div>
|
||||
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
|
||||
<span class="mock-waterline"></span>
|
||||
</div>
|
||||
<div class="mock-field"></div>
|
||||
<div class="mock-field"></div>
|
||||
<div class="mock-submit">登 录</div>
|
||||
</div>
|
||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, #f7f8fb, #f1f3f8);
|
||||
border: 1px dashed rgba(28, 35, 51, 0.22);
|
||||
border-radius: 10px;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.logo-box:hover {
|
||||
border-color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.logo-box img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-plus {
|
||||
font-size: 26px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* ── 登录页实景预览:按登录卡视觉 0.8 缩比重建(非 iframe 截图) ── */
|
||||
.login-mock {
|
||||
padding: 22px 26px 24px;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(28, 35, 51, 0.04),
|
||||
0 10px 26px rgba(28, 35, 51, 0.08);
|
||||
}
|
||||
|
||||
.mock-brand {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mock-logo {
|
||||
display: block;
|
||||
max-width: 64px;
|
||||
max-height: 40px;
|
||||
margin: 0 auto 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mock-name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.34em;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.mock-slogan {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.mock-waterline {
|
||||
display: block;
|
||||
width: 34px;
|
||||
height: 2px;
|
||||
margin: 10px auto 0;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0));
|
||||
}
|
||||
|
||||
.mock-field {
|
||||
height: 32px;
|
||||
margin-top: 12px;
|
||||
background: #f4f5f8;
|
||||
border: 1px solid rgba(28, 35, 51, 0.07);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mock-submit {
|
||||
height: 32px;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
letter-spacing: 0.4em;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
background: var(--ys-indigo);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-caption {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user