Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a536608b53 | ||
|
|
ccfd20651b | ||
|
|
48c0a86279 | ||
|
|
e48cfb752d | ||
|
|
028a94a6eb | ||
|
|
a89d540e3f | ||
|
|
7a99994d17 | ||
|
|
45c268b146 | ||
|
|
828b57350a | ||
|
|
76ded6d5ec | ||
|
|
7a460cb682 | ||
|
|
cb4f38dfd9 | ||
|
|
35fe37fb0c | ||
|
|
7008de0c69 | ||
|
|
2652397720 | ||
|
|
0e8c5713fa | ||
|
|
238a38c356 | ||
|
|
a1217f4482 | ||
|
|
e7e47d88bc | ||
|
|
22a2872100 | ||
|
|
df1a3839a1 | ||
|
|
a5c86640e2 | ||
|
|
30a179fc4c | ||
|
|
1c93ea0a4b | ||
|
|
fc8a42bcd9 | ||
|
|
b5e27810fd | ||
|
|
927592b472 | ||
|
|
74f1ef1cf9 | ||
|
|
8ef26e22fb | ||
|
|
680661a80b | ||
|
|
efed29647c | ||
|
|
e95dc4b2de | ||
|
|
acf87fdfdc |
No files matched your search
@@ -59,3 +59,8 @@ frontend/e2e/*.png
|
||||
backup/
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# 运行期上传物(公司 logo 等):与构建产物隔离,不入版本库
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
@@ -0,0 +1,632 @@
|
||||
"""V7 核算台账/月结/调整单四表与调整单开关种子
|
||||
|
||||
Revision ID: 3bca400421c6
|
||||
Revises: 79bcc5aa98d4
|
||||
Create Date: 2026-10-16 15:54:53.000890
|
||||
|
||||
"""
|
||||
|
||||
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 = "3bca400421c6"
|
||||
down_revision: str | Sequence[str] | None = "79bcc5aa98d4"
|
||||
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(
|
||||
"monthly_settlements",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"settlement_month",
|
||||
sa.String(length=7),
|
||||
nullable=False,
|
||||
comment="结算月份(YYYY-MM;全表唯一)",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
|
||||
),
|
||||
sa.Column(
|
||||
"total_performance",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="绩效金额合计(可负)",
|
||||
),
|
||||
sa.Column(
|
||||
"total_payable",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="应付绩效合计(可负)",
|
||||
),
|
||||
sa.Column(
|
||||
"person_count", mysql.TINYINT(), server_default="0", nullable=False, comment="结算人数"
|
||||
),
|
||||
sa.Column(
|
||||
"snapshot_json",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)",
|
||||
),
|
||||
sa.Column(
|
||||
"approval_order_no",
|
||||
sa.String(length=32),
|
||||
nullable=True,
|
||||
comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)",
|
||||
),
|
||||
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("locked_at", sa.DateTime(), nullable=True, comment="锁定时间(status=3 时回填)"),
|
||||
sa.Column(
|
||||
"locked_by", sa.String(length=64), nullable=True, 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_monthly_settlements_submitter_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_monthly_settlements")),
|
||||
sa.UniqueConstraint(
|
||||
"settlement_month", name=op.f("uk_monthly_settlements_settlement_month")
|
||||
),
|
||||
comment="月度结算表(按月一行;通过即锁定,锁定后走调整单)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"adjustment_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="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
|
||||
),
|
||||
sa.Column(
|
||||
"settlement_month",
|
||||
sa.String(length=7),
|
||||
nullable=False,
|
||||
comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)",
|
||||
),
|
||||
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("direction", mysql.TINYINT(), nullable=False, comment="方向:1补发 2扣减"),
|
||||
sa.Column(
|
||||
"amount",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=False,
|
||||
comment="调整金额(恒正,方向表达语义)",
|
||||
),
|
||||
sa.Column(
|
||||
"reason",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="扣补说明(必填,审批与追溯锚点)",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
|
||||
),
|
||||
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(
|
||||
["personnel_id"],
|
||||
["personnel.id"],
|
||||
name=op.f("fk_adjustment_orders_personnel_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["submitter_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_adjustment_orders_submitter_id"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_adjustment_orders")),
|
||||
sa.UniqueConstraint("order_no", name=op.f("uk_adjustment_orders_order_no")),
|
||||
comment="调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"settlement_items",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"settlement_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="月结ID(monthly_settlements.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(
|
||||
"role_code", sa.String(length=32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
|
||||
),
|
||||
sa.Column(
|
||||
"calc_type",
|
||||
mysql.TINYINT(),
|
||||
nullable=False,
|
||||
comment="核算类型:1单笔回款 2经理团队聚合",
|
||||
),
|
||||
sa.Column(
|
||||
"performance_sum",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=False,
|
||||
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
|
||||
),
|
||||
sa.Column(
|
||||
"payable_sum",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=False,
|
||||
comment="应付绩效合计(可负)",
|
||||
),
|
||||
sa.Column(
|
||||
"basic_amount",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="月工资标准(personnel.salary_standard 当月快照)",
|
||||
),
|
||||
sa.Column(
|
||||
"salary_mode",
|
||||
sa.String(length=16),
|
||||
nullable=True,
|
||||
comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE",
|
||||
),
|
||||
sa.Column(
|
||||
"net_amount",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="净工资 = 绩效±基本工资(按口径;试用期 0)",
|
||||
),
|
||||
sa.Column(
|
||||
"is_probation",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||
),
|
||||
sa.Column(
|
||||
"probation_note",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="试用期说明(年累计超阈值部分年底一次性结算)",
|
||||
),
|
||||
sa.Column(
|
||||
"snapshot_json", sa.JSON(), nullable=True, comment="个人明细快照(台账行引用列表)"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["personnel_id"],
|
||||
["personnel.id"],
|
||||
name=op.f("fk_settlement_items_personnel_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["settlement_id"],
|
||||
["monthly_settlements.id"],
|
||||
name=op.f("fk_settlement_items_settlement_id"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_settlement_items")),
|
||||
comment="月结明细表(按人固化;支持负数工资;锁定后只读)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"performance_ledgers",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"receipt_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||
),
|
||||
sa.Column(
|
||||
"calc_version",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||
),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||
),
|
||||
sa.Column(
|
||||
"calc_type",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||
),
|
||||
sa.Column(
|
||||
"business_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||
),
|
||||
sa.Column(
|
||||
"business_no",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="业务编号(冗余固化,列表免联查)",
|
||||
),
|
||||
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(
|
||||
"role_code",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="岗位编码(business_roles.role_code,计提公式锚点)",
|
||||
),
|
||||
sa.Column(
|
||||
"customer_type",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="客户分类:personal/manager_personal/enterprise",
|
||||
),
|
||||
sa.Column(
|
||||
"product_category",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||
),
|
||||
sa.Column("ship_date", sa.Date(), nullable=True, comment="发货日期(回款天数基准)"),
|
||||
sa.Column(
|
||||
"receipt_date",
|
||||
sa.Date(),
|
||||
nullable=False,
|
||||
comment="回款日期(参数版本匹配日,裁决 #11)",
|
||||
),
|
||||
sa.Column(
|
||||
"payment_days",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)",
|
||||
),
|
||||
sa.Column(
|
||||
"rate_text",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||
),
|
||||
sa.Column(
|
||||
"converted_profit",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="折算毛利润额(可负;混合业务为各行汇总)",
|
||||
),
|
||||
sa.Column(
|
||||
"performance_amount",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="绩效金额(可负,裁决:负绩效照算不归零)",
|
||||
),
|
||||
sa.Column(
|
||||
"barter_applied",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||
),
|
||||
sa.Column(
|
||||
"weight_r",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment="支付权重 R(回款占销售额百分比向下取整)",
|
||||
),
|
||||
sa.Column(
|
||||
"payment_weight",
|
||||
sa.Numeric(precision=7, scale=6),
|
||||
nullable=True,
|
||||
comment="支付权重系数 R×R(展示用)",
|
||||
),
|
||||
sa.Column(
|
||||
"payable_amount",
|
||||
sa.Numeric(precision=14, scale=2),
|
||||
nullable=True,
|
||||
comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)",
|
||||
),
|
||||
sa.Column(
|
||||
"settlement_month",
|
||||
sa.String(length=7),
|
||||
nullable=False,
|
||||
comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)",
|
||||
),
|
||||
sa.Column(
|
||||
"is_probation",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="试用期:1照算不发放 0否",
|
||||
),
|
||||
sa.Column(
|
||||
"missing_cost",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||
),
|
||||
sa.Column(
|
||||
"snapshot_json",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="计算快照(所用参数版本与行级折算明细,重算可回溯)",
|
||||
),
|
||||
sa.Column(
|
||||
"settlement_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||
),
|
||||
sa.Column(
|
||||
"calc_reason",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="核算/重算原因(重算必填留痕)",
|
||||
),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True, 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(
|
||||
["personnel_id"],
|
||||
["personnel.id"],
|
||||
name=op.f("fk_performance_ledgers_personnel_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["receipt_id"],
|
||||
["receipts.id"],
|
||||
name=op.f("fk_performance_ledgers_receipt_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["settlement_id"],
|
||||
["monthly_settlements.id"],
|
||||
name=op.f("fk_performance_ledgers_settlement_id"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_performance_ledgers")),
|
||||
sa.UniqueConstraint(
|
||||
"receipt_id", "calc_version", name="uk_performance_ledgers_receipt_id_calc_version"
|
||||
),
|
||||
comment="绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_performance_ledgers_business_id", "performance_ledgers", ["business_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"idx_performance_ledgers_month_active",
|
||||
"performance_ledgers",
|
||||
["settlement_month", "is_active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_performance_ledgers_month_personnel_type",
|
||||
"performance_ledgers",
|
||||
["settlement_month", "personnel_id", "calc_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_performance_ledgers_personnel_id",
|
||||
"performance_ledgers",
|
||||
["personnel_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"approval_orders",
|
||||
"order_type",
|
||||
existing_type=mysql.TINYINT(),
|
||||
comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||
existing_comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# ---- 种子:调整单审批开关(V6 写法先例;definitions id 19 / values id 22)----
|
||||
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": 19,
|
||||
"param_code": "approval.adjustment.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=走财务复核→领导审核审批流。"
|
||||
"调整单是已锁定月份补发/扣减的唯一通道"
|
||||
),
|
||||
"sort_order": 19,
|
||||
"status": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
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": 22,
|
||||
"param_id": 19,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "0",
|
||||
"batch_id": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚迁移。
|
||||
|
||||
整表回滚只保留 `op.drop_table`(V1/V3/V6 先例:外键唯一支撑索引单独
|
||||
drop 会报 MySQL 1553)。行级种子(param_definitions 19 / param_values 22)
|
||||
的宿主表由 V3 建、本迁移只插行,须显式 DELETE 否则重放 upgrade 撞唯一键。
|
||||
删除顺序:先删引用方(ledger/items),再删被引用方(头表/调整单)。
|
||||
"""
|
||||
op.drop_table("performance_ledgers")
|
||||
op.drop_table("settlement_items")
|
||||
op.drop_table("adjustment_orders")
|
||||
op.drop_table("monthly_settlements")
|
||||
op.alter_column(
|
||||
"approval_orders",
|
||||
"order_type",
|
||||
existing_type=mysql.TINYINT(),
|
||||
comment="审批对象类型:1二次分配方案 2月度结算(预留)",
|
||||
existing_comment="审批对象类型:1二次分配方案 2月度结算 3调整单",
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.execute("DELETE FROM param_values WHERE id = 22")
|
||||
op.execute("DELETE FROM param_definitions WHERE id = 19")
|
||||
@@ -0,0 +1,441 @@
|
||||
"""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")),
|
||||
sa.UniqueConstraint(
|
||||
"business_id",
|
||||
"personnel_id",
|
||||
name="uk_business_secondary_allocations_business_personnel",
|
||||
),
|
||||
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')")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
|
||||
|
||||
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
|
||||
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
|
||||
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
|
||||
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
|
||||
|
||||
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
|
||||
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
|
||||
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
|
||||
时会无条件输出 DDL,无法携带存在性判断。
|
||||
|
||||
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
|
||||
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "ba0217f5091b"
|
||||
down_revision: str | None = "c7d2e5a81f30"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
|
||||
op.execute(
|
||||
"SET @uk_exists := ("
|
||||
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
|
||||
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = 'business_secondary_allocations' "
|
||||
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"SET @ddl := IF(@uk_exists = 0, "
|
||||
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
|
||||
"UNIQUE (business_id, personnel_id)', "
|
||||
"'DO 0')"
|
||||
)
|
||||
op.execute("PREPARE stmt FROM @ddl")
|
||||
op.execute("EXECUTE stmt")
|
||||
op.execute("DEALLOCATE PREPARE stmt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
|
||||
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
|
||||
"""
|
||||
@@ -0,0 +1,193 @@
|
||||
"""V8:业务答复口径落实(2026-10-19 回执)。
|
||||
|
||||
四件事,全部源于《待澄清问题清单》2026-10-19 答复:
|
||||
|
||||
1. 审批开关默认值翻转(#5/#7 答复「默认开启」):param_definitions 的
|
||||
default_value 更新为 '1';param_values **插入生效日 2026-10-19 的新行**
|
||||
而非改历史行——参数体系时点化(按业务日期回溯),直接改种子行会篡改
|
||||
10-19 之前的口径历史。
|
||||
2. 撤销试用期统一工资参数(#16 答复「按人员档案逐人维护」):删除
|
||||
probation.salary.basic 的定义与版本行;月结基本工资已直接读
|
||||
personnel.salary_standard(该参数自 M3 ④ 实现起即无业务消费点)。
|
||||
3. 新增经理个人户聚合基数开关(#12 答复「作为参数配置,手动调整是否
|
||||
纳入」):manager.personal.included,默认 0=不计入(维持暂口径)。
|
||||
4. 绩效台账唯一键加 personnel_id(二次分配分摊铺路,澄清 #1 答复
|
||||
「分配绩效」):分摊后同一回款同版本按人各落一行(负责人余额行 +
|
||||
参与人比例行),uk(receipt_id, calc_version) 会撞;扩为三列后聚合行
|
||||
(receipt_id NULL)依旧不受 MySQL 唯一索引约束。
|
||||
|
||||
迁移 downgrade 只保留 drop_table/行级种子反向 DELETE(V6 教训:逐个
|
||||
drop_index 会撞外键支撑索引 errno 1553)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c7d2e5a81f30"
|
||||
down_revision: str | None = "3bca400421c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""种子行级变更 + 台账唯一键扩展(无表结构新增/删除)。"""
|
||||
# 1. 审批开关默认值翻转(#5/#7:默认开启,业务可关闭)
|
||||
op.execute(
|
||||
"UPDATE param_definitions SET default_value = '1' "
|
||||
"WHERE id IN (17, 18)"
|
||||
)
|
||||
# 时点化生效行:插新行不改历史(10-19 前仍按 0 解析)
|
||||
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": 23,
|
||||
"param_id": 17,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 10, 19),
|
||||
"value_text": "1",
|
||||
"batch_id": 1,
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"param_id": 18,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 10, 19),
|
||||
"value_text": "1",
|
||||
"batch_id": 1,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# 2. 撤销试用期统一工资参数(#16:按人员档案 personnel.salary_standard 逐人维护)
|
||||
op.execute("DELETE FROM param_values WHERE id = 19")
|
||||
op.execute("DELETE FROM param_definitions WHERE id = 16")
|
||||
|
||||
# 3. 经理个人户聚合基数开关(#12:参数化,默认不计入)
|
||||
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": 20,
|
||||
"param_code": "manager.personal.included",
|
||||
"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": (
|
||||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||
"0=不计入(默认,暂口径) 1=计入"
|
||||
),
|
||||
"sort_order": 20,
|
||||
"status": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
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": 25,
|
||||
"param_id": 20,
|
||||
"dimension_value": "",
|
||||
"effective_date": date(2026, 1, 1),
|
||||
"value_text": "0",
|
||||
"batch_id": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# 4. 台账唯一键扩为三列(分摊拆行:同回款同版本按人一行)
|
||||
# 顺序要紧:旧 uk 是 fk_performance_ledgers_receipt_id 的唯一支撑索引,
|
||||
# 先删会撞 errno 1553(外键无支撑索引);先建新键(receipt_id 仍居最左)
|
||||
# 再做替换,两键短暂并存无冲突。
|
||||
op.create_unique_constraint(
|
||||
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||
"performance_ledgers",
|
||||
["receipt_id", "calc_version", "personnel_id"],
|
||||
)
|
||||
op.drop_constraint(
|
||||
"uk_performance_ledgers_receipt_id_calc_version",
|
||||
"performance_ledgers",
|
||||
type_="unique",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""反向:唯一键复原 + 种子行级回退(顺序与 upgrade 相反)。
|
||||
|
||||
试用期参数(definitions 16 / values 19)的原始种子在 V3,按 V6 downgrade
|
||||
的行级反向惯例显式重插,保证可重放。
|
||||
"""
|
||||
# 与 upgrade 同因的顺序:先建回旧键再删新键(旧键支撑 receipts 外键)
|
||||
op.create_unique_constraint(
|
||||
"uk_performance_ledgers_receipt_id_calc_version",
|
||||
"performance_ledgers",
|
||||
["receipt_id", "calc_version"],
|
||||
)
|
||||
op.drop_constraint(
|
||||
"uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||
"performance_ledgers",
|
||||
type_="unique",
|
||||
)
|
||||
|
||||
op.execute("DELETE FROM param_values WHERE id = 25")
|
||||
op.execute("DELETE FROM param_definitions WHERE id = 20")
|
||||
|
||||
op.execute(
|
||||
"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 "
|
||||
"(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', "
|
||||
"'元', 'NONE', NULL, '3000', '0', NULL, "
|
||||
"'试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16, 1)"
|
||||
)
|
||||
op.execute(
|
||||
"INSERT INTO param_values (id, param_id, dimension_value, effective_date, "
|
||||
"value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1)"
|
||||
)
|
||||
|
||||
op.execute("DELETE FROM param_values WHERE id IN (23, 24)")
|
||||
op.execute(
|
||||
"UPDATE param_definitions SET default_value = '0' WHERE id IN (17, 18)"
|
||||
)
|
||||
@@ -113,3 +113,26 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
||||
|
||||
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
|
||||
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
|
||||
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
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,14 @@ 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.calc import router as calc_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import 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 +40,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)
|
||||
@@ -55,4 +61,9 @@ business_router.include_router(business_receipts.router)
|
||||
business_router.include_router(business_ratings.router)
|
||||
v1_router.include_router(business_router)
|
||||
|
||||
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(calc_router)
|
||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(settlements_router)
|
||||
|
||||
api_router.include_router(v1_router)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""绩效核算接口(M3 ④):台账查询与单笔核算触发。
|
||||
|
||||
核算动作 = 把回款固化为台账行(每笔回款仅一次;重算走独立入口并授权)。
|
||||
准入:读=登录即可;写(核算/重算/批量)=管理员与营销后勤
|
||||
(权限清单待澄清 #6,答复后细化)。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||
from app.services import calc_service
|
||||
|
||||
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
|
||||
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
|
||||
|
||||
|
||||
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||
def list_ledger(
|
||||
session: DbSession,
|
||||
query: Annotated[LedgerQuery, Query()],
|
||||
) -> ApiResponse[PageResult[LedgerOut]]:
|
||||
"""分页查询绩效核算台账(默认仅生效行,可筛选历史版本)。"""
|
||||
return ok(calc_service.list_ledger_page(session, query))
|
||||
|
||||
|
||||
@router.get("/ledger/{ledger_id}", response_model=ApiResponse[LedgerOut], summary="台账详情")
|
||||
def get_ledger(
|
||||
session: DbSession,
|
||||
ledger_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[LedgerOut]:
|
||||
"""台账行详情(含计算快照)。"""
|
||||
return ok(calc_service.get_ledger(session, ledger_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/receipts/{receipt_id}/calc",
|
||||
response_model=ApiResponse[CalcReceiptOut],
|
||||
summary="核算回款",
|
||||
)
|
||||
def calc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||
return ok(
|
||||
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
|
||||
message="核算完成",
|
||||
)
|
||||
|
||||
|
||||
class RecalcIn(BaseModel):
|
||||
"""重算入参:原因必填(重算授权留痕)。"""
|
||||
|
||||
reason: str = Field(min_length=1, max_length=255, description="重算原因")
|
||||
|
||||
|
||||
class BatchIn(BaseModel):
|
||||
"""批量核算入参。"""
|
||||
|
||||
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/receipts/{receipt_id}/recalc",
|
||||
response_model=ApiResponse[CalcReceiptOut],
|
||||
summary="重算回款",
|
||||
)
|
||||
def recalc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: RecalcIn,
|
||||
session: DbSession,
|
||||
current_user: _RecalcUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||
return ok(
|
||||
calc_service.recalc_receipt(
|
||||
session, receipt_id, payload.reason, operator=current_user, meta=meta
|
||||
),
|
||||
message="重算完成",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
|
||||
def batch_calc(
|
||||
payload: BatchIn,
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[list[dict]]:
|
||||
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||
return ok(
|
||||
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
|
||||
message="批量核算完成",
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""月度结算接口(M3 ④):生成/提交审批/锁定与调整单。
|
||||
|
||||
准入:读=登录即可;写(生成/提交/调整单)=管理员与营销后勤
|
||||
(权限清单待澄清 #6,答复后细化)。
|
||||
"""
|
||||
|
||||
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_MARKETING_OPS
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.settlement import (
|
||||
AdjustmentCreateIn,
|
||||
SettlementDetailOut,
|
||||
SettlementGenerateIn,
|
||||
SettlementOut,
|
||||
SettlementQuery,
|
||||
)
|
||||
from app.services import settlement_service
|
||||
|
||||
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
|
||||
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||
def generate(
|
||||
payload: SettlementGenerateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||
return ok(
|
||||
settlement_service.generate(session, payload.month, operator=current_user, meta=meta),
|
||||
message="月结已生成",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[SettlementOut]], summary="月结列表")
|
||||
def list_settlements(
|
||||
session: DbSession,
|
||||
query: Annotated[SettlementQuery, Query()],
|
||||
) -> ApiResponse[PageResult[SettlementOut]]:
|
||||
"""分页查询月结(按月份倒序)。"""
|
||||
return ok(settlement_service.list_page(session, query))
|
||||
|
||||
|
||||
@router.get("/{settlement_id}", response_model=ApiResponse[SettlementDetailOut], summary="月结详情")
|
||||
def get_settlement(
|
||||
session: DbSession,
|
||||
settlement_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""月结详情(含按人明细)。"""
|
||||
return ok(settlement_service.get_detail(session, settlement_id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{settlement_id}/submit",
|
||||
response_model=ApiResponse[SettlementDetailOut],
|
||||
summary="提交审批",
|
||||
)
|
||||
def submit(
|
||||
settlement_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||
return ok(
|
||||
settlement_service.submit(session, settlement_id, operator=current_user, meta=meta),
|
||||
message="已提交",
|
||||
)
|
||||
|
||||
|
||||
# ==================== 调整单(已锁定月份的补发/扣减) ====================
|
||||
|
||||
|
||||
@router.post("/adjustments", response_model=ApiResponse[dict], summary="新建调整单")
|
||||
def create_adjustment(
|
||||
payload: AdjustmentCreateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[dict]:
|
||||
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||
return ok(
|
||||
settlement_service.create_adjustment(session, payload, operator=current_user, meta=meta),
|
||||
message="调整单已创建",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/adjustments", response_model=ApiResponse[list[dict]], summary="调整单列表")
|
||||
def list_adjustments(session: DbSession) -> ApiResponse[list[dict]]:
|
||||
"""调整单列表(一期不分页,内部系统量级有限)。"""
|
||||
return ok(settlement_service.list_adjustments(session))
|
||||
@@ -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="已提交",
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
|
||||
|
||||
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
|
||||
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
|
||||
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
|
||||
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
|
||||
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
|
||||
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
|
||||
导出为文件流(非统一信封),前端以 blob 接收。
|
||||
"""
|
||||
|
||||
@@ -12,7 +13,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Path, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.param import (
|
||||
ParamBatchDetailOut,
|
||||
@@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
|
||||
)
|
||||
def get_catalog(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
|
||||
) -> ApiResponse[list[ParamCatalogItemOut]]:
|
||||
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
|
||||
@@ -63,7 +64,7 @@ def submit_change(
|
||||
)
|
||||
def list_changes(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
query: Annotated[ParamBatchQuery, Query()],
|
||||
) -> ApiResponse[PageResult[ParamBatchOut]]:
|
||||
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
|
||||
@@ -75,7 +76,7 @@ def list_changes(
|
||||
)
|
||||
def get_batch(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
batch_id: Annotated[int, Path(ge=1)],
|
||||
) -> ApiResponse[ParamBatchDetailOut]:
|
||||
"""批次头 + 逐项旧值 → 新值。"""
|
||||
@@ -97,7 +98,7 @@ def withdraw_batch(
|
||||
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
|
||||
def compare(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
from_date: Annotated[date, Query(description="起始业务日期(含)")],
|
||||
to_date: Annotated[date, Query(description="截止业务日期(含)")],
|
||||
) -> ApiResponse[list[ParamDiffOut]]:
|
||||
@@ -108,7 +109,7 @@ def compare(
|
||||
@router.get("/export", summary="导出变更记录 Excel")
|
||||
def export_changes(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
|
||||
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
|
||||
) -> StreamingResponse:
|
||||
@@ -125,7 +126,7 @@ def export_changes(
|
||||
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
|
||||
def preview(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
payload: ParamPreviewIn,
|
||||
) -> ApiResponse[ParamPreviewOut]:
|
||||
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
|
||||
@@ -139,7 +140,7 @@ def preview(
|
||||
)
|
||||
def get_history(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: LoginUser,
|
||||
param_code: Annotated[str, Path(max_length=64)],
|
||||
query: Annotated[ParamHistoryQuery, Query()],
|
||||
) -> ApiResponse[PageResult[ParamHistoryOut]]:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""系统设置接口(公司信息)。
|
||||
|
||||
`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.exceptions import BadRequestError
|
||||
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")
|
||||
def upload_logo(
|
||||
file: UploadFile,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||
|
||||
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
|
||||
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||
"""
|
||||
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
content = file.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,10 @@ MODULE_BUSINESS = "业务管理"
|
||||
MODULE_SHIPMENT = "发货管理"
|
||||
MODULE_RECEIPT = "回款管理"
|
||||
MODULE_RATING = "客户评级"
|
||||
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@@ -77,6 +94,8 @@ NO_PREFIX_BUSINESS = "YW"
|
||||
NO_PREFIX_SHIPMENT = "SH"
|
||||
NO_PREFIX_RECEIPT = "HK"
|
||||
NO_PREFIX_PARAM = "P"
|
||||
NO_PREFIX_APPROVAL = "AP"
|
||||
NO_PREFIX_ADJUSTMENT = "TZ"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
@@ -84,3 +103,5 @@ ACTION_DELETE = "删除"
|
||||
ACTION_RESET_PASSWORD = "重置密码"
|
||||
ACTION_ENABLE = "启用"
|
||||
ACTION_DISABLE = "禁用"
|
||||
ACTION_APPROVE = "审批通过"
|
||||
ACTION_REJECT = "审批驳回"
|
||||
@@ -9,22 +9,29 @@
|
||||
`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
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.performance_ledger import PerformanceLedger
|
||||
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.models.product import Product
|
||||
from app.models.rating import CustomerRatingGrade
|
||||
from app.models.receipt import Receipt
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"AdjustmentOrder",
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"Base",
|
||||
"Business",
|
||||
"BusinessItem",
|
||||
@@ -33,11 +40,13 @@ __all__ = [
|
||||
"Customer",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonthlySettlement",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
@@ -46,8 +55,11 @@ __all__ = [
|
||||
"Receipt",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"SecondaryAllocation",
|
||||
"SettlementItem",
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""审批模型:审批单与节点记录(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)
|
||||
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||
APPROVAL_TYPE_ADJUSTMENT = 3
|
||||
|
||||
|
||||
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月度结算 3调整单"
|
||||
)
|
||||
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="创建时间"
|
||||
)
|
||||
@@ -15,7 +15,7 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@@ -33,6 +33,63 @@ 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 字面断言失配(全库既有注释同此惯例)
|
||||
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||||
UniqueConstraint(
|
||||
"business_id",
|
||||
"personnel_id",
|
||||
name="uk_business_secondary_allocations_business_personnel",
|
||||
),
|
||||
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 的「合同编号」。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""绩效核算台账模型(M3 ④:每笔回款仅核算一次的固化点)。
|
||||
|
||||
**append-only 版本行**:重算 = 插入 calc_version+1 的新行,旧行仅把
|
||||
is_active 翻 0 —— 这是全表唯一的受控 UPDATE(模型注释与 test_models
|
||||
均已声明例外),行内容永不改写;历史版本可回溯(重算授权留痕的需求口径)。
|
||||
|
||||
防重三层:服务层 CAS(UPDATE ... WHERE settlement_status=0,rowcount=0
|
||||
即 400)+ FOR UPDATE 行锁 + 库级 uk(receipt_id, calc_version, personnel_id)
|
||||
兜底。uk 含 personnel_id:二次分配分摊(澄清 #1 答复 2026-10-19,分配
|
||||
绩效——按方案比例拆给参与人、余额归合同负责人)后同一回款同版本按人
|
||||
各落一行,拆分仍是「一次核算」的组成部分;聚合行 receipt_id 为 NULL,
|
||||
MySQL 唯一索引对 NULL 不去重,天然不约束。
|
||||
|
||||
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
|
||||
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
|
||||
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
|
||||
声明即可)。
|
||||
|
||||
#12 答复(2026-10-19):经理个人户业绩是否计入聚合基数改为参数
|
||||
``manager.personal.included``(默认 0=不计入)——消费点在 settlement_service
|
||||
的聚合函数。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
# 核算类型(performance_ledgers.calc_type)
|
||||
CALC_TYPE_RECEIPT = 1 # 单笔回款核算
|
||||
CALC_TYPE_MANAGER_AGGREGATE = 2 # 经理团队聚合(月度批处理)
|
||||
|
||||
|
||||
class PerformanceLedger(Base, TimestampMixin):
|
||||
"""绩效核算台账表:核算事实的固化点,报表 2.4.1 的数据源。"""
|
||||
|
||||
__tablename__ = "performance_ledgers"
|
||||
__table_args__ = mysql_table_args(
|
||||
"绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)",
|
||||
# 库级防重兜底:同一回款同版本按人仅一行(分摊拆行后负责人+参与人
|
||||
# 各占一行;服务层 CAS+行锁之外的第二道闸)。聚合行 receipt_id 为
|
||||
# NULL,MySQL 唯一索引对 NULL 不去重,天然不约束聚合行
|
||||
UniqueConstraint(
|
||||
"receipt_id",
|
||||
"calc_version",
|
||||
"personnel_id",
|
||||
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||
),
|
||||
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
|
||||
Index(
|
||||
"idx_performance_ledgers_month_personnel_type",
|
||||
"settlement_month",
|
||||
"personnel_id",
|
||||
"calc_type",
|
||||
),
|
||||
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
|
||||
Index("idx_performance_ledgers_business_id", "business_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
receipt_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("receipts.id", ondelete="RESTRICT"),
|
||||
comment="回款ID(receipts.id;聚合行可为 NULL)",
|
||||
)
|
||||
calc_version: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||
)
|
||||
is_active: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)",
|
||||
)
|
||||
calc_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="核算类型:1单笔回款 2经理团队聚合(月度)",
|
||||
)
|
||||
business_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
comment="业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)",
|
||||
)
|
||||
business_no: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="业务编号(冗余固化,列表免联查)"
|
||||
)
|
||||
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="归属营销人员姓名(冗余固化)"
|
||||
)
|
||||
role_code: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="岗位编码(business_roles.role_code,计提公式锚点)"
|
||||
)
|
||||
customer_type: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="客户分类:personal/manager_personal/enterprise"
|
||||
)
|
||||
product_category: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)",
|
||||
)
|
||||
ship_date: Mapped[date | None] = mapped_column(Date, comment="发货日期(回款天数基准)")
|
||||
receipt_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, comment="回款日期(参数版本匹配日,裁决 #11)"
|
||||
)
|
||||
payment_days: Mapped[int | None] = mapped_column(
|
||||
comment="回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)"
|
||||
)
|
||||
rate_text: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)",
|
||||
)
|
||||
converted_profit: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="折算毛利润额(可负;混合业务为各行汇总)"
|
||||
)
|
||||
performance_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="绩效金额(可负,裁决:负绩效照算不归零)"
|
||||
)
|
||||
barter_applied: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||
)
|
||||
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
|
||||
payment_weight: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(7, 6), comment="支付权重系数 R×R(展示用)"
|
||||
)
|
||||
payable_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)"
|
||||
)
|
||||
settlement_month: Mapped[str] = mapped_column(
|
||||
String(7), nullable=False, comment="归属月(回款日期所在月,YYYY-MM;月结与报表维度)"
|
||||
)
|
||||
is_probation: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="试用期:1照算不发放 0否",
|
||||
)
|
||||
missing_cost: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="成本缺失:1有明细行缺成本(该行毛利按 0 计)0否",
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="计算快照(所用参数版本与行级折算明细,重算可回溯)"
|
||||
)
|
||||
settlement_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("monthly_settlements.id", ondelete="RESTRICT"),
|
||||
comment="所属月结ID(月结生成时回填;NULL=未入结)",
|
||||
)
|
||||
calc_reason: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="核算/重算原因(重算必填留痕)"
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
@@ -0,0 +1,197 @@
|
||||
"""月度结算与调整单模型(M3 ④:月结审批报表补充需求)。
|
||||
|
||||
- 月度结算按「确认回款」和计算快照生成;**通过即锁定**(status=3 已审定
|
||||
即终态,锁定后源数据改动只产生提示与后续调整)
|
||||
- settlement_items 明细按人固化(可负=负数工资),锁定后无 UPDATE 路径
|
||||
(重生成 = 草稿/驳回态删旧插新)
|
||||
- 调整单是已锁定月份的唯一调整通道(补发/扣减,走审批;生效后不回写
|
||||
已锁定月结——append-only,报表汇总并入)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
# 月结状态(monthly_settlements.status;通过即锁定——两态合一)
|
||||
SETTLEMENT_STATUS_DRAFT = 1 # 草稿(可重新生成/提交)
|
||||
SETTLEMENT_STATUS_PENDING = 2 # 待审批(提交后)
|
||||
SETTLEMENT_STATUS_APPROVED = 3 # 已审定(终态=已锁定)
|
||||
SETTLEMENT_STATUS_REJECTED = 4 # 已驳回(回草稿可重提)
|
||||
|
||||
# 调整单状态(adjustment_orders.status)
|
||||
ADJUSTMENT_STATUS_PENDING = 1 # 待审批
|
||||
ADJUSTMENT_STATUS_EFFECTIVE = 2 # 已生效
|
||||
ADJUSTMENT_STATUS_REJECTED = 3 # 已驳回
|
||||
ADJUSTMENT_STATUS_WITHDRAWN = 4 # 已撤回
|
||||
|
||||
# 调整方向(adjustment_orders.direction)
|
||||
ADJUSTMENT_DIRECTION_EXTRA = 1 # 补发
|
||||
ADJUSTMENT_DIRECTION_DEDUCT = 2 # 扣减
|
||||
|
||||
|
||||
class MonthlySettlement(Base, TimestampMixin):
|
||||
"""月度结算表:按月一行(settlement_month 唯一),快照 + 审批 + 锁定。"""
|
||||
|
||||
__tablename__ = "monthly_settlements"
|
||||
__table_args__ = mysql_table_args("月度结算表(按月一行;通过即锁定,锁定后走调整单)")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
settlement_month: Mapped[str] = mapped_column(
|
||||
String(7), nullable=False, unique=True, comment="结算月份(YYYY-MM;全表唯一)"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)",
|
||||
)
|
||||
total_performance: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="绩效金额合计(可负)"
|
||||
)
|
||||
total_payable: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="应付绩效合计(可负)"
|
||||
)
|
||||
person_count: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="0", comment="结算人数"
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="月度总览快照(生成时刻的确认回款口径说明与汇总参数)"
|
||||
)
|
||||
approval_order_no: Mapped[str | None] = mapped_column(
|
||||
String(32), comment="关联审批单号(冗余固化;开关关闭自动通过时也有单)"
|
||||
)
|
||||
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="提交人用户名(冗余固化)"
|
||||
)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="锁定时间(status=3 时回填)"
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
|
||||
|
||||
|
||||
class SettlementItem(Base):
|
||||
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
|
||||
|
||||
__tablename__ = "settlement_items"
|
||||
__table_args__ = mysql_table_args(
|
||||
"月结明细表(按人固化;支持负数工资;锁定后只读)",
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
settlement_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="月结ID(monthly_settlements.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="结算人员姓名(冗余固化)"
|
||||
)
|
||||
role_code: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="岗位编码(主岗,聚合锚点)"
|
||||
)
|
||||
calc_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="核算类型:1单笔回款 2经理团队聚合"
|
||||
)
|
||||
performance_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2),
|
||||
nullable=False,
|
||||
comment="绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)",
|
||||
)
|
||||
payable_sum: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="应付绩效合计(可负)"
|
||||
)
|
||||
basic_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="月工资标准(personnel.salary_standard 当月快照)"
|
||||
)
|
||||
salary_mode: Mapped[str | None] = mapped_column(
|
||||
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
|
||||
)
|
||||
net_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
|
||||
)
|
||||
is_probation: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
|
||||
)
|
||||
probation_note: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="个人明细快照(台账行引用列表)"
|
||||
)
|
||||
|
||||
|
||||
class AdjustmentOrder(Base, TimestampMixin):
|
||||
"""调整单:已锁定月份的唯一调整通道(补发/扣减,走审批;不回写月结)。"""
|
||||
|
||||
__tablename__ = "adjustment_orders"
|
||||
__table_args__ = mysql_table_args(
|
||||
"调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)",
|
||||
)
|
||||
|
||||
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="调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}",
|
||||
)
|
||||
settlement_month: Mapped[str] = mapped_column(
|
||||
String(7), nullable=False, comment="调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)"
|
||||
)
|
||||
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="调整人员姓名(冗余固化)"
|
||||
)
|
||||
direction: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="方向:1补发 2扣减"
|
||||
)
|
||||
amount: Mapped[Decimal] = mapped_column(
|
||||
Numeric(14, 2), nullable=False, comment="调整金额(恒正,方向表达语义)"
|
||||
)
|
||||
reason: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, comment="扣补说明(必填,审批与追溯锚点)"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="状态:1待审批 2已生效 3已驳回 4已撤回",
|
||||
)
|
||||
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="提交人用户名(冗余固化)"
|
||||
)
|
||||
@@ -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,6 +18,7 @@ from app.models import (
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
@@ -198,6 +199,47 @@ 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 delete_allocations(session: Session, business_id: int) -> None:
|
||||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||||
|
||||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||||
方案事实由审批单留痕与操作日志 request_data 承载。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
|
||||
@@ -40,9 +40,7 @@ def list_by_ref(
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_by_ref(
|
||||
session: Session, *, owner_type: int, ref_column, ref_id: int
|
||||
) -> int:
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.schemas.calc import LedgerQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, ledger_id: int) -> PerformanceLedger | None:
|
||||
"""按主键取台账行。"""
|
||||
return session.get(PerformanceLedger, ledger_id)
|
||||
|
||||
|
||||
def get_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> PerformanceLedger | None:
|
||||
"""取某回款的当前生效核算行(is_active=1;无则 None)。
|
||||
|
||||
二次分摊拆行后同回款可有多行(负责人+参与人)——重算作废用
|
||||
``list_active_by_receipt``;本函数仅限确知单行的调用方(重算前的
|
||||
存在性判断按业务维度走 has_active_for_business)。
|
||||
"""
|
||||
return session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_active_by_receipt(
|
||||
session: Session, receipt_id: int, *, calc_type: int = 1
|
||||
) -> list[PerformanceLedger]:
|
||||
"""取某回款当前生效的**全部**核算行(分摊拆行后负责人+参与人各一行)。"""
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def has_active_for_business(session: Session, business_id: int, *, calc_type: int = 1) -> bool:
|
||||
"""该业务是否已有生效核算行(核算粒度 #25:业务级一次性计提的判定)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.business_id == business_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
> 0
|
||||
)
|
||||
|
||||
|
||||
def get_active_by_receipt_of_business(
|
||||
session: Session, business_id: int, *, calc_type: int = 1
|
||||
) -> PerformanceLedger:
|
||||
"""取该业务当前生效的核算行(重复回款登记进度时回显既有台账)。"""
|
||||
return (
|
||||
session.execute(
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.business_id == business_id,
|
||||
PerformanceLedger.calc_type == calc_type,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.order_by(PerformanceLedger.id.desc())
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def add(session: Session, row: PerformanceLedger) -> PerformanceLedger:
|
||||
"""落台账行(flush 取 id;commit 在服务层)。"""
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def mark_inactive(session: Session, row: PerformanceLedger) -> None:
|
||||
"""重算时把旧版本行翻为已替代(全表唯一的受控 UPDATE 例外;不 commit)。"""
|
||||
row.is_active = 0
|
||||
session.flush()
|
||||
|
||||
|
||||
def deactivate_month_aggregates(session: Session, month: str) -> None:
|
||||
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
|
||||
|
||||
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
|
||||
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
|
||||
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_version(session: Session, receipt_id: int) -> int:
|
||||
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||
current = session.execute(
|
||||
select(func.max(PerformanceLedger.calc_version)).where(
|
||||
PerformanceLedger.receipt_id == receipt_id
|
||||
)
|
||||
).scalar_one()
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
|
||||
"""分页查询台账(筛选:月/人员/业务/生效/类型)。"""
|
||||
stmt = select(PerformanceLedger).where(*_filters(query))
|
||||
stmt = stmt.order_by(PerformanceLedger.id.desc()).offset(query.offset).limit(query.page_size)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_ledger(session: Session, query: LedgerQuery) -> int:
|
||||
"""台账计数(与 list_ledger 同筛选)。"""
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(PerformanceLedger).where(*_filters(query))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _filters(query: LedgerQuery) -> tuple:
|
||||
filters = []
|
||||
if query.settlement_month:
|
||||
filters.append(PerformanceLedger.settlement_month == query.settlement_month)
|
||||
if query.personnel_id is not None:
|
||||
filters.append(PerformanceLedger.personnel_id == query.personnel_id)
|
||||
if query.business_id is not None:
|
||||
filters.append(PerformanceLedger.business_id == query.business_id)
|
||||
if query.is_active is not None:
|
||||
filters.append(PerformanceLedger.is_active == query.is_active)
|
||||
if query.calc_type is not None:
|
||||
filters.append(PerformanceLedger.calc_type == query.calc_type)
|
||||
return tuple(filters)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""月度结算仓储:只读写数据并 flush;commit/rollback 在服务层。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import MonthlySettlement, SettlementItem
|
||||
from app.schemas.settlement import SettlementQuery
|
||||
|
||||
|
||||
def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None:
|
||||
return session.get(MonthlySettlement, settlement_id)
|
||||
|
||||
|
||||
def get_by_month(session: Session, month: str) -> MonthlySettlement | None:
|
||||
"""按月份取月结行(settlement_month 全表唯一)。"""
|
||||
return session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == month)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def add(session: Session, settlement: MonthlySettlement) -> MonthlySettlement:
|
||||
session.add(settlement)
|
||||
session.flush()
|
||||
return settlement
|
||||
|
||||
|
||||
def list_settlements(session: Session, query: SettlementQuery) -> list[MonthlySettlement]:
|
||||
return list(
|
||||
session.execute(
|
||||
select(MonthlySettlement)
|
||||
.where(*_filters(query))
|
||||
.order_by(MonthlySettlement.settlement_month.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def count_settlements(session: Session, query: SettlementQuery) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count()).select_from(MonthlySettlement).where(*_filters(query))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _filters(query: SettlementQuery) -> tuple:
|
||||
filters = []
|
||||
if query.status is not None:
|
||||
filters.append(MonthlySettlement.status == query.status)
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def list_items(session: Session, settlement_id: int) -> list[SettlementItem]:
|
||||
return list(
|
||||
session.execute(
|
||||
select(SettlementItem)
|
||||
.where(SettlementItem.settlement_id == settlement_id)
|
||||
.order_by(SettlementItem.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
def replace_items(session: Session, settlement_id: int, rows: list[SettlementItem]) -> None:
|
||||
"""全量替换明细(先删后 flush 再插,防御写法;不 commit)。"""
|
||||
session.execute(delete_items_stmt(settlement_id))
|
||||
session.flush()
|
||||
for row in rows:
|
||||
row.settlement_id = settlement_id
|
||||
session.add(row)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_items_stmt(settlement_id: int):
|
||||
from sqlalchemy import delete
|
||||
|
||||
return delete(SettlementItem).where(SettlementItem.settlement_id == settlement_id)
|
||||
|
||||
|
||||
def update_ledger_settlement_id(
|
||||
session: Session, ledger_ids: list[int], settlement_id: int
|
||||
) -> None:
|
||||
"""月结生成时把纳入的台账行回填 settlement_id(append-only 的受控回填)。"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
if ledger_ids:
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(PerformanceLedger.id.in_(ledger_ids))
|
||||
.values(settlement_id=settlement_id)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
|
||||
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
|
||||
|
||||
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
|
||||
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(PerformanceLedger.settlement_id == settlement_id)
|
||||
.values(settlement_id=None)
|
||||
)
|
||||
session.flush()
|
||||
@@ -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,62 @@
|
||||
"""绩效核算的请求/响应模型(M3 ④)。"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class LedgerQuery(PageQuery):
|
||||
"""台账列表查询条件。"""
|
||||
|
||||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||||
personnel_id: int | None = Field(default=None, ge=1, description="归属人员筛选")
|
||||
business_id: int | None = Field(default=None, ge=1, description="业务筛选")
|
||||
is_active: int | None = Field(default=None, ge=0, le=1, description="生效标记筛选")
|
||||
calc_type: int | None = Field(default=None, ge=1, le=2, description="核算类型筛选")
|
||||
|
||||
|
||||
class LedgerOut(BaseModel):
|
||||
"""台账行响应(金额/比例一律 str)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
receipt_id: int | None = None
|
||||
calc_version: int
|
||||
is_active: int
|
||||
calc_type: int
|
||||
business_id: int
|
||||
business_no: str
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
customer_type: str
|
||||
product_category: str
|
||||
ship_date: date | None = None
|
||||
receipt_date: date
|
||||
payment_days: int | None = None
|
||||
rate_text: str
|
||||
converted_profit: Decimal | None = None
|
||||
performance_amount: Decimal | None = None
|
||||
barter_applied: int
|
||||
weight_r: int | None = None
|
||||
payment_weight: Decimal | None = None
|
||||
payable_amount: Decimal | None = None
|
||||
settlement_month: str
|
||||
is_probation: int
|
||||
missing_cost: int
|
||||
snapshot_json: dict | None = None
|
||||
settlement_id: int | None = None
|
||||
calc_reason: str | None = None
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class CalcReceiptOut(BaseModel):
|
||||
"""单笔核算结果:台账行 + 重复计提标记(核算粒度 #25 一期口径)。"""
|
||||
|
||||
ledger: LedgerOut
|
||||
#: True=该业务此前已核算过,本笔回款仅登记进度、未重复计提(#25 一期口径)
|
||||
repeated: bool = False
|
||||
@@ -0,0 +1,34 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, 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):
|
||||
"""公司信息更新。
|
||||
|
||||
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _check_month(value: str) -> str:
|
||||
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
|
||||
if not _MONTH_RE.match(value):
|
||||
raise ValueError("月份格式须为 YYYY-MM")
|
||||
return value
|
||||
|
||||
|
||||
class SettlementGenerateIn(BaseModel):
|
||||
"""月结生成入参。"""
|
||||
|
||||
month: str = Field(description="结算月份 YYYY-MM")
|
||||
|
||||
@field_validator("month")
|
||||
@classmethod
|
||||
def _v_month(cls, value: str) -> str:
|
||||
return _check_month(value)
|
||||
|
||||
|
||||
class SettlementQuery(PageQuery):
|
||||
"""月结列表查询。"""
|
||||
|
||||
status: int | None = Field(default=None, ge=1, le=4, description="状态筛选")
|
||||
|
||||
|
||||
class SettlementItemOut(BaseModel):
|
||||
"""月结明细行。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
personnel_id: int
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
calc_type: int
|
||||
performance_sum: Decimal
|
||||
payable_sum: Decimal
|
||||
basic_amount: Decimal | None = None
|
||||
salary_mode: str | None = None
|
||||
net_amount: Decimal | None = None
|
||||
is_probation: int
|
||||
probation_note: str | None = None
|
||||
|
||||
|
||||
class SettlementOut(BaseModel):
|
||||
"""月结头表响应。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
settlement_month: str
|
||||
status: int
|
||||
total_performance: Decimal | None = None
|
||||
total_payable: Decimal | None = None
|
||||
person_count: int
|
||||
approval_order_no: str | None = None
|
||||
submitter_name: str
|
||||
locked_at: datetime | datetime | None = None
|
||||
locked_by: str | None = None
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class SettlementDetailOut(SettlementOut):
|
||||
"""月结详情:附明细。"""
|
||||
|
||||
items: list[SettlementItemOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdjustmentCreateIn(BaseModel):
|
||||
"""调整单创建入参(金额恒正,方向表达补发/扣减)。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
settlement_month: str = Field(description="调整月份 YYYY-MM")
|
||||
personnel_id: int = Field(ge=1, description="调整人员ID")
|
||||
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
|
||||
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
|
||||
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
|
||||
amount: str = Field(
|
||||
pattern=r"^\d{1,12}(\.\d{1,2})?$",
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
@@ -0,0 +1,377 @@
|
||||
"""审批流业务编排(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.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
ACTION_APPROVE,
|
||||
ACTION_REJECT,
|
||||
MODULE_APPROVAL,
|
||||
NO_PREFIX_APPROVAL,
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
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_REJECT,
|
||||
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 种子同源;新增审批对象类型必须在此登记,
|
||||
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
1: "approval.secondary_allocation.enabled",
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
3: "approval.adjustment.enabled",
|
||||
}
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
_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,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
||||
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
||||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||
"""
|
||||
_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
|
||||
session.flush()
|
||||
logger.info("审批单待审 | order=%s", order.order_no)
|
||||
return order
|
||||
|
||||
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||
order.status = APPROVAL_STATUS_APPROVED
|
||||
session.flush()
|
||||
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 withdraw_pending_orders(
|
||||
session: Session, *, order_type: int, ref_id: int, operator: User
|
||||
) -> int:
|
||||
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
|
||||
|
||||
返回撤回张数(0 = 本就无在途单)。
|
||||
"""
|
||||
before = len(
|
||||
session.execute(
|
||||
select(ApprovalOrder.id).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
ApprovalOrder.status.in_(_PENDING_STATUSES),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if before:
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
|
||||
return before
|
||||
|
||||
|
||||
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]
|
||||
rejected = payload.action == APPROVAL_ACTION_REJECT
|
||||
if rejected:
|
||||
order.status = APPROVAL_STATUS_REJECTED
|
||||
else:
|
||||
order.status = (
|
||||
APPROVAL_STATUS_PENDING_LEADER
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||||
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||||
# 驳回在任何节点都是终态,即时联动。
|
||||
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node_now,
|
||||
action=payload.action,
|
||||
opinion=payload.opinion,
|
||||
operator_id=operator.id,
|
||||
operator_name=operator.username,
|
||||
)
|
||||
)
|
||||
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
|
||||
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
|
||||
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
|
||||
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
|
||||
# 委托 business_repository 完成 —— service 层互调、不触 commit。
|
||||
if rejected and order.order_type == 1:
|
||||
from app.repositories import business_repository
|
||||
|
||||
business_repository.delete_allocations(session, order.ref_id)
|
||||
|
||||
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||||
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||||
if order.order_type == 2 or order.order_type == 3:
|
||||
from app.models import AdjustmentOrder
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
|
||||
)
|
||||
from app.services import settlement_service
|
||||
|
||||
if order.order_type == 2:
|
||||
from app.models.settlement import MonthlySettlement
|
||||
|
||||
target = session.get(MonthlySettlement, order.ref_id)
|
||||
if target is not None:
|
||||
if rejected:
|
||||
settlement_service.mark_rejected(session, target, operator=operator)
|
||||
elif final_approved:
|
||||
settlement_service.mark_locked(session, target, operator=operator)
|
||||
else:
|
||||
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||
if adj is not None:
|
||||
if rejected:
|
||||
adj.status = ADJ_REJECTED
|
||||
elif final_approved:
|
||||
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||||
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||||
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
|
||||
session.flush()
|
||||
detail = _detail_of(session, order)
|
||||
session.commit()
|
||||
return detail
|
||||
|
||||
|
||||
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 _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||||
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:
|
||||
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
|
||||
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
|
||||
# 下划线当单字符通配,搜出无关单据
|
||||
filters.append(
|
||||
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
|
||||
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
|
||||
)
|
||||
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)
|
||||
@@ -34,6 +34,7 @@ from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageQuery, PageResult
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessStatusLog,
|
||||
@@ -41,15 +42,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
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.schemas.business import (
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
@@ -65,8 +68,10 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
GrossProfitRowOut,
|
||||
OwnershipChangeLogOut,
|
||||
SecondaryAllocationIn,
|
||||
SecondaryAllocationOut,
|
||||
)
|
||||
from app.services import audit_service, param_service, personnel_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
|
||||
@@ -257,7 +262,7 @@ def list_businesses(session: Session, query: BusinessQuery) -> PageResult[Busine
|
||||
|
||||
|
||||
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
"""业务详情(附明细行,供编辑抽屉回显)。
|
||||
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
@@ -268,9 +273,83 @@ 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],
|
||||
*,
|
||||
old_ids: set[int],
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||
|
||||
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
|
||||
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
|
||||
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
||||
**新加入**的参与人做在职校验。
|
||||
|
||||
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
|
||||
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
|
||||
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
|
||||
自分份额则双重计入)
|
||||
"""
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
|
||||
if total > Decimal("100"):
|
||||
raise BadRequestError(
|
||||
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
|
||||
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
|
||||
)
|
||||
owner_id = business.owner_personnel_id
|
||||
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
|
||||
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
|
||||
|
||||
rows: list[SecondaryAllocation] = []
|
||||
for item in items:
|
||||
if item.personnel_id in old_ids:
|
||||
person = personnel_repository.get_by_id(session, item.personnel_id)
|
||||
if person is None:
|
||||
raise BadRequestError("分摊参与人员不存在")
|
||||
else:
|
||||
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,
|
||||
@@ -335,6 +414,26 @@ def create_business(
|
||||
operator_name=operator.username,
|
||||
),
|
||||
)
|
||||
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
|
||||
# 放在重试循环内——rollback 后方案行随业务一并重来
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business.id,
|
||||
_build_allocation_rows(
|
||||
session, business.id, payload.secondary_allocations, old_ids=set()
|
||||
),
|
||||
)
|
||||
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
|
||||
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
|
||||
# 业务/方案/审批单整体重来
|
||||
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,
|
||||
)
|
||||
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
|
||||
@@ -408,6 +507,35 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
old_ids = {
|
||||
a.personnel_id for a in business_repository.list_allocations(session, business_id)
|
||||
}
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business_id,
|
||||
_build_allocation_rows(
|
||||
session, business_id, payload.secondary_allocations, old_ids=old_ids
|
||||
),
|
||||
)
|
||||
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
|
||||
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
|
||||
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,
|
||||
)
|
||||
else:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
operator=operator,
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
@@ -430,7 +558,7 @@ def delete_business(
|
||||
与业务维度的归属留痕(无外键固化表,须手动清)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 存在发货或回款记录
|
||||
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
|
||||
"""
|
||||
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
@@ -447,6 +575,10 @@ def delete_business(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
|
||||
if _count_pending_approvals(session, business_id):
|
||||
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
|
||||
|
||||
business_no = business.business_no
|
||||
_hard_delete(session, business_id)
|
||||
@@ -454,11 +586,46 @@ def delete_business(
|
||||
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
|
||||
|
||||
|
||||
def _count_pending_approvals(session: Session, business_id: int) -> int:
|
||||
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models import ApprovalOrder
|
||||
from app.models.approval import (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(
|
||||
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ApprovalOrder.ref_id == business_id,
|
||||
ApprovalOrder.status.in_(
|
||||
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||
),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _hard_delete(session: Session, business_id: int) -> None:
|
||||
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
|
||||
状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.execute(
|
||||
delete(ApprovalOrder).where(
|
||||
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ApprovalOrder.ref_id == business_id,
|
||||
)
|
||||
)
|
||||
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
|
||||
session.execute(
|
||||
delete(OwnershipChangeLog).where(
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
|
||||
|
||||
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
|
||||
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
|
||||
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
|
||||
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||
核算才是算钱的法定固化点。
|
||||
|
||||
策略点(业务答复 2026-10-19 已回填,后续调整仍集中在对应函数 + 按回款批量重算):
|
||||
- ``_personal_rate``:#12 经理个人户单笔由参数 manager.personal.included
|
||||
分流——0=单笔落 0(聚合口径在月结时由 manager_team_share 计算)、
|
||||
1=按业务员递减公式计提;#20 在 ``_row_profit``(暂按不含税原样相减)
|
||||
- 二次分摊(澄清 #1 答复「分配绩效」,算后分):``_load_allocations``
|
||||
读生效方案,``_persist_ledger`` 按比例拆行(参与人份额 + 负责人余额,
|
||||
行合计守恒);#2 余额归负责人、#3 二次分配即协助场景的核算载体
|
||||
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import MODULE_CALC
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
Customer,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
User,
|
||||
)
|
||||
from app.models.performance_ledger import CALC_TYPE_RECEIPT
|
||||
from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING
|
||||
from app.repositories import business_repository, performance_ledger_repository
|
||||
from app.schemas.calc import CalcReceiptOut
|
||||
from app.services import audit_service, param_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import (
|
||||
apply_barter,
|
||||
clamp_weight_r,
|
||||
converted_profit,
|
||||
payment_days,
|
||||
payment_weight,
|
||||
performance_amount,
|
||||
profit_rate,
|
||||
salesperson_rate,
|
||||
start_payment_ok,
|
||||
)
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# snapshot_json 里的策略点注记(口径检索锚点;#12 已参数化,开关 0 时留此注记)
|
||||
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(基数开关=0):聚合口径在月结时由团队聚合计算"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RateDecision:
|
||||
"""单笔计提比例的分派结果。"""
|
||||
|
||||
rate: Fraction # 纯比例(str() 产出分数原文如 88/9000,直接落台账 rate_text)
|
||||
rate_note: str # 人读说明(入 snapshot)
|
||||
base_override: Decimal | None # 非 None 时绩效基数用它(信息员=销售额)
|
||||
manager_pending: bool # True=经理聚合口径,单笔落 0(#12 策略点)
|
||||
|
||||
|
||||
def calc_receipt(
|
||||
session: Session,
|
||||
receipt_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
recalc_reason: str | None = None,
|
||||
) -> CalcReceiptOut:
|
||||
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
|
||||
|
||||
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
|
||||
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
|
||||
一个 audit 事务,出参在 commit 前固化。
|
||||
|
||||
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
|
||||
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
|
||||
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
|
||||
落 calc_version+1 新行,calc_reason 留痕。
|
||||
|
||||
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
is_recalc = recalc_reason is not None
|
||||
action = "重算" if is_recalc else "核算"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_CALC,
|
||||
action=action,
|
||||
summary=f"{action}回款 #{receipt_id}",
|
||||
request_data={
|
||||
"receipt_id": receipt_id,
|
||||
**({"reason": recalc_reason} if is_recalc else {}),
|
||||
},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
|
||||
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
|
||||
if repeated_out is not None:
|
||||
return repeated_out
|
||||
|
||||
shipment = _load_shipment(session, receipt)
|
||||
person = _load_person(session, business)
|
||||
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||
items = _load_items(session, business)
|
||||
allocations = _load_allocations(session, business, person.id)
|
||||
|
||||
conversion = _convert_items(snapshot, person, items)
|
||||
amounts = _compose_amounts(
|
||||
session, snapshot, person, business, receipt, shipment, conversion
|
||||
)
|
||||
rows = _persist_ledger(
|
||||
session,
|
||||
receipt=receipt,
|
||||
business=business,
|
||||
person=person,
|
||||
shipment=shipment,
|
||||
items=items,
|
||||
allocations=allocations,
|
||||
conversion=conversion,
|
||||
amounts=amounts,
|
||||
recalc_reason=recalc_reason,
|
||||
is_recalc=is_recalc,
|
||||
)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(rows.owner_row), repeated=False)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"核算完成 | receipt=%s | ledger=%s | payable=%s | 分摊行=%s",
|
||||
receipt_id,
|
||||
rows.owner_row.id,
|
||||
amounts.payable,
|
||||
len(rows.share_rows),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _load_and_guard(
|
||||
session: Session, receipt_id: int, *, is_recalc: bool
|
||||
) -> tuple[Receipt, Business]:
|
||||
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
|
||||
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if is_recalc:
|
||||
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||
|
||||
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
|
||||
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
return receipt, business
|
||||
|
||||
|
||||
def _prepare_mode(
|
||||
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
|
||||
) -> CalcReceiptOut | None:
|
||||
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
|
||||
|
||||
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
|
||||
调用方直接返回。
|
||||
"""
|
||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
|
||||
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||
# 两行生效行导致双重计提 → 一律 400
|
||||
if is_recalc:
|
||||
# 分摊拆行后旧版本可能多行(负责人+参与人)——全部作废再落新版本
|
||||
olds = performance_ledger_repository.list_active_by_receipt(session, receipt.id)
|
||||
if not olds:
|
||||
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||
for old in olds:
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
return None
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||
if not performance_ledger_repository.has_active_for_business(
|
||||
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||
):
|
||||
return None
|
||||
_mark_settled(session, receipt)
|
||||
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||
session.commit()
|
||||
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
|
||||
return out
|
||||
|
||||
|
||||
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
|
||||
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
|
||||
if receipt.shipment_id is None:
|
||||
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||
shipment = session.get(Shipment, receipt.shipment_id)
|
||||
if shipment is None or shipment.ship_date is None:
|
||||
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||
return shipment
|
||||
|
||||
|
||||
def _load_person(session: Session, business: Business):
|
||||
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
|
||||
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
|
||||
if person.business_role_id is None:
|
||||
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||
return person
|
||||
|
||||
|
||||
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
||||
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
|
||||
items = business_repository.list_items(session, business.id)
|
||||
if not items:
|
||||
raise BadRequestError("业务无产品明细行,无法核算")
|
||||
return items
|
||||
|
||||
|
||||
def _load_allocations(session: Session, business: Business, owner_id: int) -> list:
|
||||
"""业务生效的二次分配方案(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||
|
||||
校验双保险之一(保存侧在 business_service):比例合计 >100% 会把负责人
|
||||
份额算成负数(多算参与人绩效);合同负责人不可出现在参与人列表(自分
|
||||
份额会双重计入)。无方案返回空列表 = 全额归负责人。
|
||||
"""
|
||||
allocations = business_repository.list_allocations(session, business.id)
|
||||
if not allocations:
|
||||
return []
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
if total > Decimal("100"):
|
||||
raise BadRequestError(
|
||||
f"二次分配比例合计 {total.quantize(Decimal('0.01'))}% 超过 100%,"
|
||||
"无法核算(先调整方案比例)"
|
||||
)
|
||||
if any(a.personnel_id == owner_id for a in allocations):
|
||||
raise BadRequestError("二次分配参与人含合同负责营销人员,无法核算(先调整方案)")
|
||||
return list(allocations)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Conversion:
|
||||
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
||||
|
||||
total_revenue: Decimal
|
||||
total_profit: Decimal
|
||||
converted_total: Decimal
|
||||
missing_cost: bool
|
||||
line_details: list[dict[str, Any]]
|
||||
|
||||
|
||||
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
|
||||
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
|
||||
total_revenue = Decimal("0")
|
||||
total_profit = Decimal("0")
|
||||
converted_total = Decimal("0")
|
||||
missing_cost = False
|
||||
line_details: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
row_revenue = item.sale_amount
|
||||
if item.cost_amount is None:
|
||||
missing_cost = True
|
||||
row_profit = Decimal("0")
|
||||
else:
|
||||
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||
total_revenue += row_revenue
|
||||
total_profit += row_profit
|
||||
threshold = parse_fraction(
|
||||
snapshot.get(
|
||||
"product.threshold",
|
||||
role_code=role_code_of(person),
|
||||
product_category=item.product_category,
|
||||
)
|
||||
)
|
||||
row_converted = converted_profit(
|
||||
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||
)
|
||||
converted_total += row_converted
|
||||
line_details.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"revenue": str(row_revenue),
|
||||
"profit": str(row_profit),
|
||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||
# 快照/台账口径需与列一致)
|
||||
"converted": str(_q2(row_converted)),
|
||||
}
|
||||
)
|
||||
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Amounts:
|
||||
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
|
||||
|
||||
days: int
|
||||
decision: _RateDecision
|
||||
converted: Decimal
|
||||
performance: Decimal
|
||||
barter_applied: bool
|
||||
weight_r: int
|
||||
weight: Fraction
|
||||
payable: Decimal
|
||||
start_ok: bool
|
||||
|
||||
|
||||
def _compose_amounts(
|
||||
session: Session,
|
||||
snapshot,
|
||||
person,
|
||||
business: Business,
|
||||
receipt: Receipt,
|
||||
shipment: Shipment,
|
||||
conversion: _Conversion,
|
||||
) -> _Amounts:
|
||||
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
|
||||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||
days = payment_days(shipment.ship_date, receipt.receipt_date)
|
||||
decision = _personal_rate(session, snapshot, person, business, days)
|
||||
|
||||
if decision.base_override is not None:
|
||||
converted = decision.base_override # 信息员:基数=本人销售额
|
||||
else:
|
||||
converted = conversion.converted_total
|
||||
|
||||
performance = (
|
||||
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
base_amount = performance
|
||||
if barter_applied:
|
||||
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
|
||||
base_amount = apply_barter(performance, discount)
|
||||
|
||||
received_total = business.total_received_amount or Decimal("0")
|
||||
start_ratio = parse_fraction(
|
||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||
)
|
||||
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
|
||||
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
|
||||
weight = payment_weight(weight_r)
|
||||
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||
return _Amounts(
|
||||
days=days,
|
||||
decision=decision,
|
||||
converted=converted,
|
||||
performance=performance,
|
||||
barter_applied=barter_applied,
|
||||
weight_r=weight_r,
|
||||
weight=weight,
|
||||
payable=payable,
|
||||
start_ok=start_ok,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LedgerRows:
|
||||
"""拆行落库结果:负责人行恒存在(出参与 CAS 锚点),参与人份额行可空。"""
|
||||
|
||||
owner_row: PerformanceLedger
|
||||
share_rows: list[PerformanceLedger]
|
||||
|
||||
|
||||
def _persist_ledger(
|
||||
session: Session,
|
||||
*,
|
||||
receipt: Receipt,
|
||||
business: Business,
|
||||
person,
|
||||
shipment: Shipment,
|
||||
items: list[BusinessItem],
|
||||
allocations: list,
|
||||
conversion: _Conversion,
|
||||
amounts: _Amounts,
|
||||
recalc_reason: str | None,
|
||||
is_recalc: bool,
|
||||
) -> _LedgerRows:
|
||||
"""落台账行(分摊拆行;版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。
|
||||
|
||||
分摊(澄清 #1 答复 2026-10-19「分配绩效」,算后分):业务级全额(含
|
||||
递减比例、抵顶、支付权重全环节)算完后按方案比例拆给参与人,余额归
|
||||
合同负责人。行合计与业务级全额**守恒**(负责人行取 q2 全额减参与人
|
||||
q2 份额的差额,防分漂);每行快照带完整方案快照,行级可独立回溯。
|
||||
参与人离职照分(历史方案事实,方案调整是业务侧责任——入职校验只在
|
||||
保存侧,核算侧只要求人员存在,FK RESTRICT 已保证)。
|
||||
"""
|
||||
receipt_date = receipt.receipt_date
|
||||
base_snapshot: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": conversion.line_details,
|
||||
"start_payment_ok": amounts.start_ok,
|
||||
"rate_note": amounts.decision.rate_note,
|
||||
"revenue_total": str(conversion.total_revenue),
|
||||
"profit_total": str(conversion.total_profit),
|
||||
}
|
||||
if amounts.decision.manager_pending:
|
||||
base_snapshot["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||
if allocations:
|
||||
base_snapshot["alloc_plan"] = [
|
||||
{
|
||||
"personnel_id": a.personnel_id,
|
||||
"personnel_name": a.personnel_name,
|
||||
"ratio": str(a.ratio),
|
||||
}
|
||||
for a in allocations
|
||||
]
|
||||
|
||||
version = performance_ledger_repository.next_version(session, receipt.id)
|
||||
customer_type = _customer_type(session, business)
|
||||
category = _category_label(items)
|
||||
|
||||
# 份额清单:(person, ratio, tag)——ratio 为 None 时走余额公式(q2 全额
|
||||
# 减已分累计,守恒);负责人余额的百分数(100−∑参与人)仅作快照展示,
|
||||
# 不参与计算——按它乘全额会丢 q2 尾差(39.1111×30% ≠ 39.11−27.37)
|
||||
shares: list[tuple[Any, Decimal | None, str]] = []
|
||||
owner_ratio_text: str | None = None
|
||||
if not allocations:
|
||||
shares.append((person, None, "owner"))
|
||||
owner_ratio_text = "100"
|
||||
else:
|
||||
total_ratio = Decimal("0")
|
||||
for a in allocations:
|
||||
shares.append((session.get(Personnel, a.personnel_id), a.ratio, "participant"))
|
||||
total_ratio += a.ratio
|
||||
owner_ratio_text = str((Decimal("100") - total_ratio).quantize(Decimal("0.01")))
|
||||
shares.append((person, None, "owner"))
|
||||
|
||||
def _share(full: Decimal, ratio: Decimal | None, *, added: Decimal = Decimal("0")) -> Decimal:
|
||||
"""单份额金额:参与人 = 全额×比例(q2);负责人 = q2 全额 − 已分累计。"""
|
||||
if ratio is None:
|
||||
return _q2(full - added) or Decimal("0")
|
||||
# Fraction 乘积先 to_decimal 再 q2(Fraction 无 quantize,直接喂 _q2 会炸)
|
||||
return _q2(param_service.to_decimal(Fraction(full) * Fraction(ratio) / 100)) or Decimal("0")
|
||||
|
||||
added_perf = Decimal("0")
|
||||
added_conv = Decimal("0")
|
||||
added_pay = Decimal("0")
|
||||
owner_row: PerformanceLedger | None = None
|
||||
share_rows: list[PerformanceLedger] = []
|
||||
for target, ratio, tag in shares:
|
||||
perf = _share(amounts.performance, ratio, added=added_perf)
|
||||
conv = _share(amounts.converted, ratio, added=added_conv)
|
||||
pay = _share(amounts.payable, ratio, added=added_pay)
|
||||
added_perf += perf
|
||||
added_conv += conv
|
||||
added_pay += pay
|
||||
row = performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=receipt.id,
|
||||
calc_version=version,
|
||||
calc_type=CALC_TYPE_RECEIPT,
|
||||
business_id=business.id,
|
||||
business_no=business.business_no,
|
||||
personnel_id=target.id,
|
||||
personnel_name=target.personnel_name,
|
||||
role_code=role_code_of(target),
|
||||
customer_type=customer_type,
|
||||
product_category=category,
|
||||
ship_date=shipment.ship_date,
|
||||
receipt_date=receipt_date,
|
||||
payment_days=amounts.days,
|
||||
rate_text=str(amounts.decision.rate),
|
||||
converted_profit=conv,
|
||||
performance_amount=perf,
|
||||
barter_applied=1 if amounts.barter_applied else 0,
|
||||
weight_r=amounts.weight_r,
|
||||
payment_weight=param_service.to_decimal(amounts.weight),
|
||||
payable_amount=pay,
|
||||
settlement_month=receipt_date.strftime("%Y-%m"),
|
||||
is_probation=1 if target.is_probation == 1 else 0,
|
||||
missing_cost=1 if conversion.missing_cost else 0,
|
||||
snapshot_json={
|
||||
**base_snapshot,
|
||||
"alloc_role": tag,
|
||||
"alloc_ratio": owner_ratio_text if tag == "owner" else str(ratio),
|
||||
},
|
||||
calc_reason=recalc_reason,
|
||||
),
|
||||
)
|
||||
if tag == "owner":
|
||||
owner_row = row
|
||||
else:
|
||||
share_rows.append(row)
|
||||
|
||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
return _LedgerRows(owner_row=owner_row, share_rows=share_rows)
|
||||
|
||||
|
||||
def _q2(value: Decimal | None) -> Decimal | None:
|
||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
|
||||
|
||||
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
|
||||
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _personal_rate(
|
||||
session: Session,
|
||||
snapshot,
|
||||
person,
|
||||
business: Business,
|
||||
days: int,
|
||||
) -> _RateDecision:
|
||||
"""按岗位模板分派单笔计提比例(与 param_service._preview_rate 同构)。
|
||||
|
||||
#12 策略点:经理个人户(owner 岗位绑 TPL_MANAGER_SHARE)单笔不计提
|
||||
—— 12.5%+5% 是月度团队聚合口径,单笔落 rate=0/绩效 0 的台账行保事实。
|
||||
"""
|
||||
role = _role_of(session, person)
|
||||
customer_type = _customer_type(session, business)
|
||||
|
||||
if customer_type == "enterprise":
|
||||
# 存储口径 ‰ → 纯比例(漏换算放大 1000 倍,与预览同防线)
|
||||
rate = parse_fraction(snapshot.get("enterprise.rate")) / 1000
|
||||
return _RateDecision(rate, "企业户固定计提比例", None, False)
|
||||
|
||||
template = role.calc_template if role else "TPL_SALESPERSON_DECAY"
|
||||
if template == "TPL_MANAGER_SHARE":
|
||||
# #12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户
|
||||
# 单按业务员递减公式单笔计提(个人户单本质是正常业务,有发货/回款/
|
||||
# 毛利;聚合基数开关在 settlement_service 同参数消费)——0 时单笔落 0
|
||||
if snapshot.get("manager.personal.included") != "1":
|
||||
return _RateDecision(
|
||||
Decimal("0"),
|
||||
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"经理个人户(基数开关=1):按业务员递减 {start}‰ 起,{days} 天"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
if template == "TPL_INFO_SALES_FIXED":
|
||||
# 信息员基数=本人销售额(覆盖折算与阈值),‰ 换算同防线
|
||||
rate = parse_fraction(snapshot.get("info.rate")) / 1000
|
||||
return _RateDecision(rate, "信息员:本人销售额 × 计提比例", None, False)
|
||||
if template == "TPL_SALESPERSON_DECAY":
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
raise BadRequestError(f"公式模板 {template} 暂不支持核算")
|
||||
|
||||
|
||||
def _role_of(session: Session, person):
|
||||
from app.repositories import business_role_repository
|
||||
|
||||
if person.business_role_id is None:
|
||||
return None
|
||||
return business_role_repository.get_by_id(session, person.business_role_id)
|
||||
|
||||
|
||||
def role_code_of(person) -> str:
|
||||
"""人员岗位编码(台账冗余固化用;无岗位为空串——前置校验已拦)。"""
|
||||
from app.repositories import business_role_repository
|
||||
|
||||
if person.business_role_id is None:
|
||||
return ""
|
||||
session = Session.object_session(person)
|
||||
role = business_role_repository.get_by_id(session, person.business_role_id)
|
||||
return role.role_code if role else ""
|
||||
|
||||
|
||||
def _customer_type(session: Session, business: Business) -> str:
|
||||
customer = session.get(Customer, business.customer_id)
|
||||
return customer.customer_type if customer else "personal"
|
||||
|
||||
|
||||
def _category_label(items: list[BusinessItem]) -> str:
|
||||
"""单类别直出;混合业务记 mixed(行级明细在 snapshot_json)。"""
|
||||
categories = {item.product_category for item in items}
|
||||
return categories.pop() if len(categories) == 1 else "mixed"
|
||||
|
||||
|
||||
def _mark_settled(session: Session, receipt: Receipt) -> None:
|
||||
"""CAS 置已核算:并发下另一事务先行置 1 时 rowcount=0 → 400。"""
|
||||
result = session.execute(
|
||||
update(Receipt)
|
||||
.where(Receipt.id == receipt.id, Receipt.settlement_status == SETTLEMENT_PENDING)
|
||||
.values(settlement_status=SETTLEMENT_DONE)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise BadRequestError("该回款已被并发核算,请刷新后查看台账")
|
||||
|
||||
|
||||
def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
|
||||
"""行毛利 = 行销售额 − 行成本。
|
||||
|
||||
#20 策略点(待业务答复,2026-10-16 暂定口径):金额暂按**不含税**原样
|
||||
相减;答复若为含税口径,只改本函数(含税换算/取不含税金额列)+ 按回款
|
||||
批量重算回填。
|
||||
"""
|
||||
return revenue - cost
|
||||
|
||||
|
||||
def recalc_receipt(
|
||||
session: Session,
|
||||
receipt_id: int,
|
||||
reason: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> CalcReceiptOut:
|
||||
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
|
||||
|
||||
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
|
||||
的拦截在 calc_receipt 内(核算与重算同口径)。
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
raise BadRequestError("重算必须填写原因")
|
||||
return calc_receipt(
|
||||
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
|
||||
)
|
||||
|
||||
|
||||
def batch_calc(
|
||||
session: Session,
|
||||
receipt_ids: list[int],
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> list[dict]:
|
||||
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
|
||||
|
||||
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
|
||||
回滚其它笔的核算结果。
|
||||
"""
|
||||
results: list[dict] = []
|
||||
for rid in receipt_ids:
|
||||
try:
|
||||
out = calc_receipt(session, rid, operator=operator, meta=meta)
|
||||
results.append(
|
||||
{
|
||||
"receipt_id": rid,
|
||||
"ok": True,
|
||||
"repeated": out.repeated,
|
||||
"ledger_id": out.ledger.id,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
)
|
||||
ok_count = sum(1 for r in results if r["ok"])
|
||||
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
|
||||
return results
|
||||
|
||||
|
||||
def list_ledger_page(session: Session, query):
|
||||
"""分页查询台账(api 层转发)。"""
|
||||
rows = performance_ledger_repository.list_ledger(session, query)
|
||||
total = performance_ledger_repository.count_ledger(session, query)
|
||||
from app.core.response import PageResult
|
||||
from app.schemas.calc import LedgerOut
|
||||
|
||||
return PageResult[LedgerOut](
|
||||
records=[LedgerOut.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def get_ledger(session: Session, ledger_id: int):
|
||||
"""台账详情(api 层转发)。"""
|
||||
row = performance_ledger_repository.get_by_id(session, ledger_id)
|
||||
if row is None:
|
||||
from app.core.exceptions import NotFoundError
|
||||
|
||||
raise NotFoundError("台账记录不存在")
|
||||
from app.schemas.calc import LedgerOut
|
||||
|
||||
return LedgerOut.model_validate(row)
|
||||
|
||||
|
||||
def _to_out(row: PerformanceLedger):
|
||||
from app.schemas.calc import LedgerOut
|
||||
|
||||
return LedgerOut.model_validate(row)
|
||||
@@ -41,6 +41,10 @@ def salesperson_rate(
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||
|
||||
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
|
||||
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
|
||||
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
|
||||
|
||||
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||
@@ -48,6 +52,8 @@ def salesperson_rate(
|
||||
days = max(days, 0)
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
if days <= 1:
|
||||
return start / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
|
||||
@@ -95,3 +101,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
|
||||
if revenue == 0:
|
||||
return False
|
||||
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
||||
|
||||
|
||||
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
|
||||
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
|
||||
|
||||
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
|
||||
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
|
||||
"""
|
||||
if revenue <= 0:
|
||||
return 0
|
||||
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
|
||||
|
||||
|
||||
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
|
||||
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
|
||||
|
||||
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
|
||||
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
|
||||
- EXCLUDE:仅绩效,基本工资不参与
|
||||
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
|
||||
"""
|
||||
basic_value = basic if basic is not None else Decimal("0")
|
||||
if mode == "INCLUDE":
|
||||
return performance + basic_value
|
||||
if mode == "DEDUCT":
|
||||
return performance - basic_value
|
||||
if mode == "EXCLUDE":
|
||||
return performance
|
||||
raise ValueError(f"未知的月工资口径:{mode}")
|
||||
|
||||
|
||||
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
|
||||
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
|
||||
|
||||
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
|
||||
"""
|
||||
if year_total <= threshold:
|
||||
return None
|
||||
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
|
||||
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
|
||||
|
||||
|
||||
def manager_team_share(
|
||||
team_performance: Decimal,
|
||||
company_performance: Decimal,
|
||||
*,
|
||||
team_pct: Fraction,
|
||||
company_pct: Fraction,
|
||||
) -> Decimal:
|
||||
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
|
||||
|
||||
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
|
||||
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
|
||||
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
|
||||
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
|
||||
全公司口径:全部业务员(非经理岗)绩效行之和。
|
||||
"""
|
||||
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
|
||||
Fraction(company_performance) * company_pct / 100
|
||||
)
|
||||
@@ -243,18 +243,76 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
sort_order=15,
|
||||
),
|
||||
ParamSpec(
|
||||
code="probation.salary.basic",
|
||||
name="试用期基本工资标准",
|
||||
group="月工资口径",
|
||||
value_type="DECIMAL",
|
||||
unit="元",
|
||||
code="approval.secondary_allocation.enabled",
|
||||
name="二次分配审批开关",
|
||||
group="审批开关",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="3000",
|
||||
value_min="0",
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||
sort_order=16,
|
||||
description=(
|
||||
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||
"1=提交后进入财务复核→领导审核审批流。"
|
||||
"默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭"
|
||||
),
|
||||
sort_order=17,
|
||||
),
|
||||
ParamSpec(
|
||||
code="approval.monthly_settlement.enabled",
|
||||
name="月度结算审批开关",
|
||||
group="审批开关",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"月度结算审批链开关(④ 月结提交时消费):"
|
||||
"0=结算生成即生效,1=走财务复核→领导审核审批流。"
|
||||
"默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭"
|
||||
),
|
||||
sort_order=18,
|
||||
),
|
||||
ParamSpec(
|
||||
code="approval.adjustment.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=走财务复核→领导审核审批流。"
|
||||
"调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,"
|
||||
"默认值业务未明确,暂按直接生效,业务可开启)"
|
||||
),
|
||||
sort_order=19,
|
||||
),
|
||||
ParamSpec(
|
||||
code="manager.personal.included",
|
||||
name="经理个人户计入聚合基数",
|
||||
group="经理分成",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="0",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description=(
|
||||
"经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 "
|
||||
"业务答复 2026-10-19:作为参数配置、手动调整是否纳入):"
|
||||
"0=不计入(默认,暂口径) 1=计入"
|
||||
),
|
||||
sort_order=20,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -614,11 +614,8 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
|
||||
start_ratio = take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
||||
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
||||
weight_r = (
|
||||
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
||||
)
|
||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||
weight_r = formula.clamp_weight_r(received, revenue)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
base_amount = after_barter if barter_applied else performance
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -209,6 +210,12 @@ def create_receipt(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||||
from app.services import settlement_service
|
||||
|
||||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
@@ -284,6 +291,12 @@ def update_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
|
||||
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
@@ -335,6 +348,10 @@ def delete_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止删除")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
batch_no = receipt.receipt_batch_no
|
||||
receipt_repository.delete(session, receipt)
|
||||
|
||||
@@ -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,690 @@
|
||||
"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
|
||||
|
||||
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
|
||||
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
|
||||
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
|
||||
|
||||
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
|
||||
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
MODULE_SETTLEMENT,
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import (
|
||||
MonthlySettlement,
|
||||
Personnel,
|
||||
SettlementItem,
|
||||
User,
|
||||
)
|
||||
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||
from app.models.performance_ledger import (
|
||||
CALC_TYPE_MANAGER_AGGREGATE,
|
||||
CALC_TYPE_RECEIPT,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
SETTLEMENT_STATUS_APPROVED,
|
||||
SETTLEMENT_STATUS_DRAFT,
|
||||
SETTLEMENT_STATUS_PENDING,
|
||||
SETTLEMENT_STATUS_REJECTED,
|
||||
)
|
||||
from app.repositories import performance_ledger_repository, settlement_repository
|
||||
from app.schemas.settlement import (
|
||||
SettlementDetailOut,
|
||||
SettlementOut,
|
||||
SettlementQuery,
|
||||
)
|
||||
from app.services import approval_service, audit_service
|
||||
from app.services.approval_service import AuditRecord, RequestMeta
|
||||
from app.services.formula import manager_team_share, monthly_salary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 审批对象类型引用(order_type=2 月结、3 调整单)
|
||||
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
|
||||
|
||||
|
||||
def generate(
|
||||
session: Session,
|
||||
month: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettlementOut:
|
||||
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
|
||||
|
||||
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
|
||||
重新生成」(_release_previous_round 解绑回填后清明细重算)
|
||||
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
|
||||
``_build_manager_aggregates``)
|
||||
|
||||
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SETTLEMENT,
|
||||
action="生成月结",
|
||||
summary=f"生成 {month} 月度结算快照",
|
||||
request_data={"month": month},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
existing = settlement_repository.get_by_month(session, month)
|
||||
_guard_regeneration(existing, month)
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
if not ledger_rows:
|
||||
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||
|
||||
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||
settlement = _upsert_draft(session, existing, operator, month)
|
||||
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||
|
||||
settlement_repository.replace_items(session, settlement.id, items)
|
||||
_apply_totals(settlement, items)
|
||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
|
||||
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||
# 记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
||||
return detail
|
||||
|
||||
|
||||
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
|
||||
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
|
||||
if existing is None:
|
||||
return
|
||||
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||
|
||||
|
||||
def _release_previous_round(
|
||||
session: Session, existing: MonthlySettlement | None, month: str
|
||||
) -> None:
|
||||
"""释放上一轮固化标记(必须早于 _confirm_scope)。
|
||||
|
||||
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
|
||||
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
|
||||
"""
|
||||
if existing is None:
|
||||
return
|
||||
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||
|
||||
|
||||
def _upsert_draft(
|
||||
session: Session, existing: MonthlySettlement | None, operator: User, month: str
|
||||
) -> MonthlySettlement:
|
||||
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
|
||||
if existing is None:
|
||||
settlement = MonthlySettlement(
|
||||
settlement_month=month,
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
status=SETTLEMENT_STATUS_DRAFT,
|
||||
)
|
||||
return settlement_repository.add(session, settlement)
|
||||
existing.submitter_id = operator.id
|
||||
existing.submitter_name = operator.username
|
||||
existing.status = SETTLEMENT_STATUS_DRAFT
|
||||
return existing
|
||||
|
||||
|
||||
def _build_items(
|
||||
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||
) -> tuple[list[SettlementItem], list[int]]:
|
||||
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||
return items, ledger_ids
|
||||
|
||||
|
||||
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
|
||||
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
|
||||
by_person: dict[tuple[int, int], dict] = {}
|
||||
for row in rows:
|
||||
key = (row.personnel_id, row.calc_type)
|
||||
bucket = by_person.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": row.personnel_id,
|
||||
"personnel_name": row.personnel_name,
|
||||
"role_code": row.role_code,
|
||||
"calc_type": row.calc_type,
|
||||
"performance_sum": Decimal("0"),
|
||||
"payable_sum": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"is_probation": row.is_probation,
|
||||
},
|
||||
)
|
||||
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||
bucket["ledger_ids"].append(row.id)
|
||||
return by_person
|
||||
|
||||
|
||||
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
|
||||
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
|
||||
person = session.get(Personnel, bucket["personnel_id"])
|
||||
basic = person.salary_standard if person else None
|
||||
mode = _salary_mode_of(session, bucket["role_code"], month)
|
||||
net = (
|
||||
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||
if person and person.is_probation != 1
|
||||
else None
|
||||
)
|
||||
note = None
|
||||
if person and person.is_probation == 1:
|
||||
note = (
|
||||
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
|
||||
"年底一次性结算"
|
||||
)
|
||||
return SettlementItem(
|
||||
personnel_id=bucket["personnel_id"],
|
||||
personnel_name=bucket["personnel_name"],
|
||||
role_code=bucket["role_code"],
|
||||
calc_type=bucket["calc_type"],
|
||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
basic_amount=basic,
|
||||
salary_mode=mode,
|
||||
net_amount=(
|
||||
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
|
||||
),
|
||||
is_probation=bucket["is_probation"],
|
||||
probation_note=note,
|
||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||
)
|
||||
|
||||
|
||||
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
|
||||
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
|
||||
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.person_count = len({i.personnel_id for i in items})
|
||||
|
||||
|
||||
def submit(
|
||||
session: Session,
|
||||
settlement_id: int,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettlementDetailOut:
|
||||
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SETTLEMENT,
|
||||
action="提交月结",
|
||||
summary=f"提交月结 #{settlement_id}",
|
||||
request_data={"settlement_id": settlement_id},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||
if settlement is None:
|
||||
raise NotFoundError("月结不存在")
|
||||
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||
raise BadRequestError("仅草稿或已驳回状态可提交")
|
||||
|
||||
order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=_SETTLEMENT_ORDER_TYPE,
|
||||
ref_id=settlement.id,
|
||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||
submitter=operator,
|
||||
)
|
||||
settlement.approval_order_no = order.order_no
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
mark_locked(session, settlement, operator=operator)
|
||||
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
||||
else:
|
||||
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
|
||||
# 月结记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
return detail
|
||||
|
||||
|
||||
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
|
||||
from datetime import datetime
|
||||
|
||||
settlement.status = SETTLEMENT_STATUS_APPROVED
|
||||
settlement.locked_at = datetime.now()
|
||||
settlement.locked_by = operator.username
|
||||
|
||||
|
||||
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
"""审批驳回钩子:回草稿(可修改后重提)。
|
||||
|
||||
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
||||
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
||||
"""
|
||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||
settlement.locked_at = None
|
||||
settlement.locked_by = None
|
||||
|
||||
|
||||
def ensure_month_not_locked(session: Session, month: str) -> None:
|
||||
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
|
||||
settlement = settlement_repository.get_by_month(session, month)
|
||||
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
|
||||
|
||||
|
||||
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||
if settlement is None:
|
||||
raise NotFoundError("月结不存在")
|
||||
return _detail_of(session, settlement)
|
||||
|
||||
|
||||
def list_page(session: Session, query: SettlementQuery):
|
||||
from app.core.response import PageResult
|
||||
from app.schemas.settlement import SettlementOut
|
||||
|
||||
rows = settlement_repository.list_settlements(session, query)
|
||||
total = settlement_repository.count_settlements(session, query)
|
||||
return PageResult[SettlementOut](
|
||||
records=[SettlementOut.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 内部 ----------
|
||||
|
||||
|
||||
def _confirm_scope(session: Session, month: str) -> list:
|
||||
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
|
||||
|
||||
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
|
||||
settlement_id,不排除会在重生成时与新聚合行重复计入。
|
||||
"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
return list(
|
||||
session.execute(
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
|
||||
PerformanceLedger.is_active == 1,
|
||||
PerformanceLedger.settlement_id.is_(None),
|
||||
)
|
||||
.order_by(PerformanceLedger.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ShareParams:
|
||||
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
|
||||
|
||||
month: str
|
||||
param_date: date
|
||||
team_text: str
|
||||
company_text: str
|
||||
team_pct: Fraction
|
||||
company_pct: Fraction
|
||||
include_manager_personal: bool
|
||||
|
||||
|
||||
def _manager_share_params(session: Session, month: str) -> _ShareParams:
|
||||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||||
|
||||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||||
"""
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
param_date = _month_end(month)
|
||||
snapshot = resolve_snapshot(session, param_date)
|
||||
team_text = snapshot.get("manager.share.team")
|
||||
company_text = snapshot.get("manager.share.company")
|
||||
return _ShareParams(
|
||||
month=month,
|
||||
param_date=param_date,
|
||||
team_text=team_text,
|
||||
company_text=company_text,
|
||||
team_pct=parse_fraction(team_text),
|
||||
company_pct=parse_fraction(company_text),
|
||||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||||
)
|
||||
|
||||
|
||||
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
|
||||
|
||||
def _collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""按经理采集团队业绩。
|
||||
|
||||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||||
按上溯会被跳过漏计)。
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
_add_team_performance(managers, row.personnel_id, row)
|
||||
continue # 默认不计入聚合基数(#12 参数化)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is not None:
|
||||
_add_team_performance(managers, manager_id, row)
|
||||
return managers
|
||||
|
||||
|
||||
def _aggregate_row(
|
||||
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
amount = manager_team_share(
|
||||
bucket["performance"],
|
||||
company_base,
|
||||
team_pct=share.team_pct,
|
||||
company_pct=share.company_pct,
|
||||
)
|
||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||
return performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
|
||||
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||
business_id=bucket["business_id"],
|
||||
business_no=bucket["business_no"],
|
||||
personnel_id=manager.id,
|
||||
personnel_name=manager.personnel_name,
|
||||
role_code="sales_manager",
|
||||
customer_type="personal",
|
||||
product_category="mixed",
|
||||
receipt_date=share.param_date,
|
||||
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
||||
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
|
||||
converted_profit=None,
|
||||
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
barter_applied=0,
|
||||
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
settlement_month=share.month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
"members": member_names,
|
||||
"params_as_of": str(share.param_date),
|
||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||
"note": (
|
||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理个人户业绩;9.8 方案公式"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||
"""按经理生成团队聚合行(calc_type=2)并落库。
|
||||
|
||||
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
|
||||
``manager.personal.included`` 决定(0=不计入默认,1=计入);
|
||||
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
|
||||
"""
|
||||
from app.repositories import business_role_repository
|
||||
|
||||
share = _manager_share_params(session, month)
|
||||
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
|
||||
# 口径逐行固定,循环外算一次
|
||||
company_base = sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if share.include_manager_personal or r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
aggregates: list = []
|
||||
for manager_id, bucket in managers.items():
|
||||
manager = session.get(Personnel, manager_id)
|
||||
if manager is None:
|
||||
continue
|
||||
role = (
|
||||
business_role_repository.get_by_id(session, manager.business_role_id)
|
||||
if manager.business_role_id
|
||||
else None
|
||||
)
|
||||
if role is None or role.role_code != "sales_manager":
|
||||
continue # 非营销经理的上级不参与聚合
|
||||
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||
return aggregates
|
||||
|
||||
|
||||
def _month_end(month: str) -> date:
|
||||
"""YYYY-MM → 所属月月末(月结取参锚点)。
|
||||
|
||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
||||
from app.services.param_service import resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
||||
|
||||
|
||||
def _probation_threshold(session: Session, month: str) -> Decimal:
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
from app.services.formula import to_decimal
|
||||
|
||||
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
|
||||
|
||||
|
||||
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
|
||||
items = settlement_repository.list_items(session, settlement.id)
|
||||
detail = SettlementDetailOut.model_validate(settlement)
|
||||
from app.schemas.settlement import SettlementItemOut
|
||||
|
||||
detail.items = [SettlementItemOut.model_validate(i) for i in items]
|
||||
return detail
|
||||
|
||||
|
||||
def create_adjustment(
|
||||
session: Session,
|
||||
payload,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
):
|
||||
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
||||
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
||||
from app.models import AdjustmentOrder
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
amount = _adjustment_amount(payload)
|
||||
|
||||
person = session.get(Personnel, payload.personnel_id)
|
||||
if person is None:
|
||||
raise BadRequestError("调整人员不存在")
|
||||
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SETTLEMENT,
|
||||
action="新建调整单",
|
||||
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
order = AdjustmentOrder(
|
||||
order_no=next_no(
|
||||
session,
|
||||
model=AdjustmentOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_ADJUSTMENT,
|
||||
),
|
||||
settlement_month=payload.settlement_month,
|
||||
personnel_id=payload.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=payload.direction,
|
||||
amount=amount,
|
||||
reason=payload.reason.strip(),
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
)
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||
session.commit()
|
||||
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||
return out
|
||||
|
||||
|
||||
def _adjustment_amount(payload) -> Decimal:
|
||||
"""金额恒正校验(方向表达补发/扣减)。
|
||||
|
||||
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
|
||||
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
|
||||
"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
try:
|
||||
amount = Decimal(payload.amount)
|
||||
except InvalidOperation as exc:
|
||||
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
|
||||
if amount <= 0:
|
||||
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
|
||||
return amount
|
||||
|
||||
|
||||
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
|
||||
|
||||
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
|
||||
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
|
||||
失败(历史缺陷:关开关时新建调整单不可用)。
|
||||
"""
|
||||
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
ADJUSTMENT_STATUS_PENDING,
|
||||
)
|
||||
|
||||
approval_order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
)
|
||||
order.status = (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE
|
||||
if approval_order.status == APPROVAL_STATUS_APPROVED
|
||||
else ADJUSTMENT_STATUS_PENDING
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _adjustment_out(order) -> dict:
|
||||
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"personnel_name": order.personnel_name,
|
||||
"amount": str(order.amount),
|
||||
"direction": order.direction,
|
||||
}
|
||||
|
||||
|
||||
def list_adjustments(session: Session) -> list[dict]:
|
||||
"""调整单列表(月份倒序)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AdjustmentOrder
|
||||
|
||||
rows = (
|
||||
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
from app.models.settlement import adjustment_label # noqa: F401
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"order_no": r.order_no,
|
||||
"settlement_month": r.settlement_month,
|
||||
"personnel_name": r.personnel_name,
|
||||
"direction": r.direction,
|
||||
"amount": str(r.amount),
|
||||
"reason": r.reason,
|
||||
"status": r.status,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -237,6 +237,12 @@ def create_shipment(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(
|
||||
session, payload.ship_date.strftime("%Y-%m")
|
||||
)
|
||||
|
||||
shipment = Shipment(
|
||||
shipment_no=shipment_no,
|
||||
@@ -309,6 +315,11 @@ def update_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
|
||||
|
||||
shipment.ship_date = payload.ship_date
|
||||
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
|
||||
@@ -357,6 +368,10 @@ def delete_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
if shipment_repository.count_receipt_refs(session, shipment_id):
|
||||
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""系统设置(公司信息)业务编排。
|
||||
|
||||
单例行语义:整个系统恒一行(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
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
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]
|
||||
|
||||
+49
-2
@@ -1,6 +1,6 @@
|
||||
# 后端测试
|
||||
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
||||
|
||||
## 一、测试约定
|
||||
|
||||
@@ -113,6 +113,29 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||
|
||||
### M3-core ④ 核算引擎 + 月度结算(2026-10-16 完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/test_formula.py`(扩) | R 钳制(向下取整/[0,100] 封顶封底/销售额 0 防御)、月工资三口径(INCLUDE 加/DEDUCT 减可负/EXCLUDE 仅绩效/基本缺失按 0/未知口径报错)、试用期年累计说明(未超无 note/超额精确差额)、经理 12.5%+5% 聚合(#12 暂口径) | +12 |
|
||||
| `tests/integration/test_calc.py` | **金标准双类别复算到分**(市政 4000 + 轨交 0 = 折算 4000;88/9000 约分原文落 rate_text;绩效 39.11;R=100 应付 39.11,DB 锚定);**业务级一次性计提 #25**(同业务第二笔回款 repeated=True 仅登记进度不新增行);已核算再核算 400;未指定批次 400;**成本缺失行毛利按 0 计**(折算保守 −3000、missing_cost=1);负绩效照算不归零(折算 −2000);准入(normal 403/marketing_ops 可用);列表筛选;重算(finance 授权、v2 生效 v1 保留、reason 留痕、原因缺失 400、未核算重算 400、越权 403);批量逐笔成败清单 | 13 |
|
||||
| `tests/integration/test_settlements.py` | 生成按人汇总+台账回填 settlement_id;无核算记录 400;**已审定月份重生成 400**;开关关闭提交即自动通过锁定(AP 单号/locked_at 回填);**锁定后回款禁改 400**(M2 已核算禁改防线先于锁定拦截);开关开启两节点流转(finance→leader)至锁定;#12 暂口径聚合不实现(items 按人汇总不聚合验证) | 6 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{calc,settlements}.py tests/unit/test_formula.py -q` → **32 passed**(2026-10-16);全量回归见运行记录。
|
||||
|
||||
### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 |
|
||||
| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 |
|
||||
| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 |
|
||||
| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 |
|
||||
| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 |
|
||||
| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@@ -127,11 +150,18 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 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 分页改造后复跑) |
|
||||
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
|
||||
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
|
||||
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
@@ -161,6 +191,22 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 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 归一保持等价命中 |
|
||||
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
|
||||
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
|
||||
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
|
||||
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
|
||||
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
|
||||
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
|
||||
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
|
||||
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
|
||||
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
|
||||
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
|
||||
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
|
||||
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
|
||||
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
@@ -168,7 +214,8 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| --- | --- | --- |
|
||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
@@ -29,22 +30,30 @@ 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 (
|
||||
AdjustmentOrder,
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
BusinessStatusLog,
|
||||
Customer,
|
||||
CustomerRatingGrade,
|
||||
MonthlySettlement,
|
||||
OperationLog,
|
||||
OwnershipChangeLog,
|
||||
ParamChangeBatch,
|
||||
ParamValue,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
PersonnelPlan,
|
||||
PersonnelRegion,
|
||||
Product,
|
||||
Receipt,
|
||||
Role,
|
||||
SettlementItem,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
SystemSetting,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@@ -92,6 +101,14 @@ def _purge_test_data() -> None:
|
||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
||||
|
||||
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
|
||||
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
|
||||
# 的单据与人员清理;仅测试写入,全量清)----
|
||||
session.execute(delete(PerformanceLedger))
|
||||
session.execute(delete(SettlementItem))
|
||||
session.execute(delete(MonthlySettlement))
|
||||
session.execute(delete(AdjustmentOrder).where(AdjustmentOrder.order_no.like("TZ%")))
|
||||
|
||||
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
||||
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
||||
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
||||
@@ -168,7 +185,32 @@ 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 均大于种子终值 25
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
||||
# 新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 25))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
session.execute(
|
||||
update(SystemSetting)
|
||||
.where(SystemSetting.id == 1)
|
||||
.values(company_name="衍水集团", logo_path=None)
|
||||
)
|
||||
session.commit()
|
||||
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
|
||||
if logo_dir.is_dir():
|
||||
for f in logo_dir.iterdir():
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
"""审批流集成测试(二次分配方案为首个接入对象)。
|
||||
|
||||
守的是(计划 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:
|
||||
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
|
||||
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
|
||||
|
||||
def test_驳回后方案行清空_不留在生效表(
|
||||
self, client: TestClient, admin: dict, finance, switched_on
|
||||
) -> None:
|
||||
"""被否决的方案不得以「当前生效行」身份留在表里(M3 快照会取到)。"""
|
||||
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
|
||||
|
||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||
assert detail["secondary_allocations"] == [], "被驳回的方案应从生效表清空"
|
||||
assert detail["allocation_hint"] is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_编辑清空方案_在途单撤回不再悬空(
|
||||
self, client: TestClient, admin: dict, finance, 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=[])
|
||||
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 statuses == {5}, f"清空方案后旧在途单应撤回且无新单:{statuses}"
|
||||
assert (
|
||||
client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"][
|
||||
"status"
|
||||
]
|
||||
== 5
|
||||
)
|
||||
|
||||
def test_删除业务在途单禁删_终态单随删(
|
||||
self, client: TestClient, admin: dict, finance, switched_on
|
||||
) -> None:
|
||||
"""ref_id 固化无外键:在途单禁删(防指向已删业务的活单继续被审批),
|
||||
终态单随业务删除一并清理(已通过/已驳回的历史事实由留痕日志承载)。"""
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
|
||||
# 在途:禁删
|
||||
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
assert "在途审批单" in resp.json()["message"]
|
||||
|
||||
# 驳回(终态)后:可删,且审批单随之清理
|
||||
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||
client.post(
|
||||
f"{_APPROVALS}/{order['id']}/action",
|
||||
json={"action": 2, "opinion": "驳回后可删"},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = client.get(
|
||||
_APPROVALS,
|
||||
params={"keyword": data["business_no"]},
|
||||
headers=finance["headers"],
|
||||
).json()["data"]["list"]
|
||||
assert rows == [], "终态审批单应随业务删除一并清理"
|
||||
|
||||
|
||||
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
|
||||
@@ -412,6 +412,71 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[
|
||||
{"personnel_id": part["id"], "ratio": "60"},
|
||||
{"personnel_id": _personnel(client, headers)["id"], "ratio": "50"},
|
||||
],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[{"personnel_id": owner["id"], "ratio": "30"}],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(
|
||||
self, client: TestClient, normal: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
|
||||
resp = client.put(
|
||||
f"{_BUSINESSES}/{data['id']}",
|
||||
json=_payload(
|
||||
customer["id"],
|
||||
secondary_allocations=[{"personnel_id": part["id"], "ratio": "40"}],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 方案在详情回包(更新回包为简版 Out,不含方案区)
|
||||
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||
assert len(detail["secondary_allocations"]) == 1
|
||||
assert Decimal(detail["secondary_allocations"][0]["ratio"]) == Decimal("40")
|
||||
# 余额 60% 归合同负责人(hint 文案 = #2 答复口径)
|
||||
assert detail["allocation_hint"] is not None and "60" in detail["allocation_hint"]
|
||||
|
||||
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
"""单笔回款核算集成测试(M3 ④:金标准复算到分)。
|
||||
|
||||
金标准场景(双类别混合业务,覆盖行级折算与汇总):
|
||||
- 行1 市政水利:100×100.00 = 销售 10000,成本 3000 → 毛利率 0.7,阈值 30%
|
||||
→ 折算 10000×(0.7−0.3) = 4000
|
||||
- 行2 轨道交通:50×200.00 = 销售 10000,成本 8000 → 毛利率 0.2,阈值 20%
|
||||
→ 折算 10000×(0.2−0.2) = 0
|
||||
- 折算合计 4000;发货 10-01 → 回款 10-20,days=20
|
||||
- 率 = (10 − 20×4/360)‰ = 88/9000;绩效 = 4000×88/9000 ≈ 39.11
|
||||
- 回款 20000(=销售额 100%)→ R=100、权重 1 → 应付 = 39.11
|
||||
|
||||
守的还有:核算粒度 #25(业务级一次性计提——第二笔回款仅登记进度)、
|
||||
已核算再核算 400、未指定批次 400、成本缺失行毛利按 0 计、负绩效照算、
|
||||
准入(normal 403 / marketing_ops 可用)。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger, Personnel, Receipt, SecondaryAllocation
|
||||
from app.models.receipt import SETTLEMENT_DONE
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_CALC = "/api/v1/calc"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_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": _role_id("salesperson"),
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _role_id(code: str) -> 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:
|
||||
role_code = BUSINESS_ROLE_SALESPERSON if code == "salesperson" else code
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == role_code, BusinessRole.status == 1
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _create_full_chain(
|
||||
client: TestClient,
|
||||
headers: dict,
|
||||
*,
|
||||
items: list[dict],
|
||||
ship_date: str,
|
||||
receipt_date: str,
|
||||
amount: str,
|
||||
) -> tuple[dict, dict]:
|
||||
"""客户 → 人员 → 业务(双行明细+归属)→ 发货 → 回款(指定批次)。"""
|
||||
cust = customer(client, headers)
|
||||
person = _person(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(cust["id"], owner_personnel_id=person["id"], items=items),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": receipt_date,
|
||||
"amount": amount,
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
return biz, receipt
|
||||
|
||||
|
||||
def _calc(client: TestClient, headers: dict, receipt_id: int):
|
||||
return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finance(make_user, auth_headers) -> dict:
|
||||
"""财务角色账号(重算授权角色之一)。"""
|
||||
user = make_user(["finance"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
_GOLD_ITEMS = [
|
||||
{
|
||||
"product_name": "t_市政_金标",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "3000.00",
|
||||
},
|
||||
{
|
||||
"product_name": "t_轨交_金标",
|
||||
"product_category": "rail_transit",
|
||||
"quantity": "50",
|
||||
"sale_price": "200.00",
|
||||
"cost_amount": "8000.00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestGoldenStandard:
|
||||
"""金标准:双类别混合业务复算到分(Decimal 精确断言)。"""
|
||||
|
||||
def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["repeated"] is False
|
||||
|
||||
ledger = out["ledger"]
|
||||
assert ledger["payment_days"] == 20
|
||||
assert ledger["settlement_month"] == "2026-10"
|
||||
# 率 = (10 − 20×4/360)‰ = 88/9000,Fraction 约分原文 11/1125
|
||||
assert ledger["rate_text"] == "11/1125"
|
||||
assert ledger["weight_r"] == 100
|
||||
assert ledger["missing_cost"] == 0
|
||||
assert ledger["snapshot_json"]["line_details"][0]["converted"] == "4000.00"
|
||||
|
||||
# 金额锚定 DB 事实源(NUMERIC(14,2) 读回恒两位;响应 JSON 的 Decimal
|
||||
# 序列化 scale 不作断言——归前端展示问题)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.converted_profit == Decimal("4000.00")
|
||||
# 绩效 = 4000 × 88/9000 = 39.111… → 落库两位 39.11
|
||||
assert row.performance_amount == Decimal("39.11")
|
||||
# 应付 = 绩效 × 权重 1 = 39.11
|
||||
assert row.payable_amount == Decimal("39.11")
|
||||
|
||||
# 回款状态被 CAS 置已核算
|
||||
detail = client.get(f"{_RECEIPTS}/{receipt['id']}", headers=admin["headers"]).json()["data"]
|
||||
assert detail["settlement_status"] == 1
|
||||
|
||||
def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "已核算" in resp.json()["message"]
|
||||
|
||||
def test_业务级一次性计提_第二笔仅登记进度(self, client: TestClient, admin: dict) -> None:
|
||||
"""核算粒度 #25 一期口径:同业务第二笔回款不重复计提。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="10000.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "5000.00",
|
||||
"shipment_id": None,
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
|
||||
resp = _calc(client, admin["headers"], second["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["repeated"] is True
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1, "第二笔回款不得新增台账行(一次性计提)"
|
||||
receipts = (
|
||||
session.execute(select(Receipt).where(Receipt.business_id == biz["id"]))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert all(r.settlement_status == SETTLEMENT_DONE for r in receipts)
|
||||
|
||||
|
||||
class TestGuards:
|
||||
"""校验链与策略点。"""
|
||||
|
||||
def test_未指定批次400(self, client: TestClient, admin: dict) -> None:
|
||||
cust = customer(client, admin["headers"])
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(cust["id"], owner_personnel_id=person["id"]),
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "100.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
receipt = resp.json()["data"]
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "发货批次" in resp.json()["message"]
|
||||
|
||||
def test_成本缺失行_毛利按0计并标记(self, client: TestClient, admin: dict) -> None:
|
||||
items = [
|
||||
{
|
||||
"product_name": "t_无成本_市政",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": None,
|
||||
}
|
||||
]
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=items,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="10000.00",
|
||||
)
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
ledger = resp.json()["data"]["ledger"]
|
||||
# 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算
|
||||
# 方向:缺成本的行宁少算不多发),missing_cost=1
|
||||
assert ledger["missing_cost"] == 1
|
||||
assert float(ledger["converted_profit"]) == -3000.0
|
||||
assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01)
|
||||
|
||||
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
|
||||
items = [
|
||||
{
|
||||
"product_name": "t_亏损_市政",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "9000.00",
|
||||
}
|
||||
]
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=items,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="10000.00",
|
||||
)
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
ledger = resp.json()["data"]["ledger"]
|
||||
# 毛利 1000、率 0.1 < 阈值 30% → 折算 10000×(0.1−0.3) = −2000 → 负绩效
|
||||
assert float(ledger["converted_profit"]) == -2000.0
|
||||
assert float(ledger["performance_amount"]) < 0
|
||||
|
||||
def test_准入_normal403_marketing_ops可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
resp = _calc(client, normal["headers"], receipt["id"])
|
||||
assert resp.status_code == 403
|
||||
|
||||
ops = make_user(["marketing_ops"])
|
||||
ops["headers"] = auth_headers(ops)
|
||||
resp = _calc(client, ops["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None:
|
||||
"""守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1),
|
||||
直接 int(str) 抛 ValueError 会让整月核算 500。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:INT 参数小数原文",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "salesperson.days.boundary",
|
||||
"dimension_value": "salesperson",
|
||||
"value": "360.0",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 分界取整无损:20 天 < 360 天 → 同一递减公式
|
||||
assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125"
|
||||
|
||||
def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
_calc(client, admin["headers"], receipt["id"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_CALC}/ledger",
|
||||
params={"settlement_month": "2026-10", "is_active": 1},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()["data"]["list"]
|
||||
assert rows and rows[0]["business_no"] == biz["business_no"]
|
||||
|
||||
|
||||
class TestRecalc:
|
||||
"""重算:授权动作,作废旧版本、落新版本并留痕。"""
|
||||
|
||||
def test_重算_新版本生效_旧版本保留(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
|
||||
fin = finance
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "成本录入修正"},
|
||||
headers=fin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger)
|
||||
.where(PerformanceLedger.receipt_id == receipt["id"])
|
||||
.order_by(PerformanceLedger.calc_version)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert [(r.calc_version, r.is_active) for r in rows] == [(1, 0), (2, 1)]
|
||||
assert rows[1].calc_reason == "成本录入修正"
|
||||
# 详情回显的是新版本
|
||||
assert (
|
||||
client.get(
|
||||
f"{_CALC}/ledger",
|
||||
params={"receipt_id": receipt["id"], "is_active": 1},
|
||||
headers=admin["headers"],
|
||||
).json()["data"]["list"][0]["calc_version"]
|
||||
== 2
|
||||
)
|
||||
|
||||
def test_重算_原因缺失400(self, client: TestClient, admin: dict) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": " "},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_未核算过_重算400(self, client: TestClient, admin: dict) -> None:
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "无旧版本"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "尚未核算" in resp.json()["message"]
|
||||
|
||||
def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None:
|
||||
"""业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "100.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{second['id']}/recalc",
|
||||
json={"reason": "尝试重算进度登记"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "进度登记" in resp.json()["message"]
|
||||
|
||||
# 同业务仍只有一行生效台账(未凭空新增)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_重算_普通用户与营销后勤403(
|
||||
self, client: TestClient, normal: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
ops = make_user(["marketing_ops"])
|
||||
ops["headers"] = auth_headers(ops)
|
||||
for headers in (normal["headers"], ops["headers"]):
|
||||
resp = client.post(f"{_CALC}/receipts/1/recalc", json={"reason": "x"}, headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestBatch:
|
||||
"""批量核算:逐笔独立事务,失败不中断。"""
|
||||
|
||||
def test_批量_逐笔成败清单(self, client: TestClient, admin: dict) -> None:
|
||||
biz, r1 = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
# 同业务第二笔回款:核算走 #25 重复分支(ok=True、repeated=True)
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "100.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
r2 = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/batch",
|
||||
json={"receipt_ids": [r1["id"], r2["id"], 999999]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
results = resp.json()["data"]
|
||||
by_id = {r["receipt_id"]: r for r in results}
|
||||
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
|
||||
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
|
||||
assert by_id[999999]["ok"] is False
|
||||
|
||||
|
||||
class TestSecondaryAllocation:
|
||||
"""二次分摊拆行(澄清 #1 答复 2026-10-19:分配绩效——算后分)。
|
||||
|
||||
方案行直插入库(跳过保存侧校验,聚焦核算引擎;保存侧校验用例在
|
||||
test_businesses)。金标准:双类别混合业务绩效 39.11 / 折算 4000.00,
|
||||
拆 30%+40% 后参与人 11.73/15.64、负责人余额 11.74,行合计守恒。
|
||||
"""
|
||||
|
||||
def _add_plan(self, business_id: int, rows: list[tuple[int, str]]) -> None:
|
||||
"""直插方案行(测试隔离造数;核算引擎按「当前落库方案」消费)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
for pid, ratio in rows:
|
||||
person = session.get(Personnel, pid)
|
||||
session.add(
|
||||
SecondaryAllocation(
|
||||
business_id=business_id,
|
||||
personnel_id=pid,
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal(ratio),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_分摊拆行_参与人比例与负责人余额守恒(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.receipt_id == receipt["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 3, "负责人+两参与人各一行"
|
||||
by_pid = {r.personnel_id: r for r in rows}
|
||||
a, b, o = (
|
||||
by_pid[parts[0]["id"]],
|
||||
by_pid[parts[1]["id"]],
|
||||
by_pid[biz["owner_personnel_id"]],
|
||||
)
|
||||
# 绩效份额:A=39.11×30%=11.73、B=39.11×40%=15.64、负责人=守恒余额 11.74
|
||||
assert (a.performance_amount, b.performance_amount, o.performance_amount) == (
|
||||
Decimal("11.73"),
|
||||
Decimal("15.64"),
|
||||
Decimal("11.74"),
|
||||
)
|
||||
# 行合计 == 业务级全额(守恒是分摊红线:多一分少一分都是错账)
|
||||
assert a.payable_amount + b.payable_amount + o.payable_amount == Decimal("39.11")
|
||||
# 折算额同比例拆分:4000 的 30%/40%/余额
|
||||
assert (a.converted_profit, b.converted_profit, o.converted_profit) == (
|
||||
Decimal("1200.00"),
|
||||
Decimal("1600.00"),
|
||||
Decimal("1200.00"),
|
||||
)
|
||||
# 行级身份固化:参与人各自岗位;快照带方案与角色标签
|
||||
# (ratio 为 NUMERIC(5,2) 读回两位 scale,断言用数值比较)
|
||||
assert a.role_code == b.role_code == "salesperson"
|
||||
assert a.snapshot_json["alloc_role"] == "participant"
|
||||
assert Decimal(a.snapshot_json["alloc_ratio"]) == Decimal("30")
|
||||
assert o.snapshot_json["alloc_role"] == "owner"
|
||||
assert Decimal(o.snapshot_json["alloc_plan"][0]["ratio"]) == Decimal("30")
|
||||
|
||||
def test_分摊后第二笔回款仅登记进度(self, client: TestClient, admin: dict) -> None:
|
||||
"""一次性计提(#25)与分摊叠加:第二笔不新增行、不重复拆分。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="10000.00",
|
||||
)
|
||||
part = _person(client, admin["headers"])
|
||||
self._add_plan(biz["id"], [(part["id"], "50")])
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "5000.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
out = _calc(client, admin["headers"], second["id"]).json()["data"]
|
||||
assert out["repeated"] is True
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 2, "第二笔回款不得新增拆分行(一次性计提)"
|
||||
|
||||
def test_重算作废全部拆分行并落新版本(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
parts = [_person(client, admin["headers"]) for _ in range(2)]
|
||||
self._add_plan(biz["id"], [(parts[0]["id"], "30"), (parts[1]["id"], "40")])
|
||||
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/recalc",
|
||||
json={"reason": "用例:分摊重算"},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(PerformanceLedger.receipt_id == receipt["id"])
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
active = [r for r in rows if r.is_active == 1]
|
||||
inactive = [r for r in rows if r.is_active == 0]
|
||||
assert len(active) == 3 and len(inactive) == 3
|
||||
assert {r.calc_version for r in active} == {2}, "新版本三行"
|
||||
assert {r.calc_version for r in inactive} == {1}, "旧版本三行全部作废"
|
||||
|
||||
def test_合计超100核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
part = _person(client, admin["headers"])
|
||||
self._add_plan(biz["id"], [(part["id"], "60")])
|
||||
# 再直插另一行绕过保存校验(合计 110),核算侧防御必须拦
|
||||
other = _person(client, admin["headers"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
person = session.get(Personnel, other["id"])
|
||||
session.add(
|
||||
SecondaryAllocation(
|
||||
business_id=biz["id"],
|
||||
personnel_id=other["id"],
|
||||
personnel_name=person.personnel_name,
|
||||
ratio=Decimal("50"),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_参与人含负责人核算侧拦截(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="100.00",
|
||||
)
|
||||
self._add_plan(biz["id"], [(biz["owner_personnel_id"], "30")])
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 400
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
@@ -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_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 16
|
||||
assert len(items) == 19
|
||||
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:
|
||||
@@ -435,12 +437,23 @@ class TestPreview:
|
||||
|
||||
|
||||
class TestPermission:
|
||||
"""权限:普通用户全部 403。"""
|
||||
"""权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
|
||||
|
||||
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
resp = client.get(_PARAMS, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 19
|
||||
|
||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||
# 均属拦截;显式断言不泄露数据即可)
|
||||
assert client.get(_PARAMS).status_code in (401, 403)
|
||||
|
||||
def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
assert client.get(_PARAMS, headers=headers).status_code == 403
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
@@ -450,3 +463,8 @@ class TestPermission:
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403
|
||||
@@ -61,7 +61,10 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
|
||||
for table, columns in _parse_tables(sql).items():
|
||||
merged.setdefault(table, set()).update(columns)
|
||||
for table, column in _ALTER_ADD.findall(sql):
|
||||
merged.setdefault(table, set()).add(column)
|
||||
# ADD CONSTRAINT ... UNIQUE 的「CONSTRAINT」不是列(与 _parse_tables
|
||||
# 的约束关键词同一份清单过滤),V8 台账唯一键三列扩展即此形态
|
||||
if column.upper() not in _NON_COLUMN_KEYWORDS:
|
||||
merged.setdefault(table, set()).add(column)
|
||||
for table, column in _ALTER_DROP.findall(sql):
|
||||
merged.setdefault(table, set()).discard(column)
|
||||
return merged
|
||||
@@ -129,7 +132,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,270 @@
|
||||
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
|
||||
|
||||
守的是(计划 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 _set_switch(client: TestClient, headers: dict, value: str) -> None:
|
||||
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置二次分配审批开关={value}",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.secondary_allocation.enabled",
|
||||
"dimension_value": "",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
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:
|
||||
"""历史参与人离职后:编辑业务(保留该行)不应被 400 卡死——
|
||||
personnel_name 冗余固化的设计本就允许离职后回显;仅新增参与人
|
||||
才做在职校验。"""
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
)
|
||||
resp = client.put(
|
||||
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
detail = _detail(client, admin["headers"], data["id"])
|
||||
payload = business_payload(
|
||||
detail["customer_id"],
|
||||
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
|
||||
)
|
||||
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
after = _detail(client, admin["headers"], data["id"])
|
||||
assert after["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
|
||||
|
||||
# 新增一名离职人员仍要拦(新增才做在职校验)
|
||||
p2 = _person(client, admin["headers"])
|
||||
client.put(f"{_PERSONNEL}/{p2['id']}/status", json={"status": 0}, headers=admin["headers"])
|
||||
payload = business_payload(
|
||||
detail["customer_id"],
|
||||
secondary_allocations=[
|
||||
{"personnel_id": p["id"], "ratio": "30.00"},
|
||||
{"personnel_id": p2["id"], "ratio": "20.00"},
|
||||
],
|
||||
)
|
||||
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:
|
||||
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:
|
||||
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
|
||||
_set_switch(client, admin["headers"], "0")
|
||||
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
|
||||
@@ -0,0 +1,751 @@
|
||||
"""月度结算集成测试(M3 ④:生成快照 → 审批 → 锁定)。
|
||||
|
||||
守的还有:
|
||||
- 确认回款口径(#8 策略点):仅 is_active=1 且未入结的台账行入结;
|
||||
入结后重生成同月不再重复计入
|
||||
- 通过即锁定(status=3 终态);锁定后回款写路径 400(走调整单)
|
||||
- 驳回回草稿可重提;开关两路(关=自动通过锁定;开=审批流转)
|
||||
- #12 暂口径:经理个人户业绩不入聚合基数(金标准仅业务员个人户,
|
||||
聚合用例以「无经理台账」形态验证 items 按人汇总不聚合)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement, PerformanceLedger
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_CALC = "/api/v1/calc"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
_GOLD_ITEMS = [
|
||||
{
|
||||
"product_name": "t_市政_月结",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "3000.00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _role_id(role_code: str) -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
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 == role_code,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(
|
||||
client: TestClient,
|
||||
headers: dict,
|
||||
*,
|
||||
role_code: str = "salesperson",
|
||||
manager_id: int | None = None,
|
||||
) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _role_id(role_code),
|
||||
"salary_standard": "3000.00",
|
||||
**({"manager_id": manager_id} if manager_id is not None else {}),
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
"""显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置 {code}={value}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _calc_one(client: TestClient, headers: dict) -> dict:
|
||||
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
||||
cust = customer(client, headers)
|
||||
person = _person(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_月结产品_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": resp.json()["data"]["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return biz
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finance(make_user, auth_headers) -> dict:
|
||||
user = make_user(["finance"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
"""生成快照:确认回款口径与按人汇总。"""
|
||||
|
||||
def test_生成_按人汇总并回填台账(self, client: TestClient, admin: dict) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["settlement_month"] == "2026-10"
|
||||
assert out["status"] == 1 # 草稿
|
||||
assert len(out["items"]) >= 1
|
||||
item = out["items"][0]
|
||||
assert item["is_probation"] in (0, 1)
|
||||
|
||||
# 台账行已回填 settlement_id(入结标记,防重复计入)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
settled = session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
|
||||
).scalar_one()
|
||||
assert settled.person_count >= 1
|
||||
backfilled = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
|
||||
assert all(r.settlement_month == "2026-10" for r in backfilled)
|
||||
|
||||
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2099-01"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无可结算" in resp.json()["message"]
|
||||
|
||||
def test_已审定月份重生成400(self, client: TestClient, admin: dict) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
assert (
|
||||
client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
assert (
|
||||
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]).status_code == 200
|
||||
)
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已审定锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
class TestSubmitAndLock:
|
||||
"""提交审批与锁定(开关两路)。"""
|
||||
|
||||
def test_开关关闭_提交即自动通过锁定(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
sid = resp.json()["data"]["id"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 3 # 自动通过=已审定(锁定)
|
||||
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
||||
assert out["locked_at"] is not None
|
||||
|
||||
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
biz = _calc_one(client, admin["headers"])
|
||||
client.post(f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"])
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
|
||||
# 找该月回款并尝试编辑(400:月已锁定)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
from app.models import Receipt
|
||||
|
||||
receipt_id = int(
|
||||
session.execute(
|
||||
select(Receipt.id).where(Receipt.business_id == biz["id"])
|
||||
).scalar_one()
|
||||
)
|
||||
resp = client.put(
|
||||
f"{_RECEIPTS}/{receipt_id}",
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-21",
|
||||
"amount": "999.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||
|
||||
|
||||
class TestSwitchOn:
|
||||
"""开关开启:审批流转后锁定。"""
|
||||
|
||||
def test_提交待审_通过后锁定(
|
||||
self, client: TestClient, admin: dict, finance: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
# 开月结审批开关(立即生效)
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "开月结审批",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.monthly_settlement.enabled",
|
||||
"dimension_value": "",
|
||||
"value": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
leader = make_user(["leader"])
|
||||
leader["headers"] = auth_headers(leader)
|
||||
|
||||
# 造 11 月的台账(避开 10 月已入结月份)
|
||||
cust = customer(client, admin["headers"])
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_11月_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-11-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-11-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
assert (
|
||||
client.post(
|
||||
f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"]
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-11"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
sid = resp.json()["data"]["id"]
|
||||
|
||||
# 提交 → 待财务复核
|
||||
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 2
|
||||
|
||||
# 财务通过 → 待领导(approval_order_no 关联的审批单流转)
|
||||
order_no = resp.json()["data"]["approval_order_no"]
|
||||
order = client.get(
|
||||
"/api/v1/system/approvals", params={"keyword": order_no}, headers=admin["headers"]
|
||||
).json()["data"]["list"][0]
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后
|
||||
# 锁定字段残留、两级审批形同虚设)
|
||||
mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
|
||||
assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定"
|
||||
assert mid["locked_at"] is None
|
||||
assert mid["locked_by"] is None
|
||||
|
||||
# 领导通过 → 锁定
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=leader["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
final = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
|
||||
assert final["status"] == 3
|
||||
assert final["locked_at"] is not None
|
||||
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
||||
|
||||
|
||||
class TestManagerAggregate:
|
||||
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
|
||||
|
||||
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
|
||||
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
|
||||
manager = _person(client, headers, role_code="sales_manager")
|
||||
sales = _person(client, headers, manager_id=manager["id"])
|
||||
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=sales["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": resp.json()["data"]["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
assert (
|
||||
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
|
||||
== 200
|
||||
)
|
||||
return manager, receipt
|
||||
|
||||
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
items = out["items"]
|
||||
manager_items = [i for i in items if i["calc_type"] == 2]
|
||||
assert len(manager_items) == 1
|
||||
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
|
||||
# (若调用方把百分数再 ×100,此处会是 684.43)
|
||||
assert manager_items[0]["personnel_id"] == manager["id"]
|
||||
assert manager_items[0]["performance_sum"] == "6.84"
|
||||
assert manager_items[0]["payable_sum"] == "6.84"
|
||||
|
||||
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
|
||||
assert rows[0].is_active == 1
|
||||
assert rows[0].receipt_id is None
|
||||
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
|
||||
|
||||
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
|
||||
self._scene(client, admin["headers"])
|
||||
first = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
).json()["data"]
|
||||
second = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
out = second.json()["data"]
|
||||
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
|
||||
assert out["total_payable"] == first["total_payable"] == "45.95"
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
|
||||
|
||||
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
|
||||
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 2,
|
||||
"effective_date": "2026-10-25",
|
||||
"remark": "用例:月末取参锚点",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.share.team",
|
||||
"dimension_value": "",
|
||||
"value": "20",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
|
||||
assert manager_items[0]["performance_sum"] == "9.78"
|
||||
|
||||
# 台账快照固化月末取参日与实际比例原文
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.snapshot_json["params_as_of"] == "2026-10-31"
|
||||
assert row.rate_text == "聚合:20%+5%"
|
||||
assert str(row.receipt_date) == "2026-10-31"
|
||||
|
||||
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
|
||||
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
|
||||
|
||||
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
|
||||
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
|
||||
"""
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1,
|
||||
calc_type=1,
|
||||
business_id=0,
|
||||
business_no="t_个人户",
|
||||
personnel_id=manager["id"],
|
||||
personnel_name=manager["personnel_name"],
|
||||
role_code="sales_manager",
|
||||
customer_type="manager_personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date(2026, 10, 15),
|
||||
rate_text="0",
|
||||
performance_amount=Decimal("100.00"),
|
||||
barter_applied=0,
|
||||
payable_amount=Decimal("0"),
|
||||
settlement_month="2026-10",
|
||||
is_probation=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _manager_share() -> str:
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
assert len(items) == 1
|
||||
return items[0]["performance_sum"]
|
||||
|
||||
# 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致)
|
||||
assert _manager_share() == "6.84"
|
||||
|
||||
# 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:#12 开关计入",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.personal.included",
|
||||
"dimension_value": "",
|
||||
"value": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _manager_share() == "24.34"
|
||||
|
||||
|
||||
class TestAdjustment:
|
||||
"""调整单:已锁定月份补发/扣减的唯一通道。"""
|
||||
|
||||
def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None:
|
||||
# 显式关开关(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"amount": "500.00",
|
||||
"reason": "补发差额",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
# 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 2 # 已生效
|
||||
|
||||
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
base = {
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"reason": "金额校验用例",
|
||||
}
|
||||
for bad in ("-500.00", "0", "0.00", "abc", "1e3"):
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={**base, "amount": bad},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}"
|
||||
|
||||
|
||||
class TestLockInterception:
|
||||
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
|
||||
|
||||
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
|
||||
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
|
||||
|
||||
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
|
||||
"""
|
||||
biz = _calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
unsettled = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-05",
|
||||
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
free_shipment = resp.json()["data"]
|
||||
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 3
|
||||
return settlement, biz, unsettled, free_shipment
|
||||
|
||||
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
resp = client.put(
|
||||
f"{_RECEIPTS}/{unsettled['id']}",
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-23",
|
||||
"amount": "88.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
|
||||
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
|
||||
|
||||
# 新增:落锁定月 400
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 新增:非锁定月放行(拦截不误伤其它月份)
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
nov = resp.json()["data"]
|
||||
|
||||
# 编辑:把非锁定月的发货单改入锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{nov['id']}",
|
||||
json={"ship_date": "2026-10-15", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 编辑:原月即锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{free_shipment['id']}",
|
||||
json={"ship_date": "2026-10-06", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
|
||||
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
from app.models import Receipt
|
||||
|
||||
with get_session_factory()() as session:
|
||||
settled_id = int(
|
||||
session.execute(
|
||||
select(Receipt.id).where(
|
||||
Receipt.business_id == biz["id"],
|
||||
Receipt.settlement_status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{settled_id}/recalc",
|
||||
json={"reason": "锁定月重算"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""核算落库辅助纯函数测试。
|
||||
|
||||
守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN
|
||||
(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分
|
||||
(docstring 声称 HALF_UP,必须用测试锁死实际行为)。
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.services.calc_service import _q2
|
||||
|
||||
|
||||
class TestQ2:
|
||||
"""金额两位小数(HALF_UP)。"""
|
||||
|
||||
def test_半进位一律入(self) -> None:
|
||||
# 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数)
|
||||
assert _q2(Decimal("2.345")) == Decimal("2.35")
|
||||
|
||||
def test_负数半进位远离零(self) -> None:
|
||||
assert _q2(Decimal("-2.345")) == Decimal("-2.35")
|
||||
|
||||
def test_普通进位不变(self) -> None:
|
||||
assert _q2(Decimal("39.111111")) == Decimal("39.11")
|
||||
assert _q2(Decimal("39.115000")) == Decimal("39.12")
|
||||
|
||||
def test_None直通(self) -> None:
|
||||
assert _q2(None) is None
|
||||
@@ -11,10 +11,14 @@ from fractions import Fraction
|
||||
|
||||
from app.services.formula import (
|
||||
apply_barter,
|
||||
clamp_weight_r,
|
||||
converted_profit,
|
||||
manager_team_share,
|
||||
monthly_salary,
|
||||
payment_days,
|
||||
payment_weight,
|
||||
performance_amount,
|
||||
probation_annual_note,
|
||||
profit_rate,
|
||||
salesperson_rate,
|
||||
start_payment_ok,
|
||||
@@ -57,9 +61,14 @@ class TestSalespersonRate:
|
||||
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
|
||||
assert _sp_rate(361) == Fraction(4, 1000)
|
||||
|
||||
def test_第1天按公式为899比90000_非取整为10(self) -> None:
|
||||
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
|
||||
assert _sp_rate(1) == Fraction(899, 90000)
|
||||
def test_首日取整10千分_图示口径裁决(self) -> None:
|
||||
# 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
|
||||
# 首日不递减、直接取起始值;第 2 天起才进入公式段
|
||||
assert _sp_rate(1) == Fraction(10, 1000)
|
||||
|
||||
def test_第2天起进入公式递减段(self) -> None:
|
||||
# 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变)
|
||||
assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000)
|
||||
|
||||
def test_第180天线性段中点千分之8(self) -> None:
|
||||
assert _sp_rate(180) == Fraction(8, 1000)
|
||||
@@ -137,3 +146,68 @@ class TestModifiers:
|
||||
|
||||
def test_起付判定_未达到60点(self) -> None:
|
||||
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
|
||||
|
||||
|
||||
class TestClampWeightR:
|
||||
"""R 钳制:向下取整 + [0,100] 封顶封底(从预览内联提取的共用实现)。"""
|
||||
|
||||
def test_向下取整(self) -> None:
|
||||
assert clamp_weight_r(Decimal("5999.99"), Decimal("10000")) == 59
|
||||
|
||||
def test_封顶100_封底0(self) -> None:
|
||||
assert clamp_weight_r(Decimal("20000"), Decimal("10000")) == 100
|
||||
assert clamp_weight_r(Decimal("0"), Decimal("10000")) == 0
|
||||
|
||||
def test_销售额0_防御0(self) -> None:
|
||||
assert clamp_weight_r(Decimal("100"), Decimal("0")) == 0
|
||||
|
||||
|
||||
class TestMonthlySalary:
|
||||
"""月工资分岗口径三档(对照表 #8)。"""
|
||||
|
||||
def test_INCLUDE_绩效加基本(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "INCLUDE") == Decimal("4000")
|
||||
|
||||
def test_DEDUCT_绩效减基本_可为负(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "DEDUCT") == Decimal("-2000")
|
||||
|
||||
def test_EXCLUDE_仅绩效(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "EXCLUDE") == Decimal("1000")
|
||||
|
||||
def test_基本缺失按0(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), None, "INCLUDE") == Decimal("1000")
|
||||
|
||||
def test_未知口径报错(self) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
monthly_salary(Decimal("1"), Decimal("1"), "UNKNOWN")
|
||||
|
||||
|
||||
class TestProbationAnnualNote:
|
||||
"""试用期年累计说明:超阈值才有 note,金额精确到分。"""
|
||||
|
||||
def test_未超阈值_无说明(self) -> None:
|
||||
assert probation_annual_note(Decimal("18000"), Decimal("18000")) is None
|
||||
assert probation_annual_note(Decimal("100"), Decimal("18000")) is None
|
||||
|
||||
def test_超额_给出精确差额(self) -> None:
|
||||
note = probation_annual_note(Decimal("20000.5"), Decimal("18000"))
|
||||
assert note is not None and "2000.50" in note
|
||||
|
||||
|
||||
class TestManagerTeamShare:
|
||||
"""经理 12.5%+5% 聚合(#12 暂口径:基数不含经理个人户业绩——由调用方
|
||||
传入的基数集合保证,本函数只做乘法与求和)。"""
|
||||
|
||||
def test_两段乘加_精确分数(self) -> None:
|
||||
# 入参为百分数(与种子一致:12.5 表示 12.5%):10000×12.5%=1250、50000×5%=2500
|
||||
got = manager_team_share(
|
||||
Decimal("10000"), Decimal("50000"), team_pct=Fraction(25, 2), company_pct=Fraction(5)
|
||||
)
|
||||
assert got == Decimal("3750")
|
||||
|
||||
def test_零基数_零结果(self) -> None:
|
||||
assert manager_team_share(
|
||||
Decimal("0"), Decimal("0"), team_pct=Fraction(0), company_pct=Fraction(0)
|
||||
) == Decimal("0")
|
||||
@@ -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",
|
||||
@@ -36,6 +40,10 @@ EXPECTED_TABLES = {
|
||||
"shipment_items",
|
||||
"receipts",
|
||||
"customer_rating_grades",
|
||||
"performance_ledgers",
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
"adjustment_orders",
|
||||
}
|
||||
|
||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||
|
||||
@@ -18,7 +18,8 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
|
||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@@ -35,15 +36,18 @@ EXPECTED_CODES = {
|
||||
"product.threshold",
|
||||
"salary.basic.mode",
|
||||
"probation.annual.threshold",
|
||||
"probation.salary.basic",
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
"manager.personal.included",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共16项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 16
|
||||
def test_注册表共19项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 19
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
@@ -61,10 +65,28 @@ 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:
|
||||
"""审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。"""
|
||||
for code in (
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.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
|
||||
@@ -0,0 +1,29 @@
|
||||
"""月结取参锚点测试。
|
||||
|
||||
守的是:月结参数快照按**所属月月末**解析,而不是生成日 —— 用 date.today()
|
||||
取参会让补生成/重新生成历史月份的经理分成与月工资随操作日期漂移,
|
||||
同一月份两次生成金额不同(违背「参数按生效日期时点化」红线)。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.settlement_service import _month_end
|
||||
|
||||
|
||||
class TestMonthEnd:
|
||||
"""YYYY-MM → 所属月最后一天(含大小月与闰年)。"""
|
||||
|
||||
def test_大月31日(self) -> None:
|
||||
assert _month_end("2026-10") == date(2026, 10, 31)
|
||||
|
||||
def test_小月30日(self) -> None:
|
||||
assert _month_end("2026-09") == date(2026, 9, 30)
|
||||
|
||||
def test_二月平年28日(self) -> None:
|
||||
assert _month_end("2026-02") == date(2026, 2, 28)
|
||||
|
||||
def test_二月闰年29日(self) -> None:
|
||||
assert _month_end("2028-02") == date(2028, 2, 29)
|
||||
|
||||
def test_跨年十二月(self) -> None:
|
||||
assert _month_end("2026-12") == date(2026, 12, 31)
|
||||
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"
|
||||
|
||||
+207
-4
@@ -16,6 +16,14 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `performance_ledgers`;
|
||||
DROP TABLE IF EXISTS `settlement_items`;
|
||||
DROP TABLE IF EXISTS `adjustment_orders`;
|
||||
DROP TABLE IF EXISTS `monthly_settlements`;
|
||||
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 +416,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 引用定义,与迁移种子保持同源一致
|
||||
@@ -418,7 +434,7 @@ INSERT INTO business_roles (id, role_code, role_name, calc_template, description
|
||||
(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1);
|
||||
|
||||
INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES
|
||||
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19);
|
||||
(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决与 2026-10-19 澄清答复)', 'system', 19);
|
||||
|
||||
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) VALUES
|
||||
(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1),
|
||||
@@ -436,7 +452,10 @@ 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);
|
||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17),
|
||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18),
|
||||
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19),
|
||||
(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20);
|
||||
|
||||
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 +476,12 @@ 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);
|
||||
-- 审批开关默认开启(澄清 #5/#7 答复 2026-10-19);试用期统一工资参数已撤
|
||||
-- (#16 答复:按人员档案 personnel.salary_standard 逐人维护),本脚本直达终态
|
||||
(20, 17, '', '2026-01-01', '1', 1),
|
||||
(21, 18, '', '2026-01-01', '1', 1),
|
||||
(22, 19, '', '2026-01-01', '0', 1),
|
||||
(25, 20, '', '2026-01-01', '0', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||
@@ -663,6 +687,185 @@ 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 uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_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);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE monthly_settlements (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)',
|
||||
status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1',
|
||||
total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)',
|
||||
total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)',
|
||||
person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0',
|
||||
snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)',
|
||||
approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
locked_at DATETIME COMMENT '锁定时间(status=3 时回填)',
|
||||
locked_by VARCHAR(64) 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_monthly_settlements PRIMARY KEY (id),
|
||||
CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month),
|
||||
CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE settlement_items (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)',
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)',
|
||||
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合',
|
||||
performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)',
|
||||
payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)',
|
||||
basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)',
|
||||
salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE',
|
||||
net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)',
|
||||
is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0',
|
||||
probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)',
|
||||
snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)',
|
||||
CONSTRAINT pk_settlement_items PRIMARY KEY (id),
|
||||
CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE adjustment_orders (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}',
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)',
|
||||
direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减',
|
||||
amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)',
|
||||
reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)',
|
||||
status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' 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_adjustment_orders PRIMARY KEY (id),
|
||||
CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no),
|
||||
CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE performance_ledgers (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)',
|
||||
calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1',
|
||||
is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1',
|
||||
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1',
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)',
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)',
|
||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise',
|
||||
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)',
|
||||
ship_date DATE COMMENT '发货日期(回款天数基准)',
|
||||
receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)',
|
||||
payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)',
|
||||
rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)',
|
||||
converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)',
|
||||
performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)',
|
||||
barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0',
|
||||
weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)',
|
||||
payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)',
|
||||
payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)',
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)',
|
||||
is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0',
|
||||
missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0',
|
||||
snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)',
|
||||
settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)',
|
||||
calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)',
|
||||
remark VARCHAR(255) 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_performance_ledgers PRIMARY KEY (id),
|
||||
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id),
|
||||
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||
INSERT INTO system_settings (id, company_name, logo_path) VALUES (1, '衍水集团', NULL);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -671,4 +874,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 ('ba0217f5091b');
|
||||
@@ -0,0 +1,81 @@
|
||||
-- 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,
|
||||
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)
|
||||
)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';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Running upgrade 79bcc5aa98d4 -> 3bca400421c6
|
||||
|
||||
CREATE TABLE monthly_settlements (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '结算月份(YYYY-MM;全表唯一)',
|
||||
status TINYINT NOT NULL COMMENT '状态:1草稿 2待审批 3已审定(终态=已锁定)4已驳回(回草稿)' DEFAULT '1',
|
||||
total_performance NUMERIC(14, 2) COMMENT '绩效金额合计(可负)',
|
||||
total_payable NUMERIC(14, 2) COMMENT '应付绩效合计(可负)',
|
||||
person_count TINYINT NOT NULL COMMENT '结算人数' DEFAULT '0',
|
||||
snapshot_json JSON COMMENT '月度总览快照(生成时刻的确认回款口径说明与汇总参数)',
|
||||
approval_order_no VARCHAR(32) COMMENT '关联审批单号(冗余固化;开关关闭自动通过时也有单)',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
locked_at DATETIME COMMENT '锁定时间(status=3 时回填)',
|
||||
locked_by VARCHAR(64) 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_monthly_settlements PRIMARY KEY (id),
|
||||
CONSTRAINT fk_monthly_settlements_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_monthly_settlements_settlement_month UNIQUE (settlement_month)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月度结算表(按月一行;通过即锁定,锁定后走调整单)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE adjustment_orders (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '调整单号,自动生成 TZ{YYYYMMDD}-{4位当日序号}',
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '调整月份(YYYY-MM;跨月规则待澄清 #9 前不限制)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '调整人员ID(personnel.id)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '调整人员姓名(冗余固化)',
|
||||
direction TINYINT NOT NULL COMMENT '方向:1补发 2扣减',
|
||||
amount NUMERIC(14, 2) NOT NULL COMMENT '调整金额(恒正,方向表达语义)',
|
||||
reason VARCHAR(255) NOT NULL COMMENT '扣补说明(必填,审批与追溯锚点)',
|
||||
status TINYINT NOT NULL COMMENT '状态:1待审批 2已生效 3已驳回 4已撤回' 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_adjustment_orders PRIMARY KEY (id),
|
||||
CONSTRAINT fk_adjustment_orders_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_adjustment_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
CONSTRAINT uk_adjustment_orders_order_no UNIQUE (order_no)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='调整单表(已锁定月份的补发/扣减;走审批;生效后不回写月结快照)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE settlement_items (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '月结ID(monthly_settlements.id;头删明细随删)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '结算人员ID(personnel.id)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '结算人员姓名(冗余固化)',
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(主岗,聚合锚点)',
|
||||
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合',
|
||||
performance_sum NUMERIC(14, 2) NOT NULL COMMENT '绩效金额合计(可负;聚合行排除经理个人户业绩,#12 暂口径)',
|
||||
payable_sum NUMERIC(14, 2) NOT NULL COMMENT '应付绩效合计(可负)',
|
||||
basic_amount NUMERIC(14, 2) COMMENT '月工资标准(personnel.salary_standard 当月快照)',
|
||||
salary_mode VARCHAR(16) COMMENT '月工资口径快照:INCLUDE/DEDUCT/EXCLUDE',
|
||||
net_amount NUMERIC(14, 2) COMMENT '净工资 = 绩效±基本工资(按口径;试用期 0)',
|
||||
is_probation TINYINT NOT NULL COMMENT '试用期:1绩效照算不发放(net=0,超年累计记 note)0否' DEFAULT '0',
|
||||
probation_note VARCHAR(255) COMMENT '试用期说明(年累计超阈值部分年底一次性结算)',
|
||||
snapshot_json JSON COMMENT '个人明细快照(台账行引用列表)',
|
||||
CONSTRAINT pk_settlement_items PRIMARY KEY (id),
|
||||
CONSTRAINT fk_settlement_items_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_settlement_items_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE CASCADE
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='月结明细表(按人固化;支持负数工资;锁定后只读)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE performance_ledgers (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
receipt_id BIGINT UNSIGNED COMMENT '回款ID(receipts.id;聚合行可为 NULL)',
|
||||
calc_version TINYINT NOT NULL COMMENT '核算版本(重算+1,旧行 is_active=0)' DEFAULT '1',
|
||||
is_active TINYINT NOT NULL COMMENT '生效标记:1当前生效 0已被重算替代(重算受控 UPDATE 例外)' DEFAULT '1',
|
||||
calc_type TINYINT NOT NULL COMMENT '核算类型:1单笔回款 2经理团队聚合(月度)' DEFAULT '1',
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化无外键;聚合行为该经理名下首行业务ID,报表下钻锚点)',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '业务编号(冗余固化,列表免联查)',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '归属营销人员ID(personnel.id;核算归属锚点)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '归属营销人员姓名(冗余固化)',
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '岗位编码(business_roles.role_code,计提公式锚点)',
|
||||
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal/manager_personal/enterprise',
|
||||
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water/rail_transit/mixed(混合业务,行级明细入 snapshot_json)',
|
||||
ship_date DATE COMMENT '发货日期(回款天数基准)',
|
||||
receipt_date DATE NOT NULL COMMENT '回款日期(参数版本匹配日,裁决 #11)',
|
||||
payment_days INTEGER COMMENT '回款天数(发货日首日计第 1 天;未指定批次为 NULL;先款后货照实负值)',
|
||||
rate_text VARCHAR(32) NOT NULL COMMENT '计提比例原文(如 4/360,防 Fraction 截断;聚合行存 12.5%/5% 结构说明)',
|
||||
converted_profit NUMERIC(14, 2) COMMENT '折算毛利润额(可负;混合业务为各行汇总)',
|
||||
performance_amount NUMERIC(14, 2) COMMENT '绩效金额(可负,裁决:负绩效照算不归零)',
|
||||
barter_applied TINYINT NOT NULL COMMENT '以物抵顶折算:1已按 80% 折算 0否' DEFAULT '0',
|
||||
weight_r INTEGER COMMENT '支付权重 R(回款占销售额百分比向下取整)',
|
||||
payment_weight NUMERIC(7, 6) COMMENT '支付权重系数 R×R(展示用)',
|
||||
payable_amount NUMERIC(14, 2) COMMENT '当期应付绩效(绩效 × 支付权重;聚合行为当月汇总)',
|
||||
settlement_month VARCHAR(7) NOT NULL COMMENT '归属月(回款日期所在月,YYYY-MM;月结与报表维度)',
|
||||
is_probation TINYINT NOT NULL COMMENT '试用期:1照算不发放 0否' DEFAULT '0',
|
||||
missing_cost TINYINT NOT NULL COMMENT '成本缺失:1有明细行缺成本(该行毛利按 0 计)0否' DEFAULT '0',
|
||||
snapshot_json JSON COMMENT '计算快照(所用参数版本与行级折算明细,重算可回溯)',
|
||||
settlement_id BIGINT UNSIGNED COMMENT '所属月结ID(月结生成时回填;NULL=未入结)',
|
||||
calc_reason VARCHAR(255) COMMENT '核算/重算原因(重算必填留痕)',
|
||||
remark VARCHAR(255) 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_performance_ledgers PRIMARY KEY (id),
|
||||
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
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 (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道', 19, 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (22, 19, '', '2026-01-01', '0', 1);
|
||||
|
||||
UPDATE alembic_version SET version_num='3bca400421c6' WHERE alembic_version.version_num = '79bcc5aa98d4';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Running upgrade 3bca400421c6 -> c7d2e5a81f30
|
||||
|
||||
UPDATE param_definitions SET default_value = '1' WHERE id IN (17, 18);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (23, 17, '', '2026-10-19', '1', 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (24, 18, '', '2026-10-19', '1', 1);
|
||||
|
||||
DELETE FROM param_values WHERE id = 19;
|
||||
|
||||
DELETE FROM param_definitions WHERE id = 16;
|
||||
|
||||
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 (20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20, 1);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (25, 20, '', '2026-01-01', '0', 1);
|
||||
|
||||
ALTER TABLE performance_ledgers ADD CONSTRAINT uk_performance_ledgers_receipt_id_calc_version_personnel UNIQUE (receipt_id, calc_version, personnel_id);
|
||||
|
||||
ALTER TABLE performance_ledgers DROP INDEX uk_performance_ledgers_receipt_id_calc_version;
|
||||
|
||||
UPDATE alembic_version SET version_num='c7d2e5a81f30' WHERE alembic_version.version_num = '3bca400421c6';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Running upgrade c7d2e5a81f30 -> ba0217f5091b
|
||||
|
||||
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'business_secondary_allocations' AND CONSTRAINT_NAME = 'uk_business_secondary_allocations_business_personnel');
|
||||
|
||||
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE business_secondary_allocations ADD CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)', 'DO 0');
|
||||
|
||||
PREPARE stmt FROM @ddl;
|
||||
|
||||
EXECUTE stmt;
|
||||
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE alembic_version SET version_num='ba0217f5091b' WHERE alembic_version.version_num = 'c7d2e5a81f30';
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
|
||||
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.
+101
@@ -0,0 +1,101 @@
|
||||
# 备份恢复手册(⑨ 备份恢复与运维检查)
|
||||
|
||||
> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。
|
||||
|
||||
## 一、备份(每日一次,建议凌晨低峰)
|
||||
|
||||
```bash
|
||||
# 项目根目录执行
|
||||
bash scripts/backup_full.sh
|
||||
```
|
||||
|
||||
- 产物:`db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz`(gzip 压缩)
|
||||
- 保留策略:自动清理 30 天前的旧备份
|
||||
- 凭据:从 `docker/.env` 读取(不入脚本、不入版本库)
|
||||
|
||||
**Windows 定时任务**(任务计划程序 / `schtasks`):
|
||||
|
||||
```cmd
|
||||
:: 每日 02:30 备份(经 Git Bash)
|
||||
schtasks /create /tn "ys-payroll-backup" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_full.sh\"" /sc daily /st 02:30 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 二、恢复演练(备份后顺手跑一次,验证备份真能用)
|
||||
|
||||
```bash
|
||||
bash scripts/restore_drill.sh # 恢复到临时库 + 行数核对,临时库默认保留
|
||||
bash scripts/restore_drill.sh --drop-exec # 核对通过后再加此参数删除临时库
|
||||
```
|
||||
|
||||
- **绝不直接覆盖生产库**——演练恢复到独立临时库 `payroll_drill_<时间戳>`
|
||||
- 行数对比覆盖 16 张关键表(users/roles/param_*/business*/personnel/products/customers/shipments*/receipts/approval_*/system_settings)
|
||||
- 全部 ✓ 即备份可恢复;出现 ✗ 时临时库保留供排查
|
||||
|
||||
## 二·五、增量备份(澄清 #19 答复:每日全量 + 有更新时增量补充)
|
||||
|
||||
全量之间的数据变更由 **binlog 归档**覆盖(MySQL 8 默认开启 binlog,ROW 格式、服务端保留 30 天):
|
||||
|
||||
```bash
|
||||
bash scripts/backup_incremental.sh
|
||||
```
|
||||
|
||||
- 产物:`db/backups/binlog.NNNNNN.gz`(原始 binlog 文件逐个压缩)
|
||||
- **有更新时才产出**:状态文件 `db/backups/.incremental_state` 记录上次归档到的文件,无新变更时直接退出
|
||||
- 只归档「已写完」的 binlog(正在写的最后一个留到下次收取)
|
||||
- 保留策略同全量:自动清理 30 天前的增量包
|
||||
|
||||
**配套**:`backup_full.sh` 每次全量会同时产出位点文件 `payroll_*.sql.gz.pos`(binlog 文件名 + 位点),标记「该全量覆盖到此」——恢复重放从位点之后开始。
|
||||
|
||||
**恢复顺序**(真出事时):最新全量 → 依次 `gunzip` 其后的增量包 → 用 `mysqlbinlog` 重放(镜像内无此客户端,宿主机或临时容器执行):
|
||||
|
||||
```bash
|
||||
# 解出增量包后重放(示例:从全量位点之后开始)
|
||||
gunzip -c db/backups/binlog.000003.gz > /tmp/binlog.000003
|
||||
mysqlbinlog --start-position=<全量位点> /tmp/binlog.000003 | \
|
||||
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
|
||||
```
|
||||
|
||||
**建议定时**:增量与全量同节奏(每日 02:30 全量后接一次增量;变更频繁的日子可在中午加跑一次):
|
||||
|
||||
```cmd
|
||||
schtasks /create /tn "ys-payroll-backup-incr" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_incremental.sh\"" /sc daily /st 02:50 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 三、生产恢复(真出事了,七步)
|
||||
|
||||
> ⚠️ 生产恢复是**高危操作**:先把容器停了再恢复,避免写冲突。下面假设恢复到**全新空数据卷**(最干净路径)。
|
||||
|
||||
1. **停容器保卷**:`docker compose -f docker/docker-compose.dev.yml down`
|
||||
2. **删旧卷(谨慎!确认要全量重来才执行)**:`docker volume rm ys-payroll_mysql-dev-data`
|
||||
- 若只想恢复到某个时间点**且不丢其它数据**,改用「恢复到临时库 → mysqldump 单表导出 → 手工回灌」的细粒度路径,勿整体覆盖。
|
||||
3. **起空库**:`docker compose -f docker/docker-compose.dev.yml up -d`(首启自动跑 `db/init/init-dev.sql` 建表 + 种子)
|
||||
4. **选一份备份**:`ls -t db/backups/payroll_*.sql.gz | head`
|
||||
5. **导入该备份**:
|
||||
```bash
|
||||
gunzip -c db/backups/payroll_XXXXXXXX_XXXXXX.sql.gz | \
|
||||
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
|
||||
```
|
||||
6. **核对行数**:再跑一次 `bash scripts/restore_drill.sh`(此时源库=刚恢复的库,应全 ✓)
|
||||
7. **重启后端**:`cd backend && uv run uvicorn app.main:app --reload --port 8000`,登录抽查几个页面确认数据回来
|
||||
|
||||
## 四、运维健康检查(每日一次)
|
||||
|
||||
```bash
|
||||
bash scripts/health_check.sh
|
||||
```
|
||||
|
||||
六项检查:容器健康 ×2 / 磁盘水位(80% 阈值)/ 备份新鲜度(24h 内)/ Redis 探活 / 24h 登录失败次数(暴力破解预警)。任一项 WARN/FAIL 时退出码非零,方便监控采集。
|
||||
|
||||
**Windows 定时**:
|
||||
|
||||
```cmd
|
||||
:: 每日 08:50 检查(上班前出报告)
|
||||
schtasks /create /tn "ys-payroll-health" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/health_check.sh >> db/backups/health.log 2>&1\"" /sc daily /st 08:50 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
- 备份含**全部业务数据与参数种子**,属敏感文件——`db/backups/` 已加入 `.gitignore`,**不要**把它提交到版本库
|
||||
- 恢复演练产生的 `payroll_drill_*` 库会占磁盘,核对完及时 `--drop-exec` 清理
|
||||
- 备份脚本与恢复脚本都只依赖 Docker(无需宿主机装 MySQL 客户端)
|
||||
- 增量重放的 `mysqlbinlog` 是唯一需要宿主机(或临时容器)具备的客户端——官方 mysql:8.0 镜像内不含;重放前务必确认 binlog 序号连续(中间断号说明有未归档文件,先跑一次增量归档)
|
||||
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
# 待确认事项汇总(2026-10-16)
|
||||
|
||||
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
|
||||
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
||||
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
||||
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
||||
|
||||
## 一、立即待办(需项目内处理)
|
||||
|
||||
| # | 事项 | 状态 | 下一步动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **推送 feat/m3-increments** | ✅ 已完成(2026-10-19) | 远端 Git 服务恢复后已推送,远端现为 e48cfb7(含 code-review 修复 4 提交 + #14 首日口径 + V8 系列 + 测试/文档);并行会话(88:calc_service 分摊拆行)收口后继续推送其后提交 |
|
||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
|
||||
|
||||
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
||||
|
||||
> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。
|
||||
|
||||
### 高优先级(阻塞对应模块开工,8 项)
|
||||
|
||||
| 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
|
||||
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
|
||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
||||
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 |
|
||||
|
||||
### 中优先级(模块实现期内答复即可,9 项)
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 |
|
||||
| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 |
|
||||
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
|
||||
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
|
||||
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
|
||||
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
||||
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
||||
|
||||
### 低优先级(已有默认口径,顺手确认,7 项)
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 |
|
||||
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 |
|
||||
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
|
||||
| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 |
|
||||
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
|
||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||
|
||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
||||
|
||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
||||
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||
|
||||
### 高优先级(8 项)
|
||||
|
||||
| # | 事项 |
|
||||
| --- | --- |
|
||||
| 1 | **成本额取数口径**(阻塞核算:成本类型/毛利计算方式/更新时间/精度) |
|
||||
| 2 | 发货日期口径(出库日/签收日) |
|
||||
| 3 | 到账日期口径(银行到账日/核销日) |
|
||||
| 4 | 幂等唯一键规则(作废重开是否换号) |
|
||||
| 5 | 三单关联键(订单/发货/回款如何关联) |
|
||||
| 6 | **人员唯一编码(工号)+ 业务员↔经理隶属**(隶属已列必须;识别口径疑笔误见上方追问①) |
|
||||
| 7 | 对接方式与安全(REST/视图/中间表/文件) |
|
||||
| 8 | 负数/红字/退货/冲销处理规则与原单关联 |
|
||||
| 16 | 回款确认/核销状态字段与枚举 |
|
||||
|
||||
### 中低优先级(9 项)
|
||||
|
||||
存量起始日期与增量口径、客户档案匹配与唯一键、收款方式枚举清单、产品「数量」语义、发货单价口径、特殊业务标记字段、同步窗口时间、市场拓展计划数据、口径裁决 5 项(#20–#24 已转业务方)。
|
||||
|
||||
## 四、开发里程碑待办
|
||||
|
||||
| 里程碑 | 内容 | 前置条件 |
|
||||
| --- | --- | --- |
|
||||
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
||||
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
||||
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
||||
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||
|
||||
## 五、文档待办(低优)
|
||||
|
||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
|
||||
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
||||
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
||||
Binary file not shown.
Binary file not shown.
@@ -149,6 +149,15 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
|
||||
运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。
|
||||
|
||||
## 前端页面(2026-10-17 追加 · M3-core ④ 核算与月结)
|
||||
|
||||
| 页面 | 说明 |
|
||||
| --- | --- |
|
||||
| `/calc/ledger` 绩效台账(新组「绩效工资」order17) | 月/版本筛选、行展开计算快照、重算(原因对话框,管理员/财务) |
|
||||
| `/settlements` 月度结算(order18) | 生成快照(月份输入)→ 状态筛选 → 提交审批(开关关=自动通过锁定)→ 详情抽屉(按人三口径明细/试用期说明/审批单号) |
|
||||
|
||||
路由组「绩效工资」;api 封装 `src/api/{calc,settlements}.ts`。type-check 与 build 通过(2026-10-17)。
|
||||
|
||||
## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据)
|
||||
|
||||
| 脚本 | 覆盖 | 结果 |
|
||||
@@ -159,3 +168,13 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
||||
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
||||
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
|
||||
|
||||
## 端到端测试(2026-10-16 追加 · M3 增量批次)
|
||||
|
||||
| 脚本 | 覆盖 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| `e2e/settings_flow.py` | 公司信息 4 步:进设置页改公司名(随机名防撞)→ 保存 → 退出登录 → **登录页品牌位与页脚即时显示新名**(public 端点)→ finally 还原默认名(防污染其它 e2e 的品牌断言) | ✓ 全通过(修复 public 缓存缺陷后) |
|
||||
| `e2e/secondary_allocation_flow.py` | 二次分配 6 步:路由直达业务管理 → 建客户 → 建 2 名在职营销员 → 新增业务录两行参与人(30%+40%)→ **编辑回显 2 行 + 合计条「余额 30.00%(归属待业务确认)」** → 删除清理 | ✓ 全通过(修复 logo 无关的三处 e2e 选择器后) |
|
||||
|
||||
运行前置同上(需 `E2E_ADMIN_PASSWORD`)。**期间修复的缺陷**:public 端点 300s 响应缓存让登录页拿旧公司名(改 `fetch cache: no-store` + 时间戳绕过磁盘缓存);业务弹窗分配表与产品明细共用 `items-table` 类导致选择器错位(分配表加专属 `alloc-table` 类)。
|
||||
**回归**:既有五脚本(登录 8 / 系统管理 13 / 基础数据 12 / 业务 16 / 参数 7 步)在 allowedRoles 路由扩展与品牌名动态化后全部复跑通过。
|
||||
@@ -0,0 +1,234 @@
|
||||
"""二次分配方案录入端到端验证。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
|
||||
并填比例 → 提交成功
|
||||
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
|
||||
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
|
||||
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
|
||||
|
||||
运行前置(缺一不可):
|
||||
docker compose up -d # MySQL + Redis
|
||||
cd backend && uv run uvicorn app.main:app --port 8000
|
||||
cd frontend && pnpm dev
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
confirm_message_box,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
login,
|
||||
visible_dialog,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
SUFFIX = f"{int(time.time())}"
|
||||
console_errors: list[str] = []
|
||||
|
||||
|
||||
def step_navigate_to_business(page: Page, admin_password: str) -> None:
|
||||
login(page, ADMIN, admin_password)
|
||||
# 路由直达业务管理:比点菜单更稳(open_menu 展开分组的两段点击可能与
|
||||
# 弹窗关闭动画竞争,导致后续点击被残留 overlay 拦截)
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
if current_path(page) != "/business/businesses":
|
||||
fail("菜单导航", f"未进入业务管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 进入业务管理页")
|
||||
|
||||
|
||||
def step_create_customer_via_ui(page: Page) -> str:
|
||||
"""经客户管理页建一个 t_ 客户(业务必选引用),返回客户名。"""
|
||||
page.goto(f"{BASE_URL}/base/customers", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.click("button:has-text('新增客户')")
|
||||
dialog = visible_dialog(page)
|
||||
customer_name = f"t_e2e客户_{SUFFIX}"
|
||||
dialog.locator('input[placeholder="与负责人构成组合唯一键"]').fill(customer_name)
|
||||
dialog.locator('input[placeholder="客户公司的负责人姓名"]').fill("张老板")
|
||||
# 客户分类:个人户(下拉在表单内,需先点开)
|
||||
dialog.locator(".el-select").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="个人户").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
dialog.locator("button:has-text('保存')").click()
|
||||
expect_toast(page, "客户已创建", "新增客户")
|
||||
page.wait_for_timeout(500)
|
||||
# 关闭客户弹窗,避免残留 overlay 遮挡后续业务弹窗的菜单点击
|
||||
close_btn = dialog.locator(".el-dialog__header button").last
|
||||
if close_btn.count():
|
||||
close_btn.click()
|
||||
page.wait_for_timeout(400)
|
||||
print(f"✓ 2. 准备客户 新建 t_ 客户 {customer_name}")
|
||||
return customer_name
|
||||
|
||||
|
||||
def step_create_personnel(page: Page) -> None:
|
||||
"""造 2 名 t_ 在职营销员(二次分配参与人下拉的选项来源)。"""
|
||||
page.goto(f"{BASE_URL}/base/personnel", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
for i in range(2):
|
||||
page.click("button:has-text('新增人员')")
|
||||
dialog = visible_dialog(page)
|
||||
dialog.locator('input[placeholder="营销人员姓名"]').fill(f"t_e2e员_{SUFFIX}_{i}")
|
||||
# 岗位(表单内第一个 select):选第一项(营销员种子岗位)
|
||||
dialog.locator(".el-select").first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item").first.click()
|
||||
page.wait_for_timeout(300)
|
||||
# 月工资标准(选填,给个合法值)
|
||||
dialog.locator('.el-form-item:has-text("工资标准") input').first.fill("3000.00")
|
||||
dialog.locator("button:has-text('保存')").click()
|
||||
expect_toast(page, "已创建", f"新增人员{i+1}")
|
||||
page.wait_for_timeout(600)
|
||||
print("✓ 2b. 准备人员 新建 2 名 t_ 在职营销员")
|
||||
|
||||
|
||||
def step_create_business_with_allocation(page: Page, customer_name: str) -> None:
|
||||
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.click("button:has-text('新增业务')")
|
||||
dialog = visible_dialog(page)
|
||||
|
||||
# 主表:客户 + 一行明细
|
||||
dialog.locator(".el-select").first.click()
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=customer_name).first.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 明细行(至少 1 行):产品名 + 数量 + 单价
|
||||
# 明细表列序:产品(0)/规格(1)/单位(2)/类别(3)/数量(4)/单价(5)/成本(6)/指导价(7)
|
||||
first_row = dialog.locator(".items-table tbody tr").first
|
||||
first_row.locator('input[placeholder="产品名称,可手填"]').fill(f"t_e2e产品_{SUFFIX}")
|
||||
first_row.locator("td").nth(4).locator("input").fill("10")
|
||||
first_row.locator("td").nth(5).locator("input").fill("100.00")
|
||||
|
||||
# 二次分配方案:添加两行参与人(按钮先滚动进可视区,避免被 overlay 遮挡)
|
||||
for _ in range(2):
|
||||
add_btn = page.locator("button:has-text('添加参与人')")
|
||||
add_btn.scroll_into_view_if_needed()
|
||||
add_btn.click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 弹窗里分配表有专属类 alloc-table(与产品明细的 items-table 区分),
|
||||
# 避免空分配表时 nth(1) 错位到产品明细
|
||||
alloc_rows = page.locator(".el-dialog:visible .alloc-table tbody tr")
|
||||
for i in range(2):
|
||||
row = alloc_rows.nth(i)
|
||||
row.locator(".el-select").first.click()
|
||||
page.wait_for_timeout(400)
|
||||
options = page.locator(".el-select-dropdown:visible .el-select-dropdown__item")
|
||||
if options.count() < 2:
|
||||
fail("参与人下拉", f"可分配人员不足 2 人(需先在人员管理录入),当前 {options.count()}")
|
||||
options.nth(i).click()
|
||||
page.wait_for_timeout(300)
|
||||
row.locator("td").nth(1).locator("input").fill("30.00" if i == 0 else "40.00")
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
dialog.locator("button:has-text('保存')").click()
|
||||
expect_toast(page, "已创建", "新增业务(含二次分配)")
|
||||
page.wait_for_timeout(800)
|
||||
print("✓ 3. 录入方案 新增业务并录两行参与人(30% + 40%)")
|
||||
|
||||
|
||||
def step_verify_allocation_echo(page: Page) -> None:
|
||||
"""编辑抽屉回显:二次分配两行仍在;合计提示展示余额。"""
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.click("button:has-text('查询')")
|
||||
page.wait_for_timeout(500)
|
||||
first_row = page.locator(".el-table__row").first
|
||||
if first_row.count() == 0:
|
||||
fail("业务列表", "列表为空,未能找到刚创建的业务")
|
||||
first_row.locator("button:has-text('编辑')").click()
|
||||
dialog = visible_dialog(page)
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
# 二次分配表应有 2 行;合计提示:30+40=70,余额 30
|
||||
alloc_table = page.locator(".el-dialog:visible .alloc-table")
|
||||
alloc_count = alloc_table.locator("tbody tr").count()
|
||||
if alloc_count != 2:
|
||||
fail("方案回显", f"编辑抽屉二次分配应为 2 行,实际 {alloc_count}")
|
||||
|
||||
total_bar = page.locator(".el-dialog:visible .alloc-total")
|
||||
body_text = total_bar.inner_text() if total_bar.count() else ""
|
||||
if "余额" not in body_text:
|
||||
fail("合计提示", f"两行非满额应展示余额提示,当前:{body_text or '(无提示)'}")
|
||||
print(f"✓ 4. 编辑回显 二次分配 2 行保留,合计提示:{body_text.strip()}")
|
||||
|
||||
dialog.locator("button:has-text('取消')").click()
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.click("button:has-text('查询')")
|
||||
page.wait_for_timeout(500)
|
||||
target = page.locator(".el-table__row", has_text=f"t_e2e客户_{SUFFIX}").first
|
||||
if target.count() == 0:
|
||||
print(" (清理跳过:未找到本次业务)")
|
||||
return
|
||||
target.locator("button:has-text('删除')").click()
|
||||
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
|
||||
confirm_message_box(page, "删除")
|
||||
page.wait_for_timeout(600)
|
||||
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
collect_console_errors(page, console_errors)
|
||||
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||
if not admin_password:
|
||||
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||
|
||||
step_navigate_to_business(page, admin_password)
|
||||
customer_name = step_create_customer_via_ui(page)
|
||||
try:
|
||||
step_create_personnel(page)
|
||||
step_create_business_with_allocation(page, customer_name)
|
||||
step_verify_allocation_echo(page)
|
||||
finally:
|
||||
step_cleanup(page)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
# 弹窗较宽(业务表单含明细/二次分配两表),窄视口会把右侧操作按钮
|
||||
# 挤出可视区、被 overlay 遮挡;给到 1600 宽避免遮挡导致点击超时
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1000})
|
||||
page.set_default_timeout(12000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""公司信息设置端到端验证。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
- 登录 → 系统管理分组出现「公司信息」菜单 → 进入设置页
|
||||
- 修改公司名称并保存 → 全局提示「已保存」
|
||||
- 退出登录 → 登录页品牌位显示**新**公司名(public 端点即时生效)
|
||||
- 还原公司名(finally:防污染 login_flow/system_management 的品牌名断言)
|
||||
|
||||
运行前置(缺一不可):
|
||||
docker compose up -d # MySQL + Redis
|
||||
cd backend && uv run uvicorn app.main:app --port 8000
|
||||
cd frontend && pnpm dev
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/settings_flow.py
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
login,
|
||||
open_menu,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
console_errors: list[str] = []
|
||||
|
||||
|
||||
def step_change_company_name(page: Page, admin_password: str) -> str:
|
||||
"""进设置页改公司名(加随机后缀避免撞库),返回新名供后续断言。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "公司信息", "系统管理")
|
||||
if current_path(page) != "/system/settings":
|
||||
fail("菜单导航", f"未进入公司信息页,当前 {page.url}")
|
||||
|
||||
# 随机名:单行特性下库可复用,避免与既有公司名撞车;断言不依赖「默认」前提
|
||||
new_name = f"衍水e2e{os.getpid()}{os.urandom(2).hex()}"
|
||||
page.fill('input[placeholder="展示在登录页与导出页眉"]', new_name)
|
||||
page.click("button:has-text('保存')")
|
||||
expect_toast(page, "已保存", "修改公司名称")
|
||||
print(f"✓ 1. 设置页导航 进入公司信息并保存新名称「{new_name}」")
|
||||
return new_name
|
||||
|
||||
|
||||
def step_logout(page: Page) -> None:
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10000)
|
||||
print("✓ 2. 退出登录 已回到登录页")
|
||||
|
||||
|
||||
def step_login_page_reflects(page: Page, new_name: str) -> None:
|
||||
"""登录页品牌位应显示新公司名(public 端点即时生效)。
|
||||
|
||||
等 brand 元素文本变为新名(而非固定 sleep):public 请求是异步的,
|
||||
固定等待在慢机上会偶发拿到旧值。
|
||||
"""
|
||||
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
|
||||
try:
|
||||
page.locator(".card-brand__name").filter(has_text=new_name).wait_for(
|
||||
state="visible", timeout=8000
|
||||
)
|
||||
except Exception:
|
||||
brand = page.locator(".card-brand__name").inner_text().strip()
|
||||
fail("登录页品牌", f"品牌位未显示新名称「{new_name}」,当前:{brand}")
|
||||
foot = page.locator(".login-foot").inner_text()
|
||||
if new_name not in foot:
|
||||
fail("登录页页脚", f"页脚未含新名称「{new_name}」,当前:{foot}")
|
||||
print(f"✓ 3. 品牌即时生效 登录页品牌位与页脚已显示「{new_name}」")
|
||||
|
||||
|
||||
def step_restore(page: Page, admin_password: str) -> None:
|
||||
"""还原默认公司名,防污染后续 e2e 的品牌名断言。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "公司信息", "系统管理")
|
||||
page.fill('input[placeholder="展示在登录页与导出页眉"]', "衍水集团")
|
||||
page.click("button:has-text('保存')")
|
||||
expect_toast(page, "已保存", "还原公司名称")
|
||||
print("✓ 4. 数据清理 公司名已还原为「衍水集团」")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
collect_console_errors(page, console_errors)
|
||||
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||
if not admin_password:
|
||||
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||
|
||||
new_name = step_change_company_name(page, admin_password)
|
||||
try:
|
||||
step_logout(page)
|
||||
step_login_page_reflects(page, new_name)
|
||||
finally:
|
||||
step_restore(page, admin_password)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.set_default_timeout(10000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,70 @@
|
||||
import request from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 绩效台账行(金额/比例一律 str) */
|
||||
export interface LedgerRow {
|
||||
id: number
|
||||
receipt_id: number | null
|
||||
calc_version: number
|
||||
is_active: number
|
||||
calc_type: number
|
||||
business_id: number
|
||||
business_no: string
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
customer_type: string
|
||||
product_category: string
|
||||
ship_date: string | null
|
||||
receipt_date: string
|
||||
payment_days: number | null
|
||||
rate_text: string
|
||||
converted_profit: string | null
|
||||
performance_amount: string | null
|
||||
barter_applied: number
|
||||
weight_r: number | null
|
||||
payment_weight: string | null
|
||||
payable_amount: string | null
|
||||
settlement_month: string
|
||||
is_probation: number
|
||||
missing_cost: number
|
||||
snapshot_json: Record<string, unknown> | null
|
||||
calc_reason: string | null
|
||||
}
|
||||
|
||||
export interface LedgerQueryParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
settlement_month?: string
|
||||
personnel_id?: number
|
||||
business_id?: number
|
||||
is_active?: number
|
||||
calc_type?: number
|
||||
}
|
||||
|
||||
export function listLedger(params: LedgerQueryParams): Promise<PageData<LedgerRow>> {
|
||||
return request({ url: '/v1/calc/ledger', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getLedger(id: number): Promise<LedgerRow> {
|
||||
return request({ url: `/v1/calc/ledger/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
/** 核算回款:返回台账行 + repeated(业务级一次性计提标记) */
|
||||
export function calcReceipt(receiptId: number): Promise<{ ledger: LedgerRow; repeated: boolean }> {
|
||||
return request({ url: `/v1/calc/receipts/${receiptId}/calc`, method: 'post' })
|
||||
}
|
||||
|
||||
export function recalcReceipt(receiptId: number, reason: string): Promise<LedgerRow> {
|
||||
return request({
|
||||
url: `/v1/calc/receipts/${receiptId}/recalc`,
|
||||
method: 'post',
|
||||
data: { reason },
|
||||
})
|
||||
}
|
||||
|
||||
export function batchCalc(receiptIds: number[]): Promise<
|
||||
{ receipt_id: number; ok: boolean; repeated?: boolean; error?: string }[]
|
||||
> {
|
||||
return request({ url: '/v1/calc/batch', method: 'post', data: { receipt_ids: receiptIds } })
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||
* 请求头 no-cache:要求中间层/浏览器重校验而非直接用缓存副本 —— 公司名
|
||||
* 保存后应立即可见,不能因 300s 短缓存拿到旧值。 */
|
||||
export function getPublicSettings(): Promise<PublicSettings> {
|
||||
return request({
|
||||
url: '/v1/system/settings/public',
|
||||
method: 'get',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import request from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 月结状态:1草稿 2待审批 3已审定(=已锁定)4已驳回 */
|
||||
export const SETTLEMENT_STATUS = {
|
||||
DRAFT: 1,
|
||||
PENDING: 2,
|
||||
APPROVED: 3,
|
||||
REJECTED: 4,
|
||||
} as const
|
||||
|
||||
export function settlementStatusLabel(status: number): string {
|
||||
return (
|
||||
{ 1: '草稿', 2: '待审批', 3: '已审定(锁定)', 4: '已驳回' }[status] ?? String(status)
|
||||
)
|
||||
}
|
||||
|
||||
export function settlementStatusTagType(
|
||||
status: number,
|
||||
): 'primary' | 'success' | 'danger' | 'warning' | 'info' {
|
||||
return (
|
||||
({ 1: 'info', 2: 'primary', 3: 'success', 4: 'danger', 5: 'info' } as const)[status] ?? 'info'
|
||||
)
|
||||
}
|
||||
|
||||
export interface SettlementItem {
|
||||
id: number
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
calc_type: number
|
||||
performance_sum: string
|
||||
payable_sum: string
|
||||
basic_amount: string | null
|
||||
salary_mode: string | null
|
||||
net_amount: string | null
|
||||
is_probation: number
|
||||
probation_note: string | null
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
id: number
|
||||
settlement_month: string
|
||||
status: number
|
||||
total_performance: string | null
|
||||
total_payable: string | null
|
||||
person_count: number
|
||||
approval_order_no: string | null
|
||||
submitter_name: string
|
||||
locked_at: string | null
|
||||
locked_by: string | null
|
||||
create_time: string
|
||||
}
|
||||
|
||||
export interface SettlementDetail extends Settlement {
|
||||
items: SettlementItem[]
|
||||
}
|
||||
|
||||
export interface SettlementQueryParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function listSettlements(params: SettlementQueryParams): Promise<PageData<Settlement>> {
|
||||
return request({ url: '/v1/settlements', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getSettlement(id: number): Promise<SettlementDetail> {
|
||||
return request({ url: `/v1/settlements/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function generateSettlement(month: string): Promise<SettlementDetail> {
|
||||
return request({ url: '/v1/settlements/generate', method: 'post', data: { month } })
|
||||
}
|
||||
|
||||
export function submitSettlement(id: number): Promise<SettlementDetail> {
|
||||
return request({ url: `/v1/settlements/${id}/submit`, method: 'post' })
|
||||
}
|
||||
|
||||
/** 调整单(已锁定月份的补发/扣减) */
|
||||
export interface AdjustmentRow {
|
||||
id: number
|
||||
order_no: string
|
||||
settlement_month: string
|
||||
personnel_name: string
|
||||
direction: number
|
||||
amount: string
|
||||
reason: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export function createAdjustment(data: {
|
||||
settlement_month: string
|
||||
personnel_id: number
|
||||
direction: number
|
||||
amount: string
|
||||
reason: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return request({ url: '/v1/settlements/adjustments', method: 'post', data })
|
||||
}
|
||||
|
||||
export function listAdjustments(): Promise<AdjustmentRow[]> {
|
||||
return request({ url: '/v1/settlements/adjustments', 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 ?? '',
|
||||
|
||||
@@ -12,6 +12,9 @@ declare module 'vue-router' {
|
||||
title?: string
|
||||
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
||||
requiresAdmin?: boolean
|
||||
/** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)——
|
||||
* 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */
|
||||
allowedRoles?: string[]
|
||||
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
|
||||
menu?: boolean
|
||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||
@@ -59,54 +62,79 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
|
||||
},
|
||||
{
|
||||
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
|
||||
// 管理员」);页面内写按钮按 admin 角色显隐
|
||||
path: '/system/params',
|
||||
name: 'system-params',
|
||||
component: () => import('@/views/system/params/index.vue'),
|
||||
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
||||
meta: {
|
||||
title: '计提参数',
|
||||
group: '系统管理',
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
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 +142,21 @@ 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 },
|
||||
},
|
||||
{
|
||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||
path: '/calc/ledger',
|
||||
name: 'calc-ledger',
|
||||
component: () => import('@/views/calc/ledger/index.vue'),
|
||||
meta: { title: '绩效台账', group: '绩效工资', order: 17 },
|
||||
},
|
||||
{
|
||||
path: '/settlements',
|
||||
name: 'settlements',
|
||||
component: () => import('@/views/settlements/index.vue'),
|
||||
meta: { title: '月度结算', group: '绩效工资', order: 18 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -163,6 +205,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
|
||||
})
|
||||
|
||||
|
||||
@@ -131,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 {
|
||||
@@ -172,6 +235,7 @@ function openCreate(): void {
|
||||
remark: '',
|
||||
})
|
||||
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
||||
allocationRows.value = []
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
@@ -203,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()
|
||||
}
|
||||
|
||||
@@ -266,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 {
|
||||
@@ -277,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('已保存')
|
||||
@@ -289,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('业务已创建')
|
||||
@@ -752,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 alloc-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>
|
||||
@@ -1209,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 绩效核算台账(M3 ④):核算事实的固化点。
|
||||
*
|
||||
* - 核算=把回款固化为台账行(每笔回款仅一次;同业务重复回款仅登记进度)
|
||||
* - 重算=作废旧版本、按当前参数落新版本(管理员/财务授权,原因留痕)
|
||||
* - 行展开看计算快照(所用参数版本与行级折算明细)
|
||||
*/
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: '',
|
||||
is_active: 1 as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search } = usePagedList<
|
||||
LedgerRow,
|
||||
{ settlement_month?: string; is_active?: number }
|
||||
>(calcApi.listLedger, () => ({
|
||||
settlement_month: filters.settlement_month.trim() || undefined,
|
||||
is_active: filters.is_active,
|
||||
}))
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.settlement_month = ''
|
||||
filters.is_active = 1
|
||||
search()
|
||||
}
|
||||
|
||||
const recalcVisible = ref(false)
|
||||
const recalcTarget = ref<LedgerRow | null>(null)
|
||||
const recalcReason = ref('')
|
||||
|
||||
function openRecalc(row: LedgerRow): void {
|
||||
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||||
if (row.receipt_id == null) return
|
||||
recalcTarget.value = row
|
||||
recalcReason.value = ''
|
||||
recalcVisible.value = true
|
||||
}
|
||||
|
||||
async function submitRecalc(): Promise<void> {
|
||||
const row = recalcTarget.value
|
||||
if (!row || row.receipt_id == null) return
|
||||
if (!recalcReason.value.trim()) {
|
||||
ElMessage.warning('重算必须填写原因')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
|
||||
await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||||
ElMessage.success('重算完成(新版本已生效)')
|
||||
recalcVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<el-form-item label="归属月">
|
||||
<el-input
|
||||
v-model="filters.settlement_month"
|
||||
placeholder="YYYY-MM"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本">
|
||||
<!-- 可清空:清空=不加 is_active 过滤(axios 自动剔除 undefined),
|
||||
即「全部版本」;0 精确匹配历史行,不能用来表达「含历史」 -->
|
||||
<el-select
|
||||
v-model="filters.is_active"
|
||||
placeholder="全部版本"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="仅当前生效" :value="1" />
|
||||
<el-option label="仅历史版本" :value="0" />
|
||||
</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 type="expand">
|
||||
<template #default="{ row }">
|
||||
<pre class="snapshot">{{ JSON.stringify(row.snapshot_json ?? {}, null, 2) }}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settlement_month" label="归属月" width="90" />
|
||||
<el-table-column prop="business_no" label="业务编号" min-width="150" />
|
||||
<el-table-column prop="personnel_name" label="归属人员" width="100" />
|
||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||||
<el-table-column prop="payment_days" label="天数" width="70" />
|
||||
<el-table-column prop="rate_text" label="计提比例" min-width="90" />
|
||||
<el-table-column prop="converted_profit" label="折算毛利" min-width="100" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" min-width="100" />
|
||||
<el-table-column prop="payable_amount" label="应付绩效" min-width="100" />
|
||||
<el-table-column label="版本" width="80">
|
||||
<template #default="{ row }">
|
||||
v{{ row.calc_version }}
|
||||
<el-tag v-if="row.is_active === 0" size="small" type="info" disable-transitions>
|
||||
已替代
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.is_active === 1 && row.calc_type === 1"
|
||||
link
|
||||
type="primary"
|
||||
@click="openRecalc(row)"
|
||||
>
|
||||
重算
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
v-model:page-num="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="recalcVisible" title="重算确认" width="460px">
|
||||
<p class="recalc-warn">
|
||||
重算会作废当前版本(v{{ recalcTarget?.calc_version }})并按当前参数落新版本,
|
||||
历史版本可追溯。请填写重算原因。
|
||||
</p>
|
||||
<el-input
|
||||
v-model="recalcReason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
placeholder="重算原因(必填,留痕)"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="recalcVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitRecalc">确认重算</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recalc-warn {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,32 @@ 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 {
|
||||
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
|
||||
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
|
||||
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
|
||||
const resp = await fetch(
|
||||
`/api/v1/system/settings/public?_=${Date.now()}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
if (!resp.ok) throw new Error(String(resp.status))
|
||||
const body = (await resp.json()) as {
|
||||
code: number
|
||||
data: { company_name?: string; logo_url?: string | null }
|
||||
}
|
||||
const data = body.data
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url ?? null
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@@ -72,7 +98,10 @@ async function handleSubmit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshCaptcha)
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
loadBrand()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,7 +114,8 @@ onMounted(refreshCaptcha)
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="card-brand">
|
||||
<div class="card-brand__name">衍水集团</div>
|
||||
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
|
||||
<div class="card-brand__name">{{ brandName }}</div>
|
||||
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@@ -151,7 +181,7 @@ onMounted(refreshCaptcha)
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<p class="login-foot">© 衍水集团 · 内部系统</p>
|
||||
<p class="login-foot">© {{ brandName }} · 内部系统</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -252,6 +282,14 @@ onMounted(refreshCaptcha)
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-brand__logo {
|
||||
display: block;
|
||||
max-width: 56px;
|
||||
max-height: 36px;
|
||||
margin: 0 auto 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.card-brand__name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 17px;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
generateSettlement,
|
||||
getSettlement,
|
||||
listSettlements,
|
||||
settlementStatusLabel,
|
||||
settlementStatusTagType,
|
||||
submitSettlement,
|
||||
type Settlement,
|
||||
type SettlementDetail,
|
||||
} from '@/api/settlements'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 月度结算(M3 ④):按确认回款生成快照 → 提交审批 → 通过即锁定。
|
||||
*
|
||||
* 锁定后源数据改动被拦截(回款/发货写路径 400),调整走调整单。
|
||||
* 详情抽屉展示按人明细(三口径净工资与试用期说明)。
|
||||
*/
|
||||
const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search } = usePagedList<
|
||||
Settlement,
|
||||
{ status?: number }
|
||||
>(listSettlements, () => ({ status: filters.status }))
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.status = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
const generateMonth = ref('')
|
||||
const generating = ref(false)
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!/^\d{4}-\d{2}$/.test(generateMonth.value)) {
|
||||
ElMessage.warning('请输入结算月份(YYYY-MM)')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
await generateSettlement(generateMonth.value)
|
||||
ElMessage.success('月结已生成(草稿)')
|
||||
await search()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<SettlementDetail | null>(null)
|
||||
|
||||
async function openDetail(row: Settlement): Promise<void> {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await getSettlement(row.id)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
async function handleSubmit(row: Settlement): Promise<void> {
|
||||
try {
|
||||
const out = await submitSettlement(row.id)
|
||||
ElMessage.success(
|
||||
out.status === 3 ? '审批通过,月结已锁定' : '已提交审批(待财务复核)',
|
||||
)
|
||||
detail.value = await getSettlement(row.id)
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<div class="gen-row">
|
||||
<el-input
|
||||
v-model="generateMonth"
|
||||
placeholder="结算月份 YYYY-MM"
|
||||
style="width: 160px"
|
||||
maxlength="7"
|
||||
/>
|
||||
<el-button type="primary" :loading="generating" @click="handleGenerate">
|
||||
生成月结快照
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<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-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="settlement_month" label="结算月份" width="100" />
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="settlementStatusTagType(row.status)" size="small" disable-transitions>
|
||||
{{ settlementStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_performance" label="绩效合计" min-width="110" />
|
||||
<el-table-column prop="total_payable" label="应付合计" min-width="110" />
|
||||
<el-table-column prop="person_count" label="人数" width="70" />
|
||||
<el-table-column prop="approval_order_no" label="审批单号" min-width="150" />
|
||||
<el-table-column label="提交时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || row.status === 4"
|
||||
link
|
||||
type="success"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit(row)"
|
||||
>
|
||||
提交审批
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
v-model:page-num="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="月结详情" size="560px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-month">{{ detail.settlement_month }}</span>
|
||||
<el-tag :type="settlementStatusTagType(detail.status)" size="small" disable-transitions>
|
||||
{{ settlementStatusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>审批单号:{{ detail.approval_order_no || '—' }}</span>
|
||||
<span>提交人:{{ detail.submitter_name }}</span>
|
||||
<span v-if="detail.locked_by">锁定人:{{ detail.locked_by }}</span>
|
||||
</div>
|
||||
<el-table :data="detail.items" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="performance_sum" label="绩效" min-width="90" />
|
||||
<el-table-column prop="payable_sum" label="应付" min-width="90" />
|
||||
<el-table-column prop="net_amount" label="净工资" min-width="90" />
|
||||
<el-table-column label="试用期" width="70">
|
||||
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="detail.status === 1 || detail.status === 4" class="hint">
|
||||
草稿/已驳回状态可重新生成快照后再次提交。
|
||||
</p>
|
||||
<p v-else-if="detail.status === 3" class="hint">已锁定:源数据改动只产生提示与调整单。</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gen-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 0;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<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 ok = await confirmAndRun({
|
||||
confirmText:
|
||||
actionForm.action === 1
|
||||
? `确认通过审批单 ${actingId.value}?通过后进入下一节点。`
|
||||
: `确认驳回审批单 ${actingId.value}?驳回后该单终止。`,
|
||||
run: () =>
|
||||
actApproval(actingId.value as number, {
|
||||
action: actionForm.action,
|
||||
opinion: actionForm.opinion.trim() || null,
|
||||
}),
|
||||
successText: actionForm.action === 1 ? '已通过' : '已驳回',
|
||||
})
|
||||
if (!ok) return
|
||||
// 动作后刷新抽屉与列表(状态可能已流转或终态)
|
||||
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>
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ROLE_ADMIN } from '@/utils/constants'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -24,9 +26,13 @@ import { handledError } from '@/utils/error'
|
||||
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
|
||||
* 以批次提交(一次一批,逐项留痕旧值/新值)
|
||||
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
|
||||
* - 权限分层(澄清 #17 答复 2026-10-19):查看对财务/领导放开(只读),
|
||||
* 修改仅管理员——写按钮按角色显隐,越权仍有后端 403 兜底
|
||||
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||||
|
||||
// ==================== 目录 ====================
|
||||
|
||||
@@ -37,6 +43,10 @@ async function loadCatalog(): Promise<void> {
|
||||
catalogLoading.value = true
|
||||
try {
|
||||
catalog.value = await paramsApi.getCatalog()
|
||||
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
||||
for (const item of catalog.value) {
|
||||
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||||
}
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
@@ -53,17 +63,42 @@ const groupedCatalog = computed(() => {
|
||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||
})
|
||||
|
||||
/** 维度值 → 展示名(NONE 维度为空串) */
|
||||
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
'': '默认',
|
||||
municipal_water: '市政水利',
|
||||
rail_transit: '轨道交通',
|
||||
salesperson: '营销员',
|
||||
sales_manager: '营销经理',
|
||||
info_officer: '信息员',
|
||||
}
|
||||
|
||||
function dimensionLabel(dimension: string): string {
|
||||
return DIMENSION_LABELS[dimension] ?? dimension
|
||||
}
|
||||
|
||||
/** 参数名 → 中文(预览「所用参数」列表用;编辑/历史/对比抽屉另有 param_name 直出) */
|
||||
const PARAM_NAME_BY_CODE: Record<string, string> = {}
|
||||
function paramNameByCode(code: string): string {
|
||||
return PARAM_NAME_BY_CODE[code] ?? code
|
||||
}
|
||||
|
||||
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */
|
||||
const ENUM_VALUE_LABELS: Record<string, string> = {
|
||||
FULL: '当月全额',
|
||||
BALANCE: '余额',
|
||||
INCLUDE: '含基本工资',
|
||||
DEDUCT: '扣除基本工资',
|
||||
EXCLUDE: '不计基本工资',
|
||||
'0': '关',
|
||||
'1': '开',
|
||||
}
|
||||
|
||||
function valueLabel(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
return ENUM_VALUE_LABELS[value] ?? value
|
||||
}
|
||||
|
||||
/** 带单位的值展示 */
|
||||
function formatValue(item: ParamCatalogItem, value: string): string {
|
||||
return item.unit ? `${value} ${item.unit}` : value
|
||||
@@ -443,6 +478,7 @@ onMounted(() => {
|
||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -458,7 +494,7 @@ onMounted(() => {
|
||||
</td>
|
||||
<td>
|
||||
<el-button
|
||||
v-if="!item.values.length"
|
||||
v-if="isAdmin && !item.values.length"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -475,8 +511,8 @@ onMounted(() => {
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
|
||||
<!-- 待提交批次 -->
|
||||
<el-card v-if="pendingList.length" shadow="never" class="pending-card">
|
||||
<!-- 待提交批次(仅管理员可见可操作) -->
|
||||
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
|
||||
<template #header>
|
||||
<div class="pending-head">
|
||||
<b>待提交变更({{ pendingList.length }} 项)</b>
|
||||
@@ -641,7 +677,7 @@ onMounted(() => {
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.effective_date > localToday()"
|
||||
v-if="isAdmin && row.effective_date > localToday()"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@@ -763,7 +799,7 @@ onMounted(() => {
|
||||
<div class="pr-used">
|
||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
||||
{{ u.param_code }} = {{ u.value_text }}
|
||||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
|
||||
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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)
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# 衍水工资系统 —— MySQL 全量备份(⑨ 备份恢复与运维检查)
|
||||
#
|
||||
# 思路:在跑中的容器内执行 mysqldump,免暴露 3306 端口给宿主机工具,
|
||||
# 也避免宿主机装 MySQL 客户端。凭据从 docker/.env 读取(不入脚本、不入版本库)。
|
||||
#
|
||||
# 用法:在项目根目录执行 bash scripts/backup_full.sh
|
||||
# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,保留最近 30 天。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -f docker/.env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source docker/.env
|
||||
else
|
||||
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
||||
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
|
||||
|
||||
BACKUP_DIR="$ROOT/db/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
OUT="$BACKUP_DIR/payroll_${STAMP}.sql.gz"
|
||||
|
||||
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
|
||||
CONTAINER="ys-mysql-dev"
|
||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "开始备份 $MYSQL_DATABASE → $(basename "$OUT")"
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
"mysqldump -uroot -p\"\$MYSQL_ROOT_PASSWORD\" --single-transaction --routines --triggers \"$MYSQL_DATABASE\"" \
|
||||
| gzip > "$OUT"
|
||||
|
||||
if [[ ! -s "$OUT" ]]; then
|
||||
echo "ERROR: 备份文件为空(数据库名或凭据可能不对)" >&2
|
||||
rm -f "$OUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE="$(du -h "$OUT" | cut -f1)"
|
||||
echo "备份完成:$(basename "$OUT") ($SIZE)"
|
||||
|
||||
# 记录 binlog 位点:增量备份(backup_incremental.sh)与恢复重放的对齐锚点——
|
||||
# 从「此位点之后」的 binlog 开始重放即为完整增量链
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW MASTER STATUS"' > "${OUT}.pos" 2>/dev/null
|
||||
if [[ -s "${OUT}.pos" ]]; then
|
||||
echo "binlog 位点:$(awk '{print $1, "pos="$2}' "${OUT}.pos") → $(basename "$OUT").pos"
|
||||
fi
|
||||
|
||||
# 保留 30 天
|
||||
find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.sql.gz' -mtime +30 -print -delete
|
||||
find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.pos' -mtime +30 -print -delete
|
||||
echo "已清理 30 天前的旧备份"
|
||||
|
||||
echo ""
|
||||
echo "最新备份:"
|
||||
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -3
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# 衍水工资系统 —— MySQL 增量备份(binlog 归档)
|
||||
#
|
||||
# 口径(澄清 #19 业务答复 2026-10-19):每日全量 + 有更新时增量补充备份。
|
||||
# 增量 = 归档 MySQL binlog:自上次归档之后产生的新 binlog 文件逐个拷出压缩;
|
||||
# 没有新变更时不产出文件(「有更新时」才补充)。
|
||||
#
|
||||
# 恢复顺序:最新全量(backup_full.sh 产物)→ 依次重放其后的 binlog 增量包
|
||||
# (用 mysqlbinlog 重放,见 docs/备份恢复手册.md 增量章节)。
|
||||
#
|
||||
# 实现说明:mysql:8.0 官方镜像不含 mysqlbinlog 客户端,故不走
|
||||
# --read-from-remote-server,直接从数据目录 tar 拷出原始 binlog 文件;
|
||||
# 只归档「已写完」的文件(SHOW BINARY LOGS 列表最后一个为当前写入中,
|
||||
# 留到下次收取,配合 30 天 binlog 过期窗口余量充足)。
|
||||
#
|
||||
# 用法:在项目根目录执行 bash scripts/backup_incremental.sh
|
||||
# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),保留 30 天。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -f docker/.env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source docker/.env
|
||||
else
|
||||
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
||||
|
||||
BACKUP_DIR="$ROOT/db/backups"
|
||||
STATE_FILE="$BACKUP_DIR/.incremental_state"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
|
||||
CONTAINER="ys-mysql-dev"
|
||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 列出 binlog:文件名 序号 大小(mysql -N 无表头,直接出数据行)
|
||||
mapfile -t BINLOGS < <(
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW BINARY LOGS"' 2>/dev/null \
|
||||
| awk '{print $1}'
|
||||
)
|
||||
if [[ ${#BINLOGS[@]} -eq 0 ]]; then
|
||||
echo "ERROR: SHOW BINARY LOGS 无输出(binlog 未开启或凭据不对)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${#BINLOGS[@]} -eq 1 ]]; then
|
||||
echo "仅有写入中的 binlog(${BINLOGS[0]}),无已写完文件可归档"
|
||||
exit 0
|
||||
fi
|
||||
COMPLETED=("${BINLOGS[@]:0:${#BINLOGS[@]}-1}") # 去掉写入中的最后一个
|
||||
|
||||
LAST="$(cat "$STATE_FILE" 2>/dev/null || echo "")"
|
||||
NEW=()
|
||||
for f in "${COMPLETED[@]}"; do
|
||||
# 与状态文件比较序号:无状态(首次)全收;有状态只收更大的
|
||||
if [[ -z "$LAST" ]] || (( ${f##*.} > ${LAST##*.} )); then
|
||||
NEW+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#NEW[@]} -eq 0 ]]; then
|
||||
echo "无新增变更(上次归档至 ${LAST},无需补充增量)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
echo "归档 ${#NEW[@]} 个 binlog(${NEW[0]} ~ ${NEW[-1]})"
|
||||
for f in "${NEW[@]}"; do
|
||||
OUT="$BACKUP_DIR/${f}.gz"
|
||||
docker exec "$CONTAINER" sh -c "tar cf - -C /var/lib/mysql '$f'" | gzip > "$OUT"
|
||||
if [[ ! -s "$OUT" ]]; then
|
||||
echo "ERROR: $OUT 为空" >&2
|
||||
rm -f "$OUT"
|
||||
exit 1
|
||||
fi
|
||||
echo " $(basename "$OUT") ($(du -h "$OUT" | cut -f1))"
|
||||
done
|
||||
echo "$STAMP ${NEW[-1]}" > "$STATE_FILE"
|
||||
|
||||
# 保留 30 天(与全量一致;binlog 服务端默认 30 天过期,先于服务端消失无妨,
|
||||
# 全量 + 其后增量链完整即满足恢复目标)
|
||||
find "$BACKUP_DIR" -maxdepth 1 -name 'binlog.*.gz' -mtime +30 -print -delete
|
||||
echo "增量归档完成,最新至 ${NEW[-1]}"
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# 运维健康检查 —— 容器/磁盘/备份新鲜度/登录异常(⑨ 输出一份可读报告)
|
||||
#
|
||||
# 用法:在项目根目录执行 bash scripts/health_check.sh
|
||||
# 每项 PASS/WARN/FAIL,最后给出结论。WARN/FAIL 时退出码非零提示运维介入。
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -f docker/.env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source docker/.env
|
||||
fi
|
||||
|
||||
ISSUES=0
|
||||
report() { printf "%-10s %s\n" "$1" "$2"; }
|
||||
warn() { report "WARN" "$1"; ISSUES=$((ISSUES+1)); }
|
||||
fail() { report "FAIL" "$1"; ISSUES=$((ISSUES+1)); }
|
||||
pass() { report "PASS" "$1"; }
|
||||
|
||||
echo "======== 衍水工资系统 健康检查 $(date '+%Y-%m-%d %H:%M:%S') ========"
|
||||
|
||||
# 1. 容器健康
|
||||
for C in ys-mysql-dev ys-redis-dev; do
|
||||
if ! docker inspect "$C" >/dev/null 2>&1; then
|
||||
fail "容器 $C 不存在"
|
||||
continue
|
||||
fi
|
||||
STATUS="$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null || echo none)"
|
||||
RUNNING="$(docker inspect -f '{{.State.Running}}' "$C" 2>/dev/null)"
|
||||
if [[ "$RUNNING" == "true" && "$STATUS" == "healthy" ]]; then
|
||||
pass "容器 $C 运行中且健康"
|
||||
elif [[ "$RUNNING" == "true" ]]; then
|
||||
warn "容器 $C 运行中但健康检查=$STATUS"
|
||||
else
|
||||
fail "容器 $C 未运行"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. 磁盘水位
|
||||
DISK_PCT="$(df -h "$ROOT" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"
|
||||
if [[ -z "$DISK_PCT" ]]; then
|
||||
warn "磁盘信息读取失败"
|
||||
elif (( DISK_PCT < 80 )); then
|
||||
pass "磁盘使用率 ${DISK_PCT}%(阈值 80%)"
|
||||
elif (( DISK_PCT < 90 )); then
|
||||
warn "磁盘使用率 ${DISK_PCT}%(建议清理)"
|
||||
else
|
||||
fail "磁盘使用率 ${DISK_PCT}%(高危,立即清理)"
|
||||
fi
|
||||
|
||||
# 3. 备份新鲜度
|
||||
BACKUP_DIR="$ROOT/db/backups"
|
||||
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
warn "未找到任何备份(应每日备份)"
|
||||
else
|
||||
AGE_H=$(( ($(date +%s) - $(stat -c %Y "$LATEST")) / 3600 ))
|
||||
if (( AGE_H < 24 )); then
|
||||
pass "最新备份 $(basename "$LATEST"),${AGE_H} 小时前"
|
||||
elif (( AGE_H < 48 )); then
|
||||
warn "最新备份已 ${AGE_H} 小时前(超过 24 小时)"
|
||||
else
|
||||
fail "最新备份已 ${AGE_H} 小时前(备份任务可能已停)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
|
||||
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
|
||||
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
|
||||
if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then
|
||||
pass "Redis 响应 PONG"
|
||||
else
|
||||
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"
|
||||
fi
|
||||
else
|
||||
warn "docker/.env 缺 REDIS_PASSWORD,跳过 Redis 探活"
|
||||
fi
|
||||
|
||||
# 5. 登录日志异常(24h 失败次数)
|
||||
if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] && docker inspect ys-mysql-dev >/dev/null 2>&1; then
|
||||
FAILS=$(docker exec ys-mysql-dev sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" ${MYSQL_DATABASE:-payroll} -N -B -e \"
|
||||
SELECT COUNT(*) FROM login_logs
|
||||
WHERE success = 0 AND create_time > NOW() - INTERVAL 24 HOUR;\" 2>/dev/null" || echo "?")
|
||||
if [[ "$FAILS" == "?" ]]; then
|
||||
warn "登录日志查询失败"
|
||||
elif (( FAILS < 10 )); then
|
||||
pass "24h 登录失败 ${FAILS} 次(阈值 10)"
|
||||
else
|
||||
warn "24h 登录失败 ${FAILS} 次(可能有暴力破解,建议核查)"
|
||||
fi
|
||||
else
|
||||
warn "跳过登录日志检查(无凭据或容器未运行)"
|
||||
fi
|
||||
|
||||
echo "=============================================================="
|
||||
if (( ISSUES == 0 )); then
|
||||
echo "结论:全部通过,系统健康。"
|
||||
exit 0
|
||||
else
|
||||
echo "结论:发现 $ISSUES 项需关注(见上)。"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# 恢复演练 —— 把最近一份备份恢复到「临时库」并核对行数(⑨:备份不演练 = 没有备份)
|
||||
#
|
||||
# 演练绝不直接覆盖生产库;临时库名 payroll_drill_<时间戳>,核对完由
|
||||
# 调用方决定是否删除(本脚本默认不 DROP,安全优先)。
|
||||
#
|
||||
# 用法:在项目根目录执行 bash scripts/restore_drill.sh
|
||||
# 产物:临时库 + 终端行数对比报告;--drop-exec 参数在核对通过后删除临时库。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -f docker/.env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source docker/.env
|
||||
else
|
||||
echo "ERROR: docker/.env 不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
|
||||
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
|
||||
|
||||
CONTAINER="ys-mysql-dev"
|
||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: 容器 $CONTAINER 未运行" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_DIR="$ROOT/db/backups"
|
||||
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
|
||||
if [[ -z "$LATEST" ]]; then
|
||||
echo "ERROR: 未找到备份文件(先 bash scripts/backup_full.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
# 0. --drop-exec:演练前先清掉**全部**历史遗留 drill 库(含上一轮「保留供
|
||||
# 核对」的那份——否则每演练一轮净增一份整库拷贝,磁盘被无人认领的
|
||||
# drill 库吃满),演练完成后本轮临时库也随之删除
|
||||
if [[ "${1:-}" == "--drop-exec" ]]; then
|
||||
OLD_DBS=$(docker exec "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SHOW DATABASES LIKE 'payroll_drill_%';\"" 2>/dev/null || true)
|
||||
for OLD in $OLD_DBS; do
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$OLD\""
|
||||
echo "已清理历史演练库 $OLD"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB"
|
||||
|
||||
# 1. 创建临时库(mysqladmin create 无反引号转义问题)
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" create \"$DRILL_DB\""
|
||||
|
||||
# 2. 解压导入
|
||||
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
|
||||
|
||||
# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时
|
||||
# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值)
|
||||
TABLES=(
|
||||
adjustment_orders approval_orders approval_steps business_items business_roles
|
||||
business_secondary_allocations business_status_logs businesses customer_rating_grades
|
||||
customers login_logs monthly_settlements operation_logs ownership_change_logs
|
||||
param_change_batches param_definitions param_values performance_ledgers permissions
|
||||
personnel personnel_plans personnel_regions products receipts role_permissions roles
|
||||
settlement_items shipment_items shipments system_settings user_roles users
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "======== 行数对比(源库 vs 演练库)========"
|
||||
printf "%-24s %12s %12s %6s\n" "表名" "源库" "演练库" "结果"
|
||||
ALL_OK=1
|
||||
for T in "${TABLES[@]}"; do
|
||||
SRC=$(docker exec "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?")
|
||||
DST=$(docker exec "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?")
|
||||
if [[ "$SRC" == "?" || "$DST" == "?" ]]; then
|
||||
# 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:?
|
||||
# 与 ? 相等会误报可恢复——备份真的漏表时演练必须红
|
||||
MARK="✗"
|
||||
ALL_OK=0
|
||||
elif [[ "$SRC" == "$DST" ]]; then
|
||||
MARK="✓"
|
||||
else
|
||||
MARK="✗"
|
||||
ALL_OK=0
|
||||
fi
|
||||
printf "%-24s %12s %12s %6s\n" "$T" "$SRC" "$DST" "$MARK"
|
||||
done
|
||||
echo "=========================================="
|
||||
|
||||
if [[ "$ALL_OK" == "1" ]]; then
|
||||
echo ""
|
||||
echo "结果:全部表行数一致,备份可恢复。"
|
||||
if [[ "${1:-}" == "--drop-exec" ]]; then
|
||||
docker exec "$CONTAINER" sh -c \
|
||||
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\""
|
||||
echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。"
|
||||
else
|
||||
echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec,"
|
||||
echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。"
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "结果:存在不一致,请检查备份文件与数据库结构。" >&2
|
||||
echo "临时库 $DRILL_DB 保留供排查。" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in new issue
Block a user