Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
backup/
|
||||||
.claude
|
.claude
|
||||||
CLAUDE.md
|
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')")
|
||||||
@@ -113,3 +113,22 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
|||||||
|
|
||||||
|
|
||||||
AdminUser = Annotated[User, Depends(require_admin)]
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*role_codes: str):
|
||||||
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||||
|
|
||||||
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||||
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||||
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||||
|
|
||||||
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
codes = set(user_repository.get_role_codes(current_user))
|
||||||
|
if not codes & set(role_codes):
|
||||||
|
raise ForbiddenError("无权访问审批管理")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return _checker
|
||||||
@@ -15,10 +15,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 ratings as business_ratings
|
||||||
from app.api.v1.business import receipts as business_receipts
|
from app.api.v1.business import receipts as business_receipts
|
||||||
from app.api.v1.business import shipments as business_shipments
|
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 business_roles as system_business_roles
|
||||||
from app.api.v1.system import logs as system_logs
|
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 params as system_params
|
||||||
from app.api.v1.system import roles as system_roles
|
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
|
from app.api.v1.system import users as system_users
|
||||||
|
|
||||||
api_router = APIRouter()
|
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_roles.router)
|
||||||
system_router.include_router(system_business_roles.router)
|
system_router.include_router(system_business_roles.router)
|
||||||
system_router.include_router(system_params.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.operation_router)
|
||||||
system_router.include_router(system_logs.login_router)
|
system_router.include_router(system_logs.login_router)
|
||||||
v1_router.include_router(system_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)
|
business_router.include_router(business_ratings.router)
|
||||||
v1_router.include_router(business_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)
|
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="已提交",
|
||||||
|
)
|
||||||
@@ -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 ""
|
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
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
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ TOKEN_TYPE_BEARER = "Bearer"
|
|||||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
ROLE_USER = "user"
|
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」,
|
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||||
@@ -70,6 +83,10 @@ MODULE_BUSINESS = "业务管理"
|
|||||||
MODULE_SHIPMENT = "发货管理"
|
MODULE_SHIPMENT = "发货管理"
|
||||||
MODULE_RECEIPT = "回款管理"
|
MODULE_RECEIPT = "回款管理"
|
||||||
MODULE_RATING = "客户评级"
|
MODULE_RATING = "客户评级"
|
||||||
|
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||||
|
MODULE_APPROVAL = "审批管理"
|
||||||
|
MODULE_CALC = "绩效核算"
|
||||||
|
MODULE_SETTLEMENT = "月度结算"
|
||||||
|
|
||||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||||
@@ -77,6 +94,8 @@ NO_PREFIX_BUSINESS = "YW"
|
|||||||
NO_PREFIX_SHIPMENT = "SH"
|
NO_PREFIX_SHIPMENT = "SH"
|
||||||
NO_PREFIX_RECEIPT = "HK"
|
NO_PREFIX_RECEIPT = "HK"
|
||||||
NO_PREFIX_PARAM = "P"
|
NO_PREFIX_PARAM = "P"
|
||||||
|
NO_PREFIX_APPROVAL = "AP"
|
||||||
|
NO_PREFIX_ADJUSTMENT = "TZ"
|
||||||
|
|
||||||
ACTION_CREATE = "新增"
|
ACTION_CREATE = "新增"
|
||||||
ACTION_UPDATE = "修改"
|
ACTION_UPDATE = "修改"
|
||||||
@@ -84,3 +103,5 @@ ACTION_DELETE = "删除"
|
|||||||
ACTION_RESET_PASSWORD = "重置密码"
|
ACTION_RESET_PASSWORD = "重置密码"
|
||||||
ACTION_ENABLE = "启用"
|
ACTION_ENABLE = "启用"
|
||||||
ACTION_DISABLE = "禁用"
|
ACTION_DISABLE = "禁用"
|
||||||
|
ACTION_APPROVE = "审批通过"
|
||||||
|
ACTION_REJECT = "审批驳回"
|
||||||
@@ -9,22 +9,29 @@
|
|||||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.models.approval import ApprovalOrder, ApprovalStep
|
||||||
from app.models.base import Base, TimestampMixin
|
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.business_role import BusinessRole
|
||||||
from app.models.customer import Customer, OwnershipChangeLog
|
from app.models.customer import Customer, OwnershipChangeLog
|
||||||
from app.models.login_log import LoginLog
|
from app.models.login_log import LoginLog
|
||||||
from app.models.operation_log import OperationLog
|
from app.models.operation_log import OperationLog
|
||||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
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.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
from app.models.rating import CustomerRatingGrade
|
from app.models.rating import CustomerRatingGrade
|
||||||
from app.models.receipt import Receipt
|
from app.models.receipt import Receipt
|
||||||
from app.models.role import Permission, Role, RolePermission
|
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.shipment import Shipment, ShipmentItem
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AdjustmentOrder",
|
||||||
|
"ApprovalOrder",
|
||||||
|
"ApprovalStep",
|
||||||
"Base",
|
"Base",
|
||||||
"Business",
|
"Business",
|
||||||
"BusinessItem",
|
"BusinessItem",
|
||||||
@@ -33,11 +40,13 @@ __all__ = [
|
|||||||
"Customer",
|
"Customer",
|
||||||
"CustomerRatingGrade",
|
"CustomerRatingGrade",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
|
"MonthlySettlement",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
"OwnershipChangeLog",
|
"OwnershipChangeLog",
|
||||||
"ParamChangeBatch",
|
"ParamChangeBatch",
|
||||||
"ParamDefinition",
|
"ParamDefinition",
|
||||||
"ParamValue",
|
"ParamValue",
|
||||||
|
"PerformanceLedger",
|
||||||
"Permission",
|
"Permission",
|
||||||
"Personnel",
|
"Personnel",
|
||||||
"PersonnelPlan",
|
"PersonnelPlan",
|
||||||
@@ -46,8 +55,11 @@ __all__ = [
|
|||||||
"Receipt",
|
"Receipt",
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
|
"SecondaryAllocation",
|
||||||
|
"SettlementItem",
|
||||||
"Shipment",
|
"Shipment",
|
||||||
"ShipmentItem",
|
"ShipmentItem",
|
||||||
|
"SystemSetting",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"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 datetime import date, datetime
|
||||||
from decimal import Decimal
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import (
|
from app.models.base import (
|
||||||
@@ -33,6 +33,63 @@ BUSINESS_STATUS_DONE = 2
|
|||||||
BUSINESS_STATUS_TERMINATED = 3
|
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):
|
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||||
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""绩效核算台账模型(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) 兜底。
|
||||||
|
|
||||||
|
经理聚合行(calc_type=2,receipt_id 为 NULL):月度批处理时按「本团队
|
||||||
|
业务员绩效 ×12.5% + 全公司 ×5%」落一行;其唯一性由月结生成的
|
||||||
|
settlement_month 串行化 + 服务层保证(MySQL 无部分唯一索引,docstring
|
||||||
|
声明即可)。
|
||||||
|
|
||||||
|
#12 策略点(待业务答复):经理个人户业绩暂不计入聚合基数——聚合集中
|
||||||
|
在 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", name="uk_performance_ledgers_receipt_id_calc_version"
|
||||||
|
),
|
||||||
|
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,
|
OwnershipChangeLog,
|
||||||
Personnel,
|
Personnel,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
SecondaryAllocation,
|
||||||
Shipment,
|
Shipment,
|
||||||
)
|
)
|
||||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
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()
|
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:
|
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||||
"""追加状态留痕(不 commit)。"""
|
"""追加状态留痕(不 commit)。"""
|
||||||
session.add(log)
|
session.add(log)
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ def list_by_ref(
|
|||||||
return list(session.execute(stmt).all())
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def count_by_ref(
|
def count_by_ref(session: Session, *, owner_type: int, ref_column, ref_id: int) -> int:
|
||||||
session: Session, *, owner_type: int, ref_column, ref_id: int
|
|
||||||
) -> int:
|
|
||||||
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
"""统计某引用对象的归属留痕总数,供分页使用。"""
|
||||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||||
OwnershipChangeLog.owner_type == owner_type,
|
OwnershipChangeLog.owner_type == owner_type,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""绩效核算台账仓储:只读写数据并 flush;commit/rollback 在服务层。"""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
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)。"""
|
||||||
|
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 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 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,94 @@
|
|||||||
|
"""月度结算仓储:只读写数据并 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()
|
||||||
@@ -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
|
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):
|
class BusinessDetailOut(BusinessOut):
|
||||||
"""业务详情:附产品明细行。"""
|
"""业务详情:附产品明细行与二次分配方案。"""
|
||||||
|
|
||||||
items: list[BusinessItemOut] = Field(default_factory=list)
|
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):
|
class BusinessCreateIn(BaseModel):
|
||||||
@@ -175,6 +218,9 @@ class BusinessCreateIn(BaseModel):
|
|||||||
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||||
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||||
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
|
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)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -184,9 +230,14 @@ class BusinessCreateIn(BaseModel):
|
|||||||
raise ValueError("特殊业务必须填写说明")
|
raise ValueError("特殊业务必须填写说明")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_allocations(self) -> "BusinessCreateIn":
|
||||||
|
_ensure_allocations_unique(self.secondary_allocations)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BusinessUpdateIn(BaseModel):
|
class BusinessUpdateIn(BaseModel):
|
||||||
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
|
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||||
|
|
||||||
model_config = ConfigDict(str_strip_whitespace=True)
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
@@ -207,6 +258,9 @@ class BusinessUpdateIn(BaseModel):
|
|||||||
items: list[BusinessItemIn] = Field(
|
items: list[BusinessItemIn] = Field(
|
||||||
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
||||||
)
|
)
|
||||||
|
secondary_allocations: list[SecondaryAllocationIn] = Field(
|
||||||
|
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
|
||||||
|
)
|
||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -215,6 +269,11 @@ class BusinessUpdateIn(BaseModel):
|
|||||||
raise ValueError("特殊业务必须填写说明")
|
raise ValueError("特殊业务必须填写说明")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_allocations(self) -> "BusinessUpdateIn":
|
||||||
|
_ensure_allocations_unique(self.secondary_allocations)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BusinessStatusIn(BaseModel):
|
class BusinessStatusIn(BaseModel):
|
||||||
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
"""状态流转: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,90 @@
|
|||||||
|
"""月度结算的请求/响应模型(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扣减")
|
||||||
|
amount: str = Field(description="金额(元,两位小数)")
|
||||||
|
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""审批流业务编排(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 种子同源)
|
||||||
|
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||||
|
1: "approval.secondary_allocation.enabled",
|
||||||
|
2: "approval.monthly_settlement.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
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
settlement_service.mark_locked(session, target, operator=operator)
|
||||||
|
else:
|
||||||
|
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||||
|
if adj is not None:
|
||||||
|
adj.status = ADJ_REJECTED if rejected else 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.exceptions import BadRequestError, NotFoundError
|
||||||
from app.core.response import PageQuery, PageResult
|
from app.core.response import PageQuery, PageResult
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
ApprovalOrder,
|
||||||
Business,
|
Business,
|
||||||
BusinessItem,
|
BusinessItem,
|
||||||
BusinessStatusLog,
|
BusinessStatusLog,
|
||||||
@@ -41,15 +42,17 @@ from app.models import (
|
|||||||
OwnershipChangeLog,
|
OwnershipChangeLog,
|
||||||
Personnel,
|
Personnel,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
SecondaryAllocation,
|
||||||
Shipment,
|
Shipment,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
|
||||||
from app.models.business import (
|
from app.models.business import (
|
||||||
BUSINESS_STATUS_ACTIVE,
|
BUSINESS_STATUS_ACTIVE,
|
||||||
BUSINESS_STATUS_DONE,
|
BUSINESS_STATUS_DONE,
|
||||||
)
|
)
|
||||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
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 (
|
from app.schemas.business import (
|
||||||
BusinessCreateIn,
|
BusinessCreateIn,
|
||||||
BusinessDetailOut,
|
BusinessDetailOut,
|
||||||
@@ -65,8 +68,10 @@ from app.schemas.business import (
|
|||||||
GrossProfitOut,
|
GrossProfitOut,
|
||||||
GrossProfitRowOut,
|
GrossProfitRowOut,
|
||||||
OwnershipChangeLogOut,
|
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.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import converted_profit, profit_rate
|
from app.services.formula import converted_profit, profit_rate
|
||||||
from app.services.no_generator import next_no
|
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:
|
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||||
"""业务详情(附明细行,供编辑抽屉回显)。
|
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
|
||||||
|
|
||||||
:raises NotFoundError: 业务不存在
|
:raises NotFoundError: 业务不存在
|
||||||
"""
|
"""
|
||||||
@@ -268,9 +273,67 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
|||||||
out.items = [
|
out.items = [
|
||||||
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
_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
|
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: 新增参与人员不存在或已离职
|
||||||
|
"""
|
||||||
|
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(
|
def create_business(
|
||||||
session: Session,
|
session: Session,
|
||||||
payload: BusinessCreateIn,
|
payload: BusinessCreateIn,
|
||||||
@@ -335,6 +398,26 @@ def create_business(
|
|||||||
operator_name=operator.username,
|
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)
|
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||||
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||||
@@ -408,6 +491,35 @@ def update_business(
|
|||||||
business_repository.replace_items(
|
business_repository.replace_items(
|
||||||
session, business_id, _build_item_rows(business_id, payload.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)
|
_recalc_totals(session, business)
|
||||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||||
@@ -430,7 +542,7 @@ def delete_business(
|
|||||||
与业务维度的归属留痕(无外键固化表,须手动清)。
|
与业务维度的归属留痕(无外键固化表,须手动清)。
|
||||||
|
|
||||||
:raises NotFoundError: 业务不存在
|
:raises NotFoundError: 业务不存在
|
||||||
:raises BadRequestError: 存在发货或回款记录
|
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
|
||||||
"""
|
"""
|
||||||
business = business_repository.get_by_id(session, business_id, for_update=True)
|
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
@@ -447,6 +559,10 @@ def delete_business(
|
|||||||
raise NotFoundError("业务不存在")
|
raise NotFoundError("业务不存在")
|
||||||
if business_repository.has_docs(session, business_id):
|
if business_repository.has_docs(session, business_id):
|
||||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||||
|
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||||
|
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
|
||||||
|
if _count_pending_approvals(session, business_id):
|
||||||
|
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
|
||||||
|
|
||||||
business_no = business.business_no
|
business_no = business.business_no
|
||||||
_hard_delete(session, business_id)
|
_hard_delete(session, business_id)
|
||||||
@@ -454,11 +570,46 @@ def delete_business(
|
|||||||
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
|
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:
|
def _hard_delete(session: Session, business_id: int) -> None:
|
||||||
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
|
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
|
||||||
|
状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
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(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
|
||||||
session.execute(
|
session.execute(
|
||||||
delete(OwnershipChangeLog).where(
|
delete(OwnershipChangeLog).where(
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""绩效核算服务(M3 ④):回款 → 台账的固化流水线。
|
||||||
|
|
||||||
|
单笔核算(calc_receipt)与计算预览(param_service.preview)**共用同一
|
||||||
|
公式路径与取值防线**:resolve_snapshot 按回款到账日取参(裁决 #11)、按
|
||||||
|
岗位模板分派计提比例(‰ 存储口径 ÷1000 转纯比例)、行级折算(混合业务
|
||||||
|
按行汇总,对照表 #14)。两者的任何口径修改必须同步——预览给业务对数、
|
||||||
|
核算才是算钱的法定固化点。
|
||||||
|
|
||||||
|
策略点(待业务答复,全部集中为单函数,答复后只改函数 + 按回款批量重算):
|
||||||
|
- ``_personal_rate``:#12 经理个人户单笔落台账 rate=0/绩效 0(12.5%+5%
|
||||||
|
聚合口径在月结时由 manager_team_share 计算);#20 在 ``_row_profit``
|
||||||
|
- 核算粒度 #25:业务级一次性计提——首个触发核算的回款计算全额应发,
|
||||||
|
后续回款仅置 settlement_status=1(repeated=True),不重复计提
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import 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,
|
||||||
|
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 里的策略点注记(答复回填时的检索锚点)
|
||||||
|
_NOTE_MANAGER_PENDING = "经理个人户单笔不计提(#12 待答复):聚合口径在月结时由团队聚合计算"
|
||||||
|
|
||||||
|
|
||||||
|
@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 时为重算模式)。
|
||||||
|
|
||||||
|
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
|
||||||
|
是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
|
||||||
|
commit 前固化。
|
||||||
|
|
||||||
|
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
|
||||||
|
仅登记进度(repeated=True)。
|
||||||
|
重算模式:回款必须已核算过;作废旧版本行(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 = 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("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||||
|
|
||||||
|
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("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||||
|
|
||||||
|
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
|
||||||
|
if is_recalc:
|
||||||
|
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
||||||
|
if old is not None:
|
||||||
|
performance_ledger_repository.mark_inactive(session, old)
|
||||||
|
|
||||||
|
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||||
|
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||||
|
repeated = False
|
||||||
|
if not is_recalc:
|
||||||
|
repeated = performance_ledger_repository.has_active_for_business(
|
||||||
|
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||||
|
)
|
||||||
|
if repeated:
|
||||||
|
_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
|
||||||
|
|
||||||
|
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("发货批次缺少发货日期,无法核算")
|
||||||
|
|
||||||
|
person = personnel_service.ensure_active(
|
||||||
|
session, business.owner_personnel_id, label="核算归属"
|
||||||
|
)
|
||||||
|
if person.business_role_id is None:
|
||||||
|
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||||
|
|
||||||
|
receipt_date = receipt.receipt_date
|
||||||
|
snapshot = resolve_snapshot(session, receipt_date)
|
||||||
|
items = business_repository.list_items(session, business.id)
|
||||||
|
if not items:
|
||||||
|
raise BadRequestError("业务无产品明细行,无法核算")
|
||||||
|
|
||||||
|
# 逐行折算(行级 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(row_converted.quantize(Decimal("0.01"))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||||
|
days = payment_days(shipment.ship_date, receipt_date)
|
||||||
|
decision = _personal_rate(session, snapshot, person, business, days)
|
||||||
|
|
||||||
|
if decision.base_override is not None:
|
||||||
|
converted = decision.base_override # 信息员:基数=本人销售额
|
||||||
|
else:
|
||||||
|
converted = 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, total_revenue, start_ratio)
|
||||||
|
weight_r = clamp_weight_r(received_total, total_revenue)
|
||||||
|
weight = payment_weight(weight_r)
|
||||||
|
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||||
|
|
||||||
|
settlement_month = receipt_date.strftime("%Y-%m")
|
||||||
|
snapshot_json: dict[str, Any] = {
|
||||||
|
"params_as_of": str(receipt_date),
|
||||||
|
"line_details": line_details,
|
||||||
|
"start_payment_ok": start_ok,
|
||||||
|
"rate_note": decision.rate_note,
|
||||||
|
"revenue_total": str(total_revenue),
|
||||||
|
"profit_total": str(total_profit),
|
||||||
|
}
|
||||||
|
if decision.manager_pending:
|
||||||
|
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||||
|
|
||||||
|
def q2(value: Decimal | None) -> Decimal | None:
|
||||||
|
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
|
||||||
|
return value.quantize(Decimal("0.01")) if value is not None else None
|
||||||
|
|
||||||
|
row = performance_ledger_repository.add(
|
||||||
|
session,
|
||||||
|
PerformanceLedger(
|
||||||
|
receipt_id=receipt.id,
|
||||||
|
calc_version=performance_ledger_repository.next_version(session, receipt.id),
|
||||||
|
calc_type=CALC_TYPE_RECEIPT,
|
||||||
|
business_id=business.id,
|
||||||
|
business_no=business.business_no,
|
||||||
|
personnel_id=person.id,
|
||||||
|
personnel_name=person.personnel_name,
|
||||||
|
role_code=role_code_of(person),
|
||||||
|
customer_type=_customer_type(session, business),
|
||||||
|
product_category=_category_label(items),
|
||||||
|
ship_date=shipment.ship_date,
|
||||||
|
receipt_date=receipt_date,
|
||||||
|
payment_days=days,
|
||||||
|
rate_text=str(decision.rate),
|
||||||
|
converted_profit=q2(converted),
|
||||||
|
performance_amount=q2(performance),
|
||||||
|
barter_applied=1 if barter_applied else 0,
|
||||||
|
weight_r=weight_r,
|
||||||
|
payment_weight=param_service.to_decimal(weight),
|
||||||
|
payable_amount=q2(payable),
|
||||||
|
settlement_month=settlement_month,
|
||||||
|
is_probation=1 if person.is_probation == 1 else 0,
|
||||||
|
missing_cost=1 if missing_cost else 0,
|
||||||
|
snapshot_json=snapshot_json,
|
||||||
|
calc_reason=recalc_reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||||
|
# 会 rowcount=0 误报并发撞车)
|
||||||
|
if not is_recalc:
|
||||||
|
_mark_settled(session, receipt)
|
||||||
|
session.flush()
|
||||||
|
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
|
||||||
|
session.commit()
|
||||||
|
logger.info("核算完成 | receipt=%s | ledger=%s | payable=%s", receipt_id, row.id, payable)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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":
|
||||||
|
return _RateDecision(
|
||||||
|
Decimal("0"),
|
||||||
|
"经理个人户:12.5%+5% 聚合计提(月结时计算),单笔落 0",
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
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"))
|
||||||
|
boundary = int(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=...)——折算体只有一份;所属月已审定
|
||||||
|
的拦截在月结(T5)接入后补入本函数。
|
||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -95,3 +95,63 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
|
|||||||
if revenue == 0:
|
if revenue == 0:
|
||||||
return False
|
return False
|
||||||
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
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
|
||||||
|
)
|
||||||
@@ -256,6 +256,57 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
|||||||
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)",
|
||||||
sort_order=16,
|
sort_order=16,
|
||||||
),
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="approval.secondary_allocation.enabled",
|
||||||
|
name="二次分配审批开关",
|
||||||
|
group="审批开关",
|
||||||
|
value_type="BOOL",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="0",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description=(
|
||||||
|
"0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);"
|
||||||
|
"1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)"
|
||||||
|
),
|
||||||
|
sort_order=17,
|
||||||
|
),
|
||||||
|
ParamSpec(
|
||||||
|
code="approval.monthly_settlement.enabled",
|
||||||
|
name="月度结算审批开关",
|
||||||
|
group="审批开关",
|
||||||
|
value_type="BOOL",
|
||||||
|
unit=None,
|
||||||
|
dimension_type="NONE",
|
||||||
|
options=None,
|
||||||
|
default_value="0",
|
||||||
|
value_min=None,
|
||||||
|
value_max=None,
|
||||||
|
description=(
|
||||||
|
"预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);"
|
||||||
|
"0=结算生成即生效,1=走财务复核→领导审核审批流"
|
||||||
|
),
|
||||||
|
sort_order=18,
|
||||||
|
),
|
||||||
|
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=走财务复核→领导审核审批流。"
|
||||||
|
"调整单是已锁定月份补发/扣减的唯一通道"
|
||||||
|
),
|
||||||
|
sort_order=19,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -614,11 +614,8 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
|
|
||||||
start_ratio = take("payment.start.ratio")
|
start_ratio = take("payment.start.ratio")
|
||||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||||
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||||
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
weight_r = formula.clamp_weight_r(received, revenue)
|
||||||
weight_r = (
|
|
||||||
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
|
||||||
)
|
|
||||||
weight = formula.payment_weight(weight_r)
|
weight = formula.payment_weight(weight_r)
|
||||||
base_amount = after_barter if barter_applied else performance
|
base_amount = after_barter if barter_applied else performance
|
||||||
payable = to_decimal(Fraction(base_amount) * weight)
|
payable = to_decimal(Fraction(base_amount) * weight)
|
||||||
|
|||||||
@@ -217,7 +217,10 @@ def update_grade(
|
|||||||
raise NotFoundError("评级等级不存在")
|
raise NotFoundError("评级等级不存在")
|
||||||
|
|
||||||
_ensure_no_overlap(
|
_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)
|
existed = rating_repository.get_by_name(session, payload.grade_name)
|
||||||
if existed is not None and existed.id != grade_id:
|
if existed is not None and existed.id != grade_id:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -209,6 +210,12 @@ def create_receipt(
|
|||||||
raise NotFoundError("业务不存在")
|
raise NotFoundError("业务不存在")
|
||||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
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(
|
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||||
session, payload.shipment_id, payload.business_id
|
session, payload.shipment_id, payload.business_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"""角色管理业务编排。
|
"""角色管理业务编排。
|
||||||
|
|
||||||
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
|
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||||
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
|
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||||
待业务方给出权限清单后再启用)。
|
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||||
|
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||||
|
|
||||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||||
|
|
||||||
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
|
- **内置角色禁删禁停**(`BUILTIN_ROLE_CODES`:admin / marketing_ops /
|
||||||
该编码存在,删除会使系统无法自举
|
finance / leader)—— `admin` 被删除会使系统无法自举(cli 建管理员与
|
||||||
|
`require_admin` 判定依赖);财务/领导是审批节点准入锚点,被删会让
|
||||||
|
在途审批流失去可操作人
|
||||||
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
|
||||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||||
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
|
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||||
"""
|
"""
|
||||||
@@ -27,8 +30,8 @@ from app.core.constants import (
|
|||||||
ACTION_DISABLE,
|
ACTION_DISABLE,
|
||||||
ACTION_ENABLE,
|
ACTION_ENABLE,
|
||||||
ACTION_UPDATE,
|
ACTION_UPDATE,
|
||||||
|
BUILTIN_ROLE_CODES,
|
||||||
MODULE_ROLE,
|
MODULE_ROLE,
|
||||||
ROLE_ADMIN,
|
|
||||||
)
|
)
|
||||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
@@ -177,10 +180,10 @@ def set_status(
|
|||||||
|
|
||||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||||
内置管理员角色不可停用。
|
内置角色不可停用。
|
||||||
|
|
||||||
:raises NotFoundError: 角色不存在
|
:raises NotFoundError: 角色不存在
|
||||||
:raises ForbiddenError: 试图停用内置管理员角色
|
:raises ForbiddenError: 试图停用内置角色
|
||||||
"""
|
"""
|
||||||
role = role_repository.get_by_id(session, role_id)
|
role = role_repository.get_by_id(session, role_id)
|
||||||
verb = "启用" if status == 1 else "禁用"
|
verb = "启用" if status == 1 else "禁用"
|
||||||
@@ -198,8 +201,8 @@ def set_status(
|
|||||||
if role is None:
|
if role is None:
|
||||||
raise NotFoundError("角色不存在")
|
raise NotFoundError("角色不存在")
|
||||||
|
|
||||||
if role.role_code == ROLE_ADMIN and status == 0:
|
if role.role_code in BUILTIN_ROLE_CODES and status == 0:
|
||||||
raise ForbiddenError("内置管理员角色不可停用")
|
raise ForbiddenError("内置角色不可停用")
|
||||||
|
|
||||||
role.status = status
|
role.status = status
|
||||||
out = _to_out(role, _user_count(session, role_id))
|
out = _to_out(role, _user_count(session, role_id))
|
||||||
@@ -217,12 +220,12 @@ def delete_role(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""删除角色。
|
"""删除角色。
|
||||||
|
|
||||||
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
|
内置角色不可删除(admin 与三个审批职能角色,见 BUILTIN_ROLE_CODES);仍有关联用户的角色不可删除(提示先解除绑定)。
|
||||||
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
|
||||||
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
|
||||||
|
|
||||||
:raises NotFoundError: 角色不存在
|
:raises NotFoundError: 角色不存在
|
||||||
:raises ForbiddenError: 试图删除内置管理员角色
|
:raises ForbiddenError: 试图删除内置角色
|
||||||
:raises BadRequestError: 角色下仍有绑定的用户
|
:raises BadRequestError: 角色下仍有绑定的用户
|
||||||
"""
|
"""
|
||||||
role = role_repository.get_by_id(session, role_id, for_update=True)
|
role = role_repository.get_by_id(session, role_id, for_update=True)
|
||||||
@@ -239,8 +242,8 @@ def delete_role(
|
|||||||
if role is None:
|
if role is None:
|
||||||
raise NotFoundError("角色不存在")
|
raise NotFoundError("角色不存在")
|
||||||
|
|
||||||
if role.role_code == ROLE_ADMIN:
|
if role.role_code in BUILTIN_ROLE_CODES:
|
||||||
raise ForbiddenError("内置管理员角色不可删除")
|
raise ForbiddenError("内置角色不可删除")
|
||||||
|
|
||||||
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
|
||||||
user_count = _user_count(session, role_id)
|
user_count = _user_count(session, role_id)
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""月度结算服务(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 decimal import Decimal
|
||||||
|
|
||||||
|
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
|
||||||
|
from app.models.settlement import (
|
||||||
|
SETTLEMENT_STATUS_APPROVED,
|
||||||
|
SETTLEMENT_STATUS_DRAFT,
|
||||||
|
SETTLEMENT_STATUS_PENDING,
|
||||||
|
SETTLEMENT_STATUS_REJECTED,
|
||||||
|
)
|
||||||
|
from app.repositories import 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——一行一月,重生成走「驳回/草稿态
|
||||||
|
重新生成」(先清 items 重算头表)
|
||||||
|
- 经理聚合(calc_type=2)在生成时计算(#12 暂口径:基数不含经理个人户)
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||||
|
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||||
|
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||||
|
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||||
|
# 草稿/已驳回:允许重新生成(清明细重算)
|
||||||
|
|
||||||
|
ledger_rows = _confirm_scope(session, month)
|
||||||
|
if not ledger_rows:
|
||||||
|
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||||
|
|
||||||
|
# 经理团队聚合(#12 策略点:基数不含经理个人户业绩)
|
||||||
|
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||||
|
|
||||||
|
settlement = settlement_repository.get_by_month(session, month)
|
||||||
|
is_new = settlement is None
|
||||||
|
if is_new:
|
||||||
|
settlement = MonthlySettlement(
|
||||||
|
settlement_month=month,
|
||||||
|
submitter_id=operator.id,
|
||||||
|
submitter_name=operator.username,
|
||||||
|
status=SETTLEMENT_STATUS_DRAFT,
|
||||||
|
)
|
||||||
|
settlement_repository.add(session, settlement)
|
||||||
|
else:
|
||||||
|
settlement.submitter_id = operator.id
|
||||||
|
settlement.submitter_name = operator.username
|
||||||
|
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||||
|
|
||||||
|
# 按人汇总(台账行:personnel+calc_type 维度)
|
||||||
|
by_person: dict[tuple[int, int], dict] = {}
|
||||||
|
for row in ledger_rows + aggregate_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)
|
||||||
|
|
||||||
|
items: list[SettlementItem] = []
|
||||||
|
for (_, _), bucket in sorted(by_person.items()):
|
||||||
|
person = session.get(Personnel, bucket["personnel_id"])
|
||||||
|
basic = person.salary_standard if person else None
|
||||||
|
mode = _salary_mode_of(session, bucket["role_code"])
|
||||||
|
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)} 元部分"
|
||||||
|
"年底一次性结算"
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
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")),
|
||||||
|
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
|
||||||
|
basic_amount=basic,
|
||||||
|
salary_mode=mode,
|
||||||
|
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
|
||||||
|
is_probation=bucket["is_probation"],
|
||||||
|
probation_note=note,
|
||||||
|
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
settlement_repository.replace_items(session, settlement.id, items)
|
||||||
|
settlement.total_performance = sum(
|
||||||
|
(i.performance_sum for i in items), Decimal("0")
|
||||||
|
).quantize(Decimal("0.01"))
|
||||||
|
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||||
|
Decimal("0.01")
|
||||||
|
)
|
||||||
|
settlement.person_count = len({i.personnel_id for i in items})
|
||||||
|
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||||
|
session.commit()
|
||||||
|
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
||||||
|
return _detail_of(session, settlement)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
session.commit()
|
||||||
|
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
||||||
|
return _detail_of(session, settlement)
|
||||||
|
|
||||||
|
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||||
|
session.commit()
|
||||||
|
return _detail_of(session, settlement)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""审批驳回钩子:回草稿(可修改后重提)。"""
|
||||||
|
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||||
|
|
||||||
|
|
||||||
|
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 策略点):本月有效台账行(未入结)。"""
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(PerformanceLedger)
|
||||||
|
.where(
|
||||||
|
PerformanceLedger.settlement_month == month,
|
||||||
|
PerformanceLedger.is_active == 1,
|
||||||
|
PerformanceLedger.settlement_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PerformanceLedger.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||||
|
"""按经理生成团队聚合行(calc_type=2)。
|
||||||
|
|
||||||
|
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type=
|
||||||
|
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。
|
||||||
|
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
|
||||||
|
的人员(本人带领的业务员经 manager_id 关联)。
|
||||||
|
"""
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
|
||||||
|
managers: dict[int, dict] = {}
|
||||||
|
for row in ledger_rows:
|
||||||
|
if row.customer_type == "manager_personal":
|
||||||
|
continue # 经理个人户业绩不入聚合基数(#12 暂口径)
|
||||||
|
# 找到该行业务归属人员的经理(manager_id)
|
||||||
|
person = session.get(Personnel, row.personnel_id)
|
||||||
|
manager_id = person.manager_id if person else None
|
||||||
|
if manager_id is None:
|
||||||
|
continue
|
||||||
|
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)
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.repositories import business_role_repository
|
||||||
|
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||||
|
|
||||||
|
aggregates: list = []
|
||||||
|
snapshot = resolve_snapshot(session, date.today())
|
||||||
|
team_pct = parse_fraction(snapshot.get("manager.share.team"))
|
||||||
|
company_pct = parse_fraction(snapshot.get("manager.share.company"))
|
||||||
|
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 # 非营销经理的上级不参与聚合
|
||||||
|
# 全公司基数:全部非经理个人户的有效行
|
||||||
|
company_base = sum(
|
||||||
|
(
|
||||||
|
r.performance_amount or Decimal("0")
|
||||||
|
for r in ledger_rows
|
||||||
|
if r.customer_type != "manager_personal"
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
share = manager_team_share(
|
||||||
|
bucket["performance"],
|
||||||
|
company_base,
|
||||||
|
team_pct=team_pct * 100,
|
||||||
|
company_pct=company_pct * 100,
|
||||||
|
)
|
||||||
|
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||||
|
aggregates.append(
|
||||||
|
PerformanceLedger(
|
||||||
|
receipt_id=None,
|
||||||
|
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=date.today(),
|
||||||
|
rate_text="聚合:12.5%+5%",
|
||||||
|
converted_profit=None,
|
||||||
|
performance_amount=share.quantize(Decimal("0.01")),
|
||||||
|
barter_applied=0,
|
||||||
|
payable_amount=share.quantize(Decimal("0.01")),
|
||||||
|
settlement_month=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,
|
||||||
|
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return aggregates
|
||||||
|
|
||||||
|
|
||||||
|
def _salary_mode_of(session: Session, role_code: str) -> str | None:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.services.param_service import resolve_snapshot
|
||||||
|
|
||||||
|
snapshot = resolve_snapshot(session, date.today())
|
||||||
|
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _probation_threshold(session: Session) -> Decimal:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||||
|
|
||||||
|
snapshot = resolve_snapshot(session, date.today())
|
||||||
|
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, ApprovalStep
|
||||||
|
from app.models.approval import (
|
||||||
|
APPROVAL_ACTION_AUTO_APPROVE,
|
||||||
|
APPROVAL_NODE_FINANCE,
|
||||||
|
APPROVAL_NODE_LEADER,
|
||||||
|
APPROVAL_TYPE_ADJUSTMENT,
|
||||||
|
)
|
||||||
|
from app.models.settlement import (
|
||||||
|
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||||
|
ADJUSTMENT_STATUS_PENDING,
|
||||||
|
)
|
||||||
|
from app.services.no_generator import next_no
|
||||||
|
|
||||||
|
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=payload.amount,
|
||||||
|
reason=payload.reason.strip(),
|
||||||
|
submitter_id=operator.id,
|
||||||
|
submitter_name=operator.username,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
|
||||||
|
order.status = ADJUSTMENT_STATUS_PENDING
|
||||||
|
session.flush()
|
||||||
|
approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||||
|
ref_id=order.id,
|
||||||
|
refer_no=order.order_no,
|
||||||
|
submitter=operator,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||||
|
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()
|
||||||
|
session.commit()
|
||||||
|
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
|
||||||
|
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
|
||||||
|
]
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""系统设置(公司信息)业务编排。
|
||||||
|
|
||||||
|
单例行语义:整个系统恒一行(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:
|
||||||
|
_cleanup_old_logo(old_ext)
|
||||||
|
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",
|
"captcha>=0.6",
|
||||||
"pillow>=11.0",
|
"pillow>=11.0",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
|
"python-multipart>=0.0.32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+32
-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)。
|
运行:`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,6 +150,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
| 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-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-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 清理两处缺陷,见下表) |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
@@ -161,6 +187,9 @@ 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 | 先款后货(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 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||||
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
| 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 往返实测通过 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
@@ -168,7 +197,8 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||||
|
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||||
|
|
||||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete, select, update
|
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.db import get_session_factory
|
||||||
from app.core.security import create_access_token, hash_password
|
from app.core.security import create_access_token, hash_password
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
AdjustmentOrder,
|
||||||
|
ApprovalOrder,
|
||||||
Business,
|
Business,
|
||||||
BusinessItem,
|
BusinessItem,
|
||||||
BusinessRole,
|
BusinessRole,
|
||||||
BusinessStatusLog,
|
BusinessStatusLog,
|
||||||
Customer,
|
Customer,
|
||||||
CustomerRatingGrade,
|
CustomerRatingGrade,
|
||||||
|
MonthlySettlement,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
OwnershipChangeLog,
|
OwnershipChangeLog,
|
||||||
|
ParamChangeBatch,
|
||||||
|
ParamValue,
|
||||||
|
PerformanceLedger,
|
||||||
Personnel,
|
Personnel,
|
||||||
PersonnelPlan,
|
PersonnelPlan,
|
||||||
PersonnelRegion,
|
PersonnelRegion,
|
||||||
Product,
|
Product,
|
||||||
Receipt,
|
Receipt,
|
||||||
Role,
|
Role,
|
||||||
|
SettlementItem,
|
||||||
Shipment,
|
Shipment,
|
||||||
ShipmentItem,
|
ShipmentItem,
|
||||||
|
SystemSetting,
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
)
|
)
|
||||||
@@ -92,6 +101,14 @@ def _purge_test_data() -> None:
|
|||||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||||
session.execute(delete(Role).where(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):业务/发货/回款三类单据 ----
|
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
||||||
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
||||||
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
||||||
@@ -168,7 +185,31 @@ def _purge_test_data() -> None:
|
|||||||
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
||||||
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
||||||
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
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 均大于种子终值 22
|
||||||
|
# (V3 十九条 + V6 两条 + V7 调整单开关一条),删新条目与新批次后解析
|
||||||
|
# 回退到种子值(开关=0)
|
||||||
|
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 22)).scalars())
|
||||||
|
if stray_values:
|
||||||
|
stray_batches = {v.batch_id for v in stray_values}
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.id > 22))
|
||||||
|
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()
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""审批流集成测试(二次分配方案为首个接入对象)。
|
||||||
|
|
||||||
|
守的是(计划 T4 验收矩阵):
|
||||||
|
- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点
|
||||||
|
action=3 留痕(留痕不断链,关闭=直接生效的需求口径)
|
||||||
|
- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3
|
||||||
|
- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400)
|
||||||
|
- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403);
|
||||||
|
普通用户/营销后勤进不了模块(403)
|
||||||
|
- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建
|
||||||
|
- 方案为空不提单
|
||||||
|
- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定)
|
||||||
|
|
||||||
|
角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。
|
||||||
|
开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.integration.factories import business_payload, customer
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_APPROVALS = "/api/v1/system/approvals"
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def _person(client: TestClient, headers: dict) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_审批_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": _any_role_id(),
|
||||||
|
"salary_standard": "3000.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _any_role_id() -> int:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import BusinessRole
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict:
|
||||||
|
cust = customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(
|
||||||
|
cust["id"],
|
||||||
|
secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict:
|
||||||
|
resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = resp.json()["data"]["list"]
|
||||||
|
assert rows, f"未找到审批单:{refer_no}"
|
||||||
|
detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers)
|
||||||
|
return detail.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None:
|
||||||
|
"""经参数变更批次把二次分配审批开关置值(立即生效)。"""
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": f"测试切换审批开关={value}",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"param_code": "approval.secondary_allocation.enabled",
|
||||||
|
"dimension_value": "",
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def finance(make_user, auth_headers) -> dict:
|
||||||
|
"""财务角色账号(审批第二节点)。"""
|
||||||
|
user = make_user(["finance"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def leader(make_user, auth_headers) -> dict:
|
||||||
|
"""领导角色账号(审批第三节点)。"""
|
||||||
|
user = make_user(["leader"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def switched_on(client: TestClient, admin: dict) -> None:
|
||||||
|
"""把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。"""
|
||||||
|
_set_switch(client, admin["headers"], "1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSwitchOff:
|
||||||
|
"""开关关闭(种子默认):提单即自动通过。"""
|
||||||
|
|
||||||
|
def test_创建带方案_自动通过全节点留痕(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert order["status"] == 3
|
||||||
|
assert order["order_no"].startswith("AP")
|
||||||
|
auto_steps = [s for s in order["steps"] if s["action"] == 3]
|
||||||
|
assert len(auto_steps) == 2
|
||||||
|
assert {s["node"] for s in auto_steps} == {1, 2}
|
||||||
|
assert all(s["operator_name"] == "系统" for s in auto_steps)
|
||||||
|
|
||||||
|
def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None:
|
||||||
|
cust = customer(client, admin["headers"])
|
||||||
|
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = client.get(
|
||||||
|
_APPROVALS,
|
||||||
|
params={"keyword": resp.json()["data"]["business_no"]},
|
||||||
|
headers=finance["headers"],
|
||||||
|
).json()["data"]["list"]
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSwitchOn:
|
||||||
|
"""开关开启:两节点人工流转。"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def switched_on(self, client: TestClient, admin: dict) -> None:
|
||||||
|
_set_switch(client, admin["headers"], "1")
|
||||||
|
|
||||||
|
def test_两节点流转至通过(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert order["status"] == 1
|
||||||
|
|
||||||
|
# 财务通过 → 待领导
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1, "opinion": "同意"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["status"] == 2
|
||||||
|
|
||||||
|
# 领导通过 → 已通过
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1},
|
||||||
|
headers=leader["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["status"] == 3
|
||||||
|
manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1]
|
||||||
|
assert {s["node"] for s in manual} == {1, 2}
|
||||||
|
|
||||||
|
def test_财务驳回_整体驳回且意见留痕(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
_ = leader
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 2, "opinion": "比例分配不合理"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out = resp.json()["data"]
|
||||||
|
assert out["status"] == 4
|
||||||
|
rejected = [s for s in out["steps"] if s["action"] == 2]
|
||||||
|
assert rejected and rejected[0]["opinion"] == "比例分配不合理"
|
||||||
|
|
||||||
|
def test_驳回缺意见被拒(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 2},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_节点准入_领导不能审财务节点_admin不代办(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, leader["headers"], data["business_no"])
|
||||||
|
|
||||||
|
# status=1 是财务节点:领导不行,admin 也不代办
|
||||||
|
for headers in (leader["headers"], admin["headers"]):
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_重复操作已通过单被拒(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""单笔回款核算集成测试(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, Receipt
|
||||||
|
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_台账列表与筛选(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_重算_普通用户与营销后勤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
|
||||||
@@ -86,9 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
|||||||
class TestCatalog:
|
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"]
|
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}
|
groups = {item["param_group"] for item in items}
|
||||||
assert groups == {
|
assert groups == {
|
||||||
"业务员个人户",
|
"业务员个人户",
|
||||||
@@ -98,6 +99,7 @@ class TestCatalog:
|
|||||||
"支付环节",
|
"支付环节",
|
||||||
"产品阈值",
|
"产品阈值",
|
||||||
"月工资口径",
|
"月工资口径",
|
||||||
|
"审批开关",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class TestSchemaSync:
|
|||||||
class TestSeedData:
|
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:
|
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
||||||
migration_sql = "\n".join(
|
migration_sql = "\n".join(
|
||||||
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
|
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""二次分配方案录入(业务创建/编辑同事务)集成测试。
|
||||||
|
|
||||||
|
守的是(计划 T3 验收):
|
||||||
|
- 创建带方案 → 详情回显含 personnel_name 冗余与比例
|
||||||
|
- 编辑全量替换(空数组=清空)
|
||||||
|
- 离职/不存在人员 → 400「分摊参与人员不存在或已离职」
|
||||||
|
- 终态业务禁改方案(与编辑终态门槛同一口径)
|
||||||
|
- 合计不强制 100%:hint 展示合计与余额(余额归属待澄清 #2)
|
||||||
|
- 删除保护:被方案引用的人员 RESTRICT 禁删(400 而非静默剥离)
|
||||||
|
- 人员删除后方案回显仍有名(personnel_name 固化的意义)
|
||||||
|
|
||||||
|
人员造数走 /api/v1/base/personnel(种子营销员岗位);离职用状态端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.integration.factories import business_payload, customer
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def _person(client: TestClient, headers: dict, **overrides: object) -> dict:
|
||||||
|
role_id = overrides.pop("business_role_id", None)
|
||||||
|
body = {
|
||||||
|
"personnel_name": f"t_分摊_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": role_id or _any_role_id(),
|
||||||
|
"salary_standard": "3000.00",
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
resp = client.post(_PERSONNEL, json=body, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _any_role_id() -> int:
|
||||||
|
"""种子营销员岗位 ID(内置启用)。"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import BusinessRole
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_with_allocations(client: TestClient, headers: dict, allocations: list[dict]) -> dict:
|
||||||
|
cust = customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(cust["id"], secondary_allocations=allocations),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateAndEcho:
|
||||||
|
"""创建带方案与详情回显。"""
|
||||||
|
|
||||||
|
def test_带方案创建_详情回显姓名与比例(self, client: TestClient, admin: dict) -> None:
|
||||||
|
p1 = _person(client, admin["headers"])
|
||||||
|
p2 = _person(client, admin["headers"])
|
||||||
|
data = _create_with_allocations(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
[
|
||||||
|
{"personnel_id": p1["id"], "ratio": "30.00"},
|
||||||
|
{"personnel_id": p2["id"], "ratio": "20.00"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
detail = _detail(client, admin["headers"], data["id"])
|
||||||
|
assert len(detail["secondary_allocations"]) == 2
|
||||||
|
names = {a["personnel_name"] for a in detail["secondary_allocations"]}
|
||||||
|
assert p1["personnel_name"] in names and p2["personnel_name"] in names
|
||||||
|
ratios = sorted(a["ratio"] for a in detail["secondary_allocations"])
|
||||||
|
assert ratios == ["20.00", "30.00"]
|
||||||
|
# 合计 50%,余额 50% —— 只提示不落库(待澄清 #2)
|
||||||
|
assert "50.00" in (detail["allocation_hint"] or "")
|
||||||
|
assert "余额" in (detail["allocation_hint"] or "")
|
||||||
|
|
||||||
|
def test_不建方案_详情为空且无hint(self, client: TestClient, admin: dict) -> None:
|
||||||
|
data = _create_with_allocations(client, admin["headers"], [])
|
||||||
|
detail = _detail(client, admin["headers"], data["id"])
|
||||||
|
assert detail["secondary_allocations"] == []
|
||||||
|
assert detail["allocation_hint"] is None
|
||||||
|
|
||||||
|
def test_合计满额_hint只报合计(self, client: TestClient, admin: dict) -> None:
|
||||||
|
p1 = _person(client, admin["headers"])
|
||||||
|
p2 = _person(client, admin["headers"])
|
||||||
|
data = _create_with_allocations(
|
||||||
|
client,
|
||||||
|
admin["headers"],
|
||||||
|
[
|
||||||
|
{"personnel_id": p1["id"], "ratio": "60"},
|
||||||
|
{"personnel_id": p2["id"], "ratio": "40.00"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
hint = _detail(client, admin["headers"], data["id"])["allocation_hint"]
|
||||||
|
assert hint is not None and "余额" not in hint
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuards:
|
||||||
|
"""校验链:在职/终态/去重已由 schema 层覆盖,此处打服务层口径。"""
|
||||||
|
|
||||||
|
def test_离职人员参与被拒(self, client: TestClient, admin: dict) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
cust = customer(client, admin["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(
|
||||||
|
cust["id"],
|
||||||
|
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
|
||||||
|
),
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "分摊参与" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_离职历史参与人_编辑其余字段放行(self, client: TestClient, admin: dict) -> None:
|
||||||
|
"""历史参与人离职后:编辑业务(保留该行)不应被 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:
|
||||||
|
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,335 @@
|
|||||||
|
"""月度结算集成测试(M3 ④:生成快照 → 审批 → 锁定)。
|
||||||
|
|
||||||
|
守的还有:
|
||||||
|
- 确认回款口径(#8 策略点):仅 is_active=1 且未入结的台账行入结;
|
||||||
|
入结后重生成同月不再重复计入
|
||||||
|
- 通过即锁定(status=3 终态);锁定后回款写路径 400(走调整单)
|
||||||
|
- 驳回回草稿可重提;开关两路(关=自动通过锁定;开=审批流转)
|
||||||
|
- #12 暂口径:经理个人户业绩不入聚合基数(金标准仅业务员个人户,
|
||||||
|
聚合用例以「无经理台账」形态验证 items 按人汇总不聚合)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import MonthlySettlement
|
||||||
|
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 _person(client: TestClient, headers: dict) -> dict:
|
||||||
|
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_id = int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": role_id,
|
||||||
|
"salary_standard": "3000.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
# 提交(开关默认关)→ 自动通过锁定
|
||||||
|
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
|
||||||
|
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||||
|
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubmitAndLock:
|
||||||
|
"""提交审批与锁定(开关两路)。"""
|
||||||
|
|
||||||
|
def test_开关关闭_提交即自动通过锁定(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict
|
||||||
|
) -> None:
|
||||||
|
_calc_one(client, admin["headers"])
|
||||||
|
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
|
||||||
|
|
||||||
|
# 领导通过 → 锁定
|
||||||
|
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"] # 审批通过人=领导账号
|
||||||
@@ -11,10 +11,14 @@ from fractions import Fraction
|
|||||||
|
|
||||||
from app.services.formula import (
|
from app.services.formula import (
|
||||||
apply_barter,
|
apply_barter,
|
||||||
|
clamp_weight_r,
|
||||||
converted_profit,
|
converted_profit,
|
||||||
|
manager_team_share,
|
||||||
|
monthly_salary,
|
||||||
payment_days,
|
payment_days,
|
||||||
payment_weight,
|
payment_weight,
|
||||||
performance_amount,
|
performance_amount,
|
||||||
|
probation_annual_note,
|
||||||
profit_rate,
|
profit_rate,
|
||||||
salesperson_rate,
|
salesperson_rate,
|
||||||
start_payment_ok,
|
start_payment_ok,
|
||||||
@@ -137,3 +141,68 @@ class TestModifiers:
|
|||||||
|
|
||||||
def test_起付判定_未达到60点(self) -> None:
|
def test_起付判定_未达到60点(self) -> None:
|
||||||
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
|
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",
|
"login_logs",
|
||||||
"operation_logs",
|
"operation_logs",
|
||||||
"business_roles",
|
"business_roles",
|
||||||
|
"system_settings",
|
||||||
|
"approval_orders",
|
||||||
|
"approval_steps",
|
||||||
|
"business_secondary_allocations",
|
||||||
"param_definitions",
|
"param_definitions",
|
||||||
"param_change_batches",
|
"param_change_batches",
|
||||||
"param_values",
|
"param_values",
|
||||||
@@ -36,6 +40,10 @@ EXPECTED_TABLES = {
|
|||||||
"shipment_items",
|
"shipment_items",
|
||||||
"receipts",
|
"receipts",
|
||||||
"customer_rating_grades",
|
"customer_rating_grades",
|
||||||
|
"performance_ledgers",
|
||||||
|
"monthly_settlements",
|
||||||
|
"settlement_items",
|
||||||
|
"adjustment_orders",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.services.param_registry import (
|
|||||||
validate_and_normalize,
|
validate_and_normalize,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 与 db/migration/V3 迁移种子保持一致的 16 项编码
|
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项 + V7 调整单开关)
|
||||||
EXPECTED_CODES = {
|
EXPECTED_CODES = {
|
||||||
"salesperson.rate.start",
|
"salesperson.rate.start",
|
||||||
"salesperson.rate.slope",
|
"salesperson.rate.slope",
|
||||||
@@ -36,14 +36,17 @@ EXPECTED_CODES = {
|
|||||||
"salary.basic.mode",
|
"salary.basic.mode",
|
||||||
"probation.annual.threshold",
|
"probation.annual.threshold",
|
||||||
"probation.salary.basic",
|
"probation.salary.basic",
|
||||||
|
"approval.secondary_allocation.enabled",
|
||||||
|
"approval.monthly_settlement.enabled",
|
||||||
|
"approval.adjustment.enabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestRegistry:
|
class TestRegistry:
|
||||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||||
|
|
||||||
def test_注册表共16项(self) -> None:
|
def test_注册表共19项(self) -> None:
|
||||||
assert len(PARAM_REGISTRY) == 16
|
assert len(PARAM_REGISTRY) == 19
|
||||||
|
|
||||||
def test_编码集合与迁移种子一致(self) -> None:
|
def test_编码集合与迁移种子一致(self) -> None:
|
||||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||||
@@ -61,10 +64,28 @@ class TestRegistry:
|
|||||||
continue
|
continue
|
||||||
if spec.value_type in {"DECIMAL", "INT"}:
|
if spec.value_type in {"DECIMAL", "INT"}:
|
||||||
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
|
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":
|
elif spec.value_type == "ENUM":
|
||||||
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
||||||
assert spec.default_value in spec.options
|
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:
|
def test_取值域上下限可解析(self) -> None:
|
||||||
for spec in PARAM_REGISTRY.values():
|
for spec in PARAM_REGISTRY.values():
|
||||||
if spec.value_min is not None:
|
if spec.value_min is not None:
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""二次分配方案入参校验(schema 层,秒级)。
|
||||||
|
|
||||||
|
守的是:比例红线(字符串入参、两位小数、超限拒绝不静默舍入)与
|
||||||
|
参与人去重——uk(business_id, personnel_id) 的友好前置校验。
|
||||||
|
明细行同批创建的正常路径由集成测试覆盖,这里集中打边界。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.business import SecondaryAllocationIn
|
||||||
|
|
||||||
|
|
||||||
|
def _make(personnel_id: int = 1, ratio: str = "50.00") -> dict:
|
||||||
|
return {"personnel_id": personnel_id, "ratio": ratio}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRatioBoundary:
|
||||||
|
"""比例边界:0 / 满额 / 超限 / 精度 / 非数字。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("ratio", "ok"),
|
||||||
|
[
|
||||||
|
("0", False), # 必须大于 0
|
||||||
|
("0.01", True),
|
||||||
|
("33.33", True),
|
||||||
|
("100", True),
|
||||||
|
("100.00", True),
|
||||||
|
("100.01", False), # 超上限
|
||||||
|
("-5", False), # 负数
|
||||||
|
("33.333", False), # 三位小数:拒绝不静默舍入
|
||||||
|
("abc", False), # 非数字
|
||||||
|
("", False), # 空串
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_比例边界(self, ratio: str, *, ok: bool) -> None:
|
||||||
|
if ok:
|
||||||
|
assert SecondaryAllocationIn(**_make(ratio=ratio)).ratio == ratio
|
||||||
|
else:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SecondaryAllocationIn(**_make(ratio=ratio))
|
||||||
|
|
||||||
|
def test_人员id必须为正整数(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SecondaryAllocationIn(**_make(personnel_id=0))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SecondaryAllocationIn(**_make(personnel_id=-1))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicate:
|
||||||
|
"""参与人去重(Create/Update 两个入参模型同款校验)。"""
|
||||||
|
|
||||||
|
def test_创建入参_重复参与人被拒(self) -> None:
|
||||||
|
from app.schemas.business import BusinessCreateIn
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer_id": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "t_产品",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondary_allocations": [
|
||||||
|
_make(personnel_id=5, ratio="30.00"),
|
||||||
|
_make(personnel_id=5, ratio="20.00"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with pytest.raises(ValidationError, match="重复"):
|
||||||
|
BusinessCreateIn(**payload)
|
||||||
|
|
||||||
|
def test_编辑入参_重复参与人被拒(self) -> None:
|
||||||
|
from app.schemas.business import BusinessUpdateIn
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer_id": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "t_产品",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondary_allocations": [_make(personnel_id=5), _make(personnel_id=5)],
|
||||||
|
}
|
||||||
|
with pytest.raises(ValidationError, match="重复"):
|
||||||
|
BusinessUpdateIn(**payload)
|
||||||
|
|
||||||
|
def test_不同参与人合法(self) -> None:
|
||||||
|
from app.schemas.business import BusinessCreateIn
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"customer_id": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"product_name": "t_产品",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "1.00",
|
||||||
|
"sale_price": "10.00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondary_allocations": [
|
||||||
|
_make(personnel_id=5, ratio="30.00"),
|
||||||
|
_make(personnel_id=6, ratio="20.00"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
model = BusinessCreateIn(**payload)
|
||||||
|
assert len(model.secondary_allocations) == 2
|
||||||
Generated
+11
@@ -435,6 +435,7 @@ dependencies = [
|
|||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "pymysql", extra = ["rsa"] },
|
{ name = "pymysql", extra = ["rsa"] },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "redis", extra = ["hiredis"] },
|
{ name = "redis", extra = ["hiredis"] },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
@@ -462,6 +463,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||||
{ name = "pyjwt", specifier = ">=2.10" },
|
{ name = "pyjwt", specifier = ">=2.10" },
|
||||||
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
|
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||||
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
|
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
||||||
{ name = "starlette", specifier = ">=1.7,<2" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|||||||
+204
-3
@@ -16,6 +16,14 @@
|
|||||||
SET NAMES utf8mb4;
|
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_values`;
|
||||||
DROP TABLE IF EXISTS `param_change_batches`;
|
DROP TABLE IF EXISTS `param_change_batches`;
|
||||||
DROP TABLE IF EXISTS `param_definitions`;
|
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);
|
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 业务裁决)
|
-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决)
|
||||||
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
|
-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致
|
||||||
@@ -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),
|
(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),
|
(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),
|
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
||||||
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16);
|
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16),
|
||||||
|
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流(2026-10-15 复核审批补充需求)', 17),
|
||||||
|
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18),
|
||||||
|
(19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道', 19);
|
||||||
|
|
||||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||||
@@ -457,7 +476,10 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
|||||||
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
|
(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1),
|
||||||
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
|
(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1),
|
||||||
(18, 15, '', '2026-01-01', '18000', 1),
|
(18, 15, '', '2026-01-01', '18000', 1),
|
||||||
(19, 16, '', '2026-01-01', '3000', 1);
|
(19, 16, '', '2026-01-01', '3000', 1),
|
||||||
|
(20, 17, '', '2026-01-01', '0', 1),
|
||||||
|
(21, 18, '', '2026-01-01', '0', 1),
|
||||||
|
(22, 19, '', '2026-01-01', '0', 1);
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||||
@@ -663,6 +685,185 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
|
|||||||
('C', 60, 74, 3, '合格'),
|
('C', 60, 74, 3, '合格'),
|
||||||
('D', 0, 59, 4, '待改进');
|
('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 UNIQUE (receipt_id, calc_version),
|
||||||
|
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 成为空操作
|
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -671,4 +872,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f');
|
INSERT INTO alembic_version (version_num) VALUES ('3bca400421c6');
|
||||||
@@ -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_ledger_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_performance_ledger_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_performance_ledger_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT uk_performance_ledger_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_ledger_business_id ON performance_ledgers (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_performance_ledger_month_active ON performance_ledgers (settlement_month, is_active);
|
||||||
|
|
||||||
|
CREATE INDEX idx_performance_ledger_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||||
|
|
||||||
|
CREATE INDEX idx_performance_ledger_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';
|
||||||
|
|
||||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。
|
> **面向 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 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
|
**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。
|
||||||
|
|
||||||
@@ -40,11 +40,17 @@
|
|||||||
### 1.1 推荐顺序与依赖
|
### 1.1 推荐顺序与依赖
|
||||||
|
|
||||||
```text
|
```text
|
||||||
① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板
|
① 系统设置与参数体系(#28)✅ ← 地基:岗位角色 + 参数 + 公式模板
|
||||||
② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游)
|
│ 增量(2026-10-15):公司信息配置;审批开关走参数体系
|
||||||
③ 业务数据管理 ← 引用 ② 人员/产品/客户
|
② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游)
|
||||||
|
③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户
|
||||||
|
│ 增量(2026-10-15):二次分配方案(合同刚建立时录入)
|
||||||
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据
|
||||||
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约
|
│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清)
|
||||||
|
⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展
|
||||||
|
⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约
|
||||||
|
⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层
|
||||||
|
⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 顺序论证(要点)
|
### 1.2 顺序论证(要点)
|
||||||
@@ -59,32 +65,37 @@
|
|||||||
|
|
||||||
| 块 | 范围界定 | 验收标准(一句话) |
|
| 块 | 范围界定 | 验收标准(一句话) |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 |
|
| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 |
|
||||||
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 |
|
||||||
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 |
|
| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 |
|
||||||
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 |
|
| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 |
|
||||||
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 |
|
| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 |
|
||||||
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 |
|
||||||
|
| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 |
|
||||||
|
| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 |
|
||||||
|
| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 |
|
||||||
|
|
||||||
### 1.4 里程碑(交付批次)
|
### 1.4 里程碑(交付批次)
|
||||||
|
|
||||||
| 批次 | 内容 | 说明 |
|
| 批次 | 内容 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 |
|
| **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) |
|
||||||
| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 |
|
| **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) |
|
||||||
| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 |
|
| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 |
|
||||||
| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 |
|
| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 |
|
||||||
|
| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 |
|
||||||
|
|
||||||
### 1.5 需求依据对照
|
### 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.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.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 | 协助 25%;经理 8‰;26 天示例;含运费公式 |
|
| ④ | 需规 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) | — |
|
| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — |
|
||||||
| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — |
|
| ⑥ | 需规各「同步 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(四) 字段为准,导出前与业务对一次列 |
|
| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 |
|
||||||
| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) |
|
| 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` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
|||||||
|
# 备份恢复手册(⑨ 备份恢复与运维检查)
|
||||||
|
|
||||||
|
> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。
|
||||||
|
|
||||||
|
## 一、备份(每日一次,建议凌晨低峰)
|
||||||
|
|
||||||
|
```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)
|
||||||
|
- 全部 ✓ 即备份可恢复;出现 ✗ 时临时库保留供排查
|
||||||
|
|
||||||
|
## 三、生产恢复(真出事了,七步)
|
||||||
|
|
||||||
|
> ⚠️ 生产恢复是**高危操作**:先把容器停了再恢复,避免写冲突。下面假设恢复到**全新空数据卷**(最干净路径)。
|
||||||
|
|
||||||
|
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 客户端)
|
||||||
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
# 待确认事项汇总(2026-10-16)
|
||||||
|
|
||||||
|
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
|
||||||
|
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
||||||
|
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
||||||
|
|
||||||
|
## 一、立即待办(需项目内处理)
|
||||||
|
|
||||||
|
| # | 事项 | 状态 | 下一步动作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **推送 feat/m3-increments(27 个提交)** | ⛔ 被远端阻塞 | 43.140.215.244:7040 的 Git 服务应用层无响应(ping 通、TCP 通、HTTP 不回包),需**服务器管理员重启 Git 服务**;恢复后执行 `git push -u origin feat/m3-increments`。本地提交完整无丢失 |
|
||||||
|
| 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 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定 |
|
||||||
|
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认 |
|
||||||
|
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||||
|
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||||
|
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ⚠️ 模糊:「做参数可调」,初始值未给 |
|
||||||
|
|
||||||
|
### 中优先级(模块实现期内答复即可,9 项)
|
||||||
|
|
||||||
|
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 5 | 二次分配审批开关默认值? | 🔁 **与默认不同:默认开启**,业务可关闭 |
|
||||||
|
| 7 | 月度结算审批链默认开还是关? | ✅ 默认开启,可手动关闭 |
|
||||||
|
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
|
||||||
|
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
|
||||||
|
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
|
||||||
|
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||||
|
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | 🔁 **与默认不同:按人员档案逐人维护**(用 `personnel.salary_standard`,撤 `probation.salary.basic` 参数) |
|
||||||
|
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||||
|
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
||||||
|
|
||||||
|
### 低优先级(已有默认口径,顺手确认,7 项)
|
||||||
|
|
||||||
|
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | 🔁 **与默认不同:首日整 10‰**。实现口径待追问:A 仅首日覆盖(保「360 天=6‰」锚点,推荐)/ B 整体平移(破坏锚点) |
|
||||||
|
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 查看可、修改仅管理员 |
|
||||||
|
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
|
||||||
|
| 19 | 备份保留周期与恢复目标? | 🔁 **新增要求:每日全量 + 有更新时增量补充**(现三脚本仅全量) |
|
||||||
|
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
|
||||||
|
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||||
|
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||||
|
|
||||||
|
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待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 剩余 | ④ 核算引擎 + 月结/调整单/负数工资 + ⑦ 月结审批对象接入 | #12(基数参数初始值)、#20(含税口径)、#1–#3(二次分摊)落定后按同粒度另出计划;#14 首日 10‰ 口径(A/B)确认后随本批实现 |
|
||||||
|
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 交付后 |
|
||||||
|
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
||||||
|
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
||||||
|
| 杂项收口(低优) | 超长函数 17 处收敛、散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||||
|
|
||||||
|
## 五、文档待办(低优)
|
||||||
|
|
||||||
|
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||||
|
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.3**(收编业务方口径修订,生成脚本 build_tplus_xlsx.py 同步对齐)
|
||||||
|
- 新口径待落实(业务答复与默认不同的 4 项):#5 审批开关默认开启(核对 M3 参数种子)、#14 首日整 10‰(④ 实现,A/B 口径待追问)、#16 试用期工资迁人员档案、#19 备份补增量能力
|
||||||
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` 传管理员密码。
|
运行前置:容器 + 后端(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 基础数据与业务数据)
|
## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据)
|
||||||
|
|
||||||
| 脚本 | 覆盖 | 结果 |
|
| 脚本 | 覆盖 | 结果 |
|
||||||
@@ -159,3 +168,13 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
||||||
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
||||||
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
|
单价列 `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
|
remark: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务详情:附产品明细行(编辑弹窗一次回显) */
|
/** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */
|
||||||
|
export interface SecondaryAllocationRow {
|
||||||
|
personnel_id: number
|
||||||
|
personnel_name: string
|
||||||
|
/** 百分比数字字符串(两位小数) */
|
||||||
|
ratio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */
|
||||||
export interface BusinessDetail extends BusinessItem {
|
export interface BusinessDetail extends BusinessItem {
|
||||||
items: BusinessItemRow[]
|
items: BusinessItemRow[]
|
||||||
|
secondary_allocations: SecondaryAllocationRow[]
|
||||||
|
/** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */
|
||||||
|
allocation_hint: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessQueryParams {
|
export interface BusinessQueryParams {
|
||||||
@@ -96,6 +107,8 @@ export interface BusinessCreateParams {
|
|||||||
is_special?: number
|
is_special?: number
|
||||||
special_note?: string | null
|
special_note?: string | null
|
||||||
items: BusinessItemParams[]
|
items: BusinessItemParams[]
|
||||||
|
/** 二次分配方案(只出现在合同刚建立时;空=不设置) */
|
||||||
|
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +124,8 @@ export interface BusinessUpdateParams {
|
|||||||
is_special?: number
|
is_special?: number
|
||||||
special_note?: string | null
|
special_note?: string | null
|
||||||
items: BusinessItemParams[]
|
items: BusinessItemParams[]
|
||||||
|
/** 二次分配方案(全量替换;空数组=清空) */
|
||||||
|
secondary_allocations?: { personnel_id: number; ratio: string }[]
|
||||||
remark?: string | null
|
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 userStore = useUserStore()
|
||||||
|
|
||||||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||||||
|
const userRoles = computed(() => userStore.roles)
|
||||||
|
|
||||||
const menuNodes = computed<MenuNode[]>(() =>
|
const menuNodes = computed<MenuNode[]>(() =>
|
||||||
router
|
router
|
||||||
.getRoutes()
|
.getRoutes()
|
||||||
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
|
||||||
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
|
||||||
|
// 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合)
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
!record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)),
|
||||||
|
)
|
||||||
.map((record) => ({
|
.map((record) => ({
|
||||||
path: record.path,
|
path: record.path,
|
||||||
title: record.meta.title ?? '',
|
title: record.meta.title ?? '',
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ declare module 'vue-router' {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
|
||||||
requiresAdmin?: boolean
|
requiresAdmin?: boolean
|
||||||
|
/** 任一角色可访问(与 requiresAdmin 互斥使用;admin 须显式列入)——
|
||||||
|
* 菜单按角色过滤 + 守卫兜底,用于审批等职能页面 */
|
||||||
|
allowedRoles?: string[]
|
||||||
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
|
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
|
||||||
menu?: boolean
|
menu?: boolean
|
||||||
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
|
||||||
@@ -64,49 +67,67 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/system/params/index.vue'),
|
component: () => import('@/views/system/params/index.vue'),
|
||||||
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/system/settings',
|
||||||
|
name: 'system-settings',
|
||||||
|
component: () => import('@/views/system/settings/index.vue'),
|
||||||
|
meta: { title: '公司信息', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 审批管理:管理员/财务/领导可见(营销后勤待权限清单答复后放开)
|
||||||
|
path: '/system/approvals',
|
||||||
|
name: 'system-approvals',
|
||||||
|
component: () => import('@/views/system/approvals/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '审批管理',
|
||||||
|
group: '系统管理',
|
||||||
|
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/system/logs',
|
path: '/system/logs',
|
||||||
name: 'system-logs',
|
name: 'system-logs',
|
||||||
component: () => import('@/views/system/logs/index.vue'),
|
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)
|
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
|
||||||
path: '/base/personnel',
|
path: '/base/personnel',
|
||||||
name: 'base-personnel',
|
name: 'base-personnel',
|
||||||
component: () => import('@/views/base/personnel/index.vue'),
|
component: () => import('@/views/base/personnel/index.vue'),
|
||||||
meta: { title: '人员管理', group: '基础数据', order: 8 },
|
meta: { title: '人员管理', group: '基础数据', order: 10 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/products',
|
path: '/base/products',
|
||||||
name: 'base-products',
|
name: 'base-products',
|
||||||
component: () => import('@/views/base/products/index.vue'),
|
component: () => import('@/views/base/products/index.vue'),
|
||||||
meta: { title: '产品管理', group: '基础数据', order: 9 },
|
meta: { title: '产品管理', group: '基础数据', order: 11 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/base/customers',
|
path: '/base/customers',
|
||||||
name: 'base-customers',
|
name: 'base-customers',
|
||||||
component: () => import('@/views/base/customers/index.vue'),
|
component: () => import('@/views/base/customers/index.vue'),
|
||||||
meta: { title: '客户管理', group: '基础数据', order: 10 },
|
meta: { title: '客户管理', group: '基础数据', order: 12 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
// 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin
|
||||||
path: '/business/businesses',
|
path: '/business/businesses',
|
||||||
name: 'business-businesses',
|
name: 'business-businesses',
|
||||||
component: () => import('@/views/business/businesses/index.vue'),
|
component: () => import('@/views/business/businesses/index.vue'),
|
||||||
meta: { title: '业务管理', group: '业务数据', order: 11 },
|
meta: { title: '业务管理', group: '业务数据', order: 13 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/business/shipments',
|
path: '/business/shipments',
|
||||||
name: 'business-shipments',
|
name: 'business-shipments',
|
||||||
component: () => import('@/views/business/shipments/index.vue'),
|
component: () => import('@/views/business/shipments/index.vue'),
|
||||||
meta: { title: '发货管理', group: '业务数据', order: 12 },
|
meta: { title: '发货管理', group: '业务数据', order: 14 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/business/receipts',
|
path: '/business/receipts',
|
||||||
name: 'business-receipts',
|
name: 'business-receipts',
|
||||||
component: () => import('@/views/business/receipts/index.vue'),
|
component: () => import('@/views/business/receipts/index.vue'),
|
||||||
meta: { title: '回款管理', group: '业务数据', order: 13 },
|
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
||||||
@@ -114,7 +135,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/business/rating',
|
path: '/business/rating',
|
||||||
name: 'business-rating',
|
name: 'business-rating',
|
||||||
component: () => import('@/views/business/rating/index.vue'),
|
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 +198,12 @@ router.beforeEach(async (to) => {
|
|||||||
return { path: '/dashboard' }
|
return { path: '/dashboard' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线
|
||||||
|
if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) {
|
||||||
|
ElMessage.error('无权访问该页面')
|
||||||
|
return { path: '/dashboard' }
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,69 @@ const form = reactive({
|
|||||||
remark: '',
|
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 {
|
function emptyItem(): ItemDraft {
|
||||||
return {
|
return {
|
||||||
@@ -172,6 +235,7 @@ function openCreate(): void {
|
|||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
||||||
|
allocationRows.value = []
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
formRef.value?.clearValidate()
|
formRef.value?.clearValidate()
|
||||||
}
|
}
|
||||||
@@ -203,6 +267,10 @@ async function openEdit(row: BusinessItem): Promise<void> {
|
|||||||
cost_amount: it.cost_amount ?? '',
|
cost_amount: it.cost_amount ?? '',
|
||||||
guided_price: it.guided_price ?? '',
|
guided_price: it.guided_price ?? '',
|
||||||
}))
|
}))
|
||||||
|
allocationRows.value = (detail.secondary_allocations ?? []).map((a) => ({
|
||||||
|
personnel_id: a.personnel_id,
|
||||||
|
ratio: a.ratio,
|
||||||
|
}))
|
||||||
formRef.value?.clearValidate()
|
formRef.value?.clearValidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +334,11 @@ async function handleSubmit(): Promise<void> {
|
|||||||
ElMessage.warning(rowError)
|
ElMessage.warning(rowError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const allocError = validateAllocationRows(allocationRows.value)
|
||||||
|
if (allocError) {
|
||||||
|
ElMessage.warning(allocError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -277,6 +350,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
is_special: form.is_special,
|
is_special: form.is_special,
|
||||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||||
items: buildItemParams(itemRows.value),
|
items: buildItemParams(itemRows.value),
|
||||||
|
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||||
remark: form.remark.trim() || null,
|
remark: form.remark.trim() || null,
|
||||||
})
|
})
|
||||||
ElMessage.success('已保存')
|
ElMessage.success('已保存')
|
||||||
@@ -289,6 +363,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
is_special: form.is_special,
|
is_special: form.is_special,
|
||||||
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
special_note: form.is_special === 1 ? form.special_note.trim() || null : null,
|
||||||
items: buildItemParams(itemRows.value),
|
items: buildItemParams(itemRows.value),
|
||||||
|
secondary_allocations: buildAllocationParams(allocationRows.value),
|
||||||
remark: form.remark.trim() || null,
|
remark: form.remark.trim() || null,
|
||||||
})
|
})
|
||||||
ElMessage.success('业务已创建')
|
ElMessage.success('业务已创建')
|
||||||
@@ -752,6 +827,58 @@ onMounted(() => {
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-form>
|
</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">
|
<div class="items-head">
|
||||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||||||
@@ -1209,6 +1336,32 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
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 {
|
.items-table {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<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 {
|
||||||
|
recalcTarget.value = row
|
||||||
|
recalcReason.value = ''
|
||||||
|
recalcVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRecalc(): Promise<void> {
|
||||||
|
if (!recalcTarget.value) return
|
||||||
|
if (!recalcReason.value.trim()) {
|
||||||
|
ElMessage.warning('重算必须填写原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await calcApi.recalcReceipt(recalcTarget.value.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="版本">
|
||||||
|
<el-select v-model="filters.is_active" 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 captchaId = ref('')
|
||||||
const captchaImage = 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 = {
|
const rules: FormRules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
password: [{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,7 +114,8 @@ onMounted(refreshCaptcha)
|
|||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="card-brand">
|
<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>
|
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +181,7 @@ onMounted(refreshCaptcha)
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="login-foot">© 衍水集团 · 内部系统</p>
|
<p class="login-foot">© {{ brandName }} · 内部系统</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -252,6 +282,14 @@ onMounted(refreshCaptcha)
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-brand__logo {
|
||||||
|
display: block;
|
||||||
|
max-width: 56px;
|
||||||
|
max-height: 36px;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.card-brand__name {
|
.card-brand__name {
|
||||||
font-family: var(--ys-font-brand);
|
font-family: var(--ys-font-brand);
|
||||||
font-size: 17px;
|
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>
|
||||||
@@ -37,6 +37,10 @@ async function loadCatalog(): Promise<void> {
|
|||||||
catalogLoading.value = true
|
catalogLoading.value = true
|
||||||
try {
|
try {
|
||||||
catalog.value = await paramsApi.getCatalog()
|
catalog.value = await paramsApi.getCatalog()
|
||||||
|
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
||||||
|
for (const item of catalog.value) {
|
||||||
|
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,17 +57,42 @@ const groupedCatalog = computed(() => {
|
|||||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 维度值 → 展示名(NONE 维度为空串) */
|
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
|
||||||
const DIMENSION_LABELS: Record<string, string> = {
|
const DIMENSION_LABELS: Record<string, string> = {
|
||||||
'': '默认',
|
'': '默认',
|
||||||
municipal_water: '市政水利',
|
municipal_water: '市政水利',
|
||||||
rail_transit: '轨道交通',
|
rail_transit: '轨道交通',
|
||||||
|
salesperson: '营销员',
|
||||||
|
sales_manager: '营销经理',
|
||||||
|
info_officer: '信息员',
|
||||||
}
|
}
|
||||||
|
|
||||||
function dimensionLabel(dimension: string): string {
|
function dimensionLabel(dimension: string): string {
|
||||||
return DIMENSION_LABELS[dimension] ?? dimension
|
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 {
|
function formatValue(item: ParamCatalogItem, value: string): string {
|
||||||
return item.unit ? `${value} ${item.unit}` : value
|
return item.unit ? `${value} ${item.unit}` : value
|
||||||
@@ -763,7 +792,7 @@ onMounted(() => {
|
|||||||
<div class="pr-used">
|
<div class="pr-used">
|
||||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
||||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
<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>
|
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||||
</div>
|
</div>
|
||||||
</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,58 @@
|
|||||||
|
#!/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)"
|
||||||
|
|
||||||
|
# 保留 30 天
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -name 'payroll_*.sql.gz' -mtime +30 -print -delete
|
||||||
|
echo "已清理 30 天前的旧备份"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "最新备份:"
|
||||||
|
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -3
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/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 存活(密码经宿主 docker/.env,不经命令行明文参数)
|
||||||
|
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
|
||||||
|
if docker exec ys-redis-dev 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,103 @@
|
|||||||
|
#!/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. 行数核对:关键业务表
|
||||||
|
TABLES=(users roles user_roles param_definitions param_values businesses business_items personnel products customers shipments shipment_items receipts approval_orders approval_steps system_settings)
|
||||||
|
|
||||||
|
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="✓"
|
||||||
|
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