Compare commits
No commits in common. "238a38c3565a2e899f209d9a6357dfc809e045df" and "927592b472cfc72d7d30cf46767373bb0f2fc856" have entirely different histories.
238a38c356
...
927592b472
@ -1,632 +0,0 @@
|
||||
"""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")
|
||||
@ -255,11 +255,6 @@ def upgrade() -> None:
|
||||
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",
|
||||
|
||||
@ -15,8 +15,6 @@ from app.api.v1.business import businesses as business_businesses
|
||||
from app.api.v1.business import ratings as business_ratings
|
||||
from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.calc import router as calc_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import logs as system_logs
|
||||
@ -61,9 +59,4 @@ business_router.include_router(business_receipts.router)
|
||||
business_router.include_router(business_ratings.router)
|
||||
v1_router.include_router(business_router)
|
||||
|
||||
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(calc_router)
|
||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(settlements_router)
|
||||
|
||||
api_router.include_router(v1_router)
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
"""绩效核算接口(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="批量核算完成",
|
||||
)
|
||||
@ -1,99 +0,0 @@
|
||||
"""月度结算接口(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))
|
||||
@ -12,7 +12,6 @@ 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
|
||||
@ -43,21 +42,14 @@ def update_settings(
|
||||
|
||||
|
||||
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
|
||||
def upload_logo(
|
||||
async 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()
|
||||
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。"""
|
||||
content = await file.read()
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||
return ok(
|
||||
service.save_logo(session, content, ext, operator=current_user, meta=meta),
|
||||
|
||||
@ -85,8 +85,6 @@ MODULE_RECEIPT = "回款管理"
|
||||
MODULE_RATING = "客户评级"
|
||||
MODULE_SYSTEM_SETTINGS = "系统设置"
|
||||
MODULE_APPROVAL = "审批管理"
|
||||
MODULE_CALC = "绩效核算"
|
||||
MODULE_SETTLEMENT = "月度结算"
|
||||
|
||||
# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)=====
|
||||
# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组
|
||||
@ -95,7 +93,6 @@ NO_PREFIX_SHIPMENT = "SH"
|
||||
NO_PREFIX_RECEIPT = "HK"
|
||||
NO_PREFIX_PARAM = "P"
|
||||
NO_PREFIX_APPROVAL = "AP"
|
||||
NO_PREFIX_ADJUSTMENT = "TZ"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
|
||||
@ -17,19 +17,16 @@ from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.performance_ledger import PerformanceLedger
|
||||
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.models.product import Product
|
||||
from app.models.rating import CustomerRatingGrade
|
||||
from app.models.receipt import Receipt
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"AdjustmentOrder",
|
||||
"ApprovalOrder",
|
||||
"ApprovalStep",
|
||||
"Base",
|
||||
@ -40,13 +37,11 @@ __all__ = [
|
||||
"Customer",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonthlySettlement",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
@ -56,7 +51,6 @@ __all__ = [
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"SecondaryAllocation",
|
||||
"SettlementItem",
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
|
||||
@ -42,10 +42,9 @@ APPROVAL_ACTION_REJECT = 2 # 人工驳回
|
||||
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
||||
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
||||
|
||||
# 审批对象类型(approval_orders.order_type)
|
||||
# 审批对象类型(approval_orders.order_type;2 为 ④ 月结预留)
|
||||
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||
APPROVAL_TYPE_ADJUSTMENT = 3
|
||||
|
||||
|
||||
class ApprovalOrder(Base, TimestampMixin):
|
||||
@ -67,7 +66,7 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
comment="审批单号,自动生成 AP{YYYYMMDD}-{4位当日序号}",
|
||||
)
|
||||
order_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算 3调整单"
|
||||
TINYINT_TYPE, nullable=False, comment="审批对象类型:1二次分配方案 2月度结算(预留)"
|
||||
)
|
||||
ref_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@ -56,11 +56,6 @@ class SecondaryAllocation(Base, TimestampMixin):
|
||||
# 表/列注释避开「%」字符: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"),
|
||||
)
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
"""绩效核算台账模型(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="备注")
|
||||
@ -1,197 +0,0 @@
|
||||
"""月度结算与调整单模型(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="提交人用户名(冗余固化)"
|
||||
)
|
||||
@ -228,18 +228,6 @@ def replace_allocations(
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_allocations(session: Session, business_id: int) -> None:
|
||||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||||
|
||||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||||
方案事实由审批单留痕与操作日志 request_data 承载。
|
||||
"""
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||
"""追加状态留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
"""绩效核算台账仓储:只读写数据并 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)
|
||||
@ -1,94 +0,0 @@
|
||||
"""月度结算仓储:只读写数据并 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()
|
||||
@ -1,62 +0,0 @@
|
||||
"""绩效核算的请求/响应模型(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
|
||||
@ -5,7 +5,7 @@
|
||||
顺着复用链泄漏到匿名端点。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
@ -23,12 +23,6 @@ class SettingPublicOut(BaseModel):
|
||||
|
||||
|
||||
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="公司名称")
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
"""月度结算的请求/响应模型(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="扣补说明")
|
||||
@ -22,6 +22,7 @@ import logging
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import (
|
||||
@ -32,13 +33,13 @@ from app.core.constants import (
|
||||
ROLE_FINANCE,
|
||||
ROLE_LEADER,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import ApprovalOrder, ApprovalStep, User
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_APPROVE,
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
APPROVAL_ACTION_REJECT,
|
||||
APPROVAL_ACTION_WITHDRAW,
|
||||
APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_NODE_LEADER,
|
||||
@ -70,6 +71,8 @@ ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
}
|
||||
|
||||
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||
|
||||
@ -97,15 +100,19 @@ def submit_order(
|
||||
ref_id: int,
|
||||
refer_no: str,
|
||||
submitter: User,
|
||||
retry_on_conflict: bool,
|
||||
) -> ApprovalOrder:
|
||||
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||
|
||||
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
||||
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
||||
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
||||
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
||||
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
||||
(全节点 action=3 留痕)。
|
||||
|
||||
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部
|
||||
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环,
|
||||
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把
|
||||
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
|
||||
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
|
||||
全局 handler 收敛 400,用户重试即可。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
@ -126,13 +133,13 @@ def submit_order(
|
||||
|
||||
if is_enabled(session, order_type):
|
||||
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
||||
session.flush()
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
logger.info("审批单待审 | order=%s", order.order_no)
|
||||
return order
|
||||
|
||||
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||
order.status = APPROVAL_STATUS_APPROVED
|
||||
session.flush()
|
||||
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||
session.add_all(
|
||||
[
|
||||
ApprovalStep(
|
||||
@ -149,29 +156,6 @@ def submit_order(
|
||||
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,
|
||||
@ -210,15 +194,14 @@ def act(
|
||||
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:
|
||||
if payload.action == APPROVAL_ACTION_APPROVE:
|
||||
order.status = (
|
||||
APPROVAL_STATUS_PENDING_LEADER
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
else:
|
||||
order.status = APPROVAL_STATUS_REJECTED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
@ -230,49 +213,8 @@ def act(
|
||||
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
|
||||
return _detail_of(session, order)
|
||||
|
||||
|
||||
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||
@ -337,8 +279,33 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
|
||||
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||
|
||||
|
||||
def _flush_order(session: Session, order: ApprovalOrder, *, retry_on_conflict: bool) -> None:
|
||||
"""flush 提单;撞 AP 单号唯一键时按 retry_on_conflict 决定重取或抛出。
|
||||
|
||||
rollback 会连业务写路径的未提交变更一并回滚 —— 见 submit_order 的
|
||||
retry_on_conflict 说明(create 才允许内部重取)。
|
||||
"""
|
||||
for attempt in range(_NO_RETRY + 1):
|
||||
try:
|
||||
session.flush()
|
||||
return
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
if is_duplicate_entry(exc) and retry_on_conflict and attempt < _NO_RETRY:
|
||||
order.id = None # rollback 后实例回 transient,主键重新分配
|
||||
order.order_no = next_no(
|
||||
session,
|
||||
model=ApprovalOrder,
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_APPROVAL,
|
||||
)
|
||||
session.add(order)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||||
"""出参组装(含 steps 时间线)。"""
|
||||
steps = (
|
||||
session.execute(
|
||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||
@ -354,13 +321,8 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
|
||||
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)
|
||||
)
|
||||
like = f"%{query.keyword}%"
|
||||
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like))
|
||||
if query.status is not None:
|
||||
filters.append(ApprovalOrder.status == query.status)
|
||||
if query.order_type is not None:
|
||||
|
||||
@ -34,7 +34,6 @@ from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageQuery, PageResult
|
||||
from app.models import (
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessStatusLog,
|
||||
@ -52,7 +51,7 @@ from app.models.business import (
|
||||
BUSINESS_STATUS_DONE,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.repositories import business_repository, customer_repository
|
||||
from app.schemas.business import (
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
@ -285,29 +284,15 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
|
||||
|
||||
def _build_allocation_rows(
|
||||
session: Session,
|
||||
business_id: int,
|
||||
items: list[SecondaryAllocationIn],
|
||||
*,
|
||||
old_ids: set[int],
|
||||
session: Session, business_id: int, items: list[SecondaryAllocationIn]
|
||||
) -> list[SecondaryAllocation]:
|
||||
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||
"""构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。
|
||||
|
||||
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
|
||||
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
|
||||
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
|
||||
**新加入**的参与人做在职校验。
|
||||
|
||||
:raises BadRequestError: 新增参与人员不存在或已离职
|
||||
: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, "分摊参与")
|
||||
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
|
||||
rows.append(
|
||||
SecondaryAllocation(
|
||||
business_id=business_id,
|
||||
@ -403,13 +388,11 @@ def create_business(
|
||||
business_repository.replace_allocations(
|
||||
session,
|
||||
business.id,
|
||||
_build_allocation_rows(
|
||||
session, business.id, payload.secondary_allocations, old_ids=set()
|
||||
),
|
||||
_build_allocation_rows(session, business.id, payload.secondary_allocations),
|
||||
)
|
||||
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
|
||||
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
|
||||
# 业务/方案/审批单整体重来
|
||||
# retry_on_conflict=True:AP 编号撞键可内部重取(外层编号
|
||||
# 重试循环兜底整体重来)
|
||||
if payload.secondary_allocations:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
@ -417,6 +400,7 @@ def create_business(
|
||||
ref_id=business.id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
retry_on_conflict=True,
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
@ -493,18 +477,13 @@ def update_business(
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
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
|
||||
),
|
||||
_build_allocation_rows(session, business_id, payload.secondary_allocations),
|
||||
)
|
||||
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
|
||||
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
|
||||
# 方案非空提单(自动撤回旧在途单);retry_on_conflict=False:
|
||||
# 本路径无外层重试,内部 rollback 会回掉业务行修改造成静默丢失
|
||||
if payload.secondary_allocations:
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
@ -512,13 +491,7 @@ def update_business(
|
||||
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,
|
||||
retry_on_conflict=False,
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
@ -542,7 +515,7 @@ def delete_business(
|
||||
与业务维度的归属留痕(无外键固化表,须手动清)。
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
|
||||
:raises BadRequestError: 存在发货或回款记录
|
||||
"""
|
||||
business = business_repository.get_by_id(session, business_id, for_update=True)
|
||||
with audit_service.audit(
|
||||
@ -559,10 +532,6 @@ def delete_business(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
|
||||
if _count_pending_approvals(session, business_id):
|
||||
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
|
||||
|
||||
business_no = business.business_no
|
||||
_hard_delete(session, business_id)
|
||||
@ -570,46 +539,11 @@ def delete_business(
|
||||
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
|
||||
|
||||
|
||||
def _count_pending_approvals(session: Session, business_id: int) -> int:
|
||||
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models import ApprovalOrder
|
||||
from app.models.approval import (
|
||||
APPROVAL_STATUS_PENDING_FINANCE,
|
||||
APPROVAL_STATUS_PENDING_LEADER,
|
||||
)
|
||||
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(ApprovalOrder)
|
||||
.where(
|
||||
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ApprovalOrder.ref_id == business_id,
|
||||
ApprovalOrder.status.in_(
|
||||
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
|
||||
),
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _hard_delete(session: Session, business_id: int) -> None:
|
||||
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
|
||||
状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||
session.execute(
|
||||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||||
)
|
||||
session.execute(
|
||||
delete(ApprovalOrder).where(
|
||||
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ApprovalOrder.ref_id == business_id,
|
||||
)
|
||||
)
|
||||
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
|
||||
session.execute(
|
||||
delete(OwnershipChangeLog).where(
|
||||
|
||||
@ -1,468 +0,0 @@
|
||||
"""绩效核算服务(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,63 +95,3 @@ def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bo
|
||||
if revenue == 0:
|
||||
return False
|
||||
return Fraction(received) / Fraction(revenue) >= ratio / 100
|
||||
|
||||
|
||||
def clamp_weight_r(received: Decimal, revenue: Decimal) -> int:
|
||||
"""支付权重 R:回款占销售额百分比,封顶封底 [0, 100](对照表 #22)。
|
||||
|
||||
received 非负时 int() 即向下取整;min/max 同时是红字冲销(负数回款)
|
||||
接入时的方向防线。从计算预览的内联实现提取 —— ④ 核算与预览共用。
|
||||
"""
|
||||
if revenue <= 0:
|
||||
return 0
|
||||
return max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100)))
|
||||
|
||||
|
||||
def monthly_salary(performance: Decimal, basic: Decimal | None, mode: str) -> Decimal | None:
|
||||
"""月工资分岗口径(对照表 #8,salary.basic.mode 按角色维度取值)。
|
||||
|
||||
- INCLUDE:绩效 + 基本工资(业务员默认——绩效之外照发基本工资)
|
||||
- DEDUCT:绩效 − 基本工资(经理默认——基本工资从绩效分成中扣除)
|
||||
- EXCLUDE:仅绩效,基本工资不参与
|
||||
basic 缺失时按 0 参与(INCLUDE/EXCLUDE 等价于纯绩效)。
|
||||
"""
|
||||
basic_value = basic if basic is not None else Decimal("0")
|
||||
if mode == "INCLUDE":
|
||||
return performance + basic_value
|
||||
if mode == "DEDUCT":
|
||||
return performance - basic_value
|
||||
if mode == "EXCLUDE":
|
||||
return performance
|
||||
raise ValueError(f"未知的月工资口径:{mode}")
|
||||
|
||||
|
||||
def probation_annual_note(year_total: Decimal, threshold: Decimal) -> str | None:
|
||||
"""试用期年累计说明:超阈值部分年底一次性结算(对照表 #17)。
|
||||
|
||||
未超阈值返回 None(无需说明);阈值按元展示(Decimal 直出)。
|
||||
"""
|
||||
if year_total <= threshold:
|
||||
return None
|
||||
excess = (year_total - threshold).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP")
|
||||
return f"试用期绩效照算不发放;年累计超 {threshold} 元部分年底一次性结算(当前超 {excess} 元)"
|
||||
|
||||
|
||||
def manager_team_share(
|
||||
team_performance: Decimal,
|
||||
company_performance: Decimal,
|
||||
*,
|
||||
team_pct: Fraction,
|
||||
company_pct: Fraction,
|
||||
) -> Decimal:
|
||||
"""经理绩效 = 本团队业务员绩效 × 12.5% + 全公司业务员绩效 × 5%(裁决 #6/#15/#16)。
|
||||
|
||||
**#12 策略点(待业务答复,2026-10-16 暂定口径)**:基数**不含**经理本人
|
||||
名下经理个人户业务的业绩——该部分按经理个人户规则另行处理(单笔落台账
|
||||
rate=0/绩效 0,待答复后若需并入,只改调用方传入的基数集合)。
|
||||
团队口径:manager_id=该经理且岗位非「营销经理」的业务员绩效行之和;
|
||||
全公司口径:全部业务员(非经理岗)绩效行之和。
|
||||
"""
|
||||
return _to_decimal(Fraction(team_performance) * team_pct / 100) + _to_decimal(
|
||||
Fraction(company_performance) * company_pct / 100
|
||||
)
|
||||
|
||||
@ -290,23 +290,6 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
),
|
||||
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,8 +614,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
||||
|
||||
start_ratio = take("payment.start.ratio")
|
||||
start_ok = formula.start_payment_ok(received, revenue, start_ratio)
|
||||
# R 钳制已提取为纯函数 clamp_weight_r(④ 核算与预览共用同一实现)
|
||||
weight_r = formula.clamp_weight_r(received, revenue)
|
||||
# R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int()
|
||||
# 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线
|
||||
weight_r = (
|
||||
max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0
|
||||
)
|
||||
weight = formula.payment_weight(weight_r)
|
||||
base_amount = after_barter if barter_applied else performance
|
||||
payable = to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
@ -23,7 +23,6 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -210,12 +209,6 @@ def create_receipt(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新增回款")
|
||||
# 月结锁定拦截(2026-10-15 补充需求):比需求原文「提示」取硬,
|
||||
# 一期安全侧(策略点注释);未指定日期时以当天月近似
|
||||
from app.services import settlement_service
|
||||
|
||||
_lock_month = (payload.receipt_date or date.today()).strftime("%Y-%m")
|
||||
settlement_service.ensure_month_not_locked(session, _lock_month)
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
session, payload.shipment_id, payload.business_id
|
||||
)
|
||||
|
||||
@ -1,523 +0,0 @@
|
||||
"""月度结算服务(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
|
||||
]
|
||||
@ -126,18 +126,17 @@ def save_logo(
|
||||
setting = get_setting(session)
|
||||
old_path = setting.logo_path
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
|
||||
# 刚落盘的新文件一并删掉(回读 404)
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
_cleanup_old_logo(old_ext)
|
||||
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 后端测试
|
||||
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
|
||||
|
||||
## 一、测试约定
|
||||
|
||||
@ -113,29 +113,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||
|
||||
### M3-core ④ 核算引擎 + 月度结算(2026-10-16 完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/test_formula.py`(扩) | R 钳制(向下取整/[0,100] 封顶封底/销售额 0 防御)、月工资三口径(INCLUDE 加/DEDUCT 减可负/EXCLUDE 仅绩效/基本缺失按 0/未知口径报错)、试用期年累计说明(未超无 note/超额精确差额)、经理 12.5%+5% 聚合(#12 暂口径) | +12 |
|
||||
| `tests/integration/test_calc.py` | **金标准双类别复算到分**(市政 4000 + 轨交 0 = 折算 4000;88/9000 约分原文落 rate_text;绩效 39.11;R=100 应付 39.11,DB 锚定);**业务级一次性计提 #25**(同业务第二笔回款 repeated=True 仅登记进度不新增行);已核算再核算 400;未指定批次 400;**成本缺失行毛利按 0 计**(折算保守 −3000、missing_cost=1);负绩效照算不归零(折算 −2000);准入(normal 403/marketing_ops 可用);列表筛选;重算(finance 授权、v2 生效 v1 保留、reason 留痕、原因缺失 400、未核算重算 400、越权 403);批量逐笔成败清单 | 13 |
|
||||
| `tests/integration/test_settlements.py` | 生成按人汇总+台账回填 settlement_id;无核算记录 400;**已审定月份重生成 400**;开关关闭提交即自动通过锁定(AP 单号/locked_at 回填);**锁定后回款禁改 400**(M2 已核算禁改防线先于锁定拦截);开关开启两节点流转(finance→leader)至锁定;#12 暂口径聚合不实现(items 按人汇总不聚合验证) | 6 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{calc,settlements}.py tests/unit/test_formula.py -q` → **32 passed**(2026-10-16);全量回归见运行记录。
|
||||
|
||||
### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 |
|
||||
| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 |
|
||||
| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 |
|
||||
| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 |
|
||||
| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 |
|
||||
| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@ -150,9 +127,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
|
||||
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
|
||||
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
@ -187,9 +161,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
@ -197,8 +168,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| --- | --- | --- |
|
||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
|
||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
@ -30,7 +29,6 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.security import create_access_token, hash_password
|
||||
from app.models import (
|
||||
AdjustmentOrder,
|
||||
ApprovalOrder,
|
||||
Business,
|
||||
BusinessItem,
|
||||
@ -38,22 +36,18 @@ from app.models import (
|
||||
BusinessStatusLog,
|
||||
Customer,
|
||||
CustomerRatingGrade,
|
||||
MonthlySettlement,
|
||||
OperationLog,
|
||||
OwnershipChangeLog,
|
||||
ParamChangeBatch,
|
||||
ParamValue,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
PersonnelPlan,
|
||||
PersonnelRegion,
|
||||
Product,
|
||||
Receipt,
|
||||
Role,
|
||||
SettlementItem,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
SystemSetting,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
@ -101,14 +95,6 @@ def _purge_test_data() -> None:
|
||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
||||
|
||||
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
|
||||
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
|
||||
# 的单据与人员清理;仅测试写入,全量清)----
|
||||
session.execute(delete(PerformanceLedger))
|
||||
session.execute(delete(SettlementItem))
|
||||
session.execute(delete(MonthlySettlement))
|
||||
session.execute(delete(AdjustmentOrder).where(AdjustmentOrder.order_no.like("TZ%")))
|
||||
|
||||
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
||||
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
||||
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
||||
@ -186,30 +172,18 @@ def _purge_test_data() -> None:
|
||||
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
||||
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
||||
|
||||
# ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ----
|
||||
# ---- 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())
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21
|
||||
# (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0)
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 22))
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 21))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
session.execute(
|
||||
update(SystemSetting)
|
||||
.where(SystemSetting.id == 1)
|
||||
.values(company_name="衍水集团", logo_path=None)
|
||||
)
|
||||
session.commit()
|
||||
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
|
||||
if logo_dir.is_dir():
|
||||
for f in logo_dir.iterdir():
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@ -261,28 +261,9 @@ class TestSwitchOn:
|
||||
)
|
||||
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
|
||||
@ -312,64 +293,6 @@ class TestResubmit:
|
||||
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)。"""
|
||||
|
||||
@ -1,507 +0,0 @@
|
||||
"""单笔回款核算集成测试(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,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
||||
class TestCatalog:
|
||||
"""参数目录:种子完整性与时点化回溯。"""
|
||||
|
||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
||||
def test_目录共18项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V6 起含审批开关组(16 计提项 + 2 BOOL 开关)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 19
|
||||
assert len(items) == 18
|
||||
groups = {item["param_group"] for item in items}
|
||||
assert groups == {
|
||||
"业务员个人户",
|
||||
|
||||
@ -142,43 +142,6 @@ class TestGuards:
|
||||
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(
|
||||
|
||||
@ -1,335 +0,0 @@
|
||||
"""月度结算集成测试(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,14 +11,10 @@ from fractions import Fraction
|
||||
|
||||
from app.services.formula import (
|
||||
apply_barter,
|
||||
clamp_weight_r,
|
||||
converted_profit,
|
||||
manager_team_share,
|
||||
monthly_salary,
|
||||
payment_days,
|
||||
payment_weight,
|
||||
performance_amount,
|
||||
probation_annual_note,
|
||||
profit_rate,
|
||||
salesperson_rate,
|
||||
start_payment_ok,
|
||||
@ -141,68 +137,3 @@ class TestModifiers:
|
||||
|
||||
def test_起付判定_未达到60点(self) -> None:
|
||||
assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False
|
||||
|
||||
|
||||
class TestClampWeightR:
|
||||
"""R 钳制:向下取整 + [0,100] 封顶封底(从预览内联提取的共用实现)。"""
|
||||
|
||||
def test_向下取整(self) -> None:
|
||||
assert clamp_weight_r(Decimal("5999.99"), Decimal("10000")) == 59
|
||||
|
||||
def test_封顶100_封底0(self) -> None:
|
||||
assert clamp_weight_r(Decimal("20000"), Decimal("10000")) == 100
|
||||
assert clamp_weight_r(Decimal("0"), Decimal("10000")) == 0
|
||||
|
||||
def test_销售额0_防御0(self) -> None:
|
||||
assert clamp_weight_r(Decimal("100"), Decimal("0")) == 0
|
||||
|
||||
|
||||
class TestMonthlySalary:
|
||||
"""月工资分岗口径三档(对照表 #8)。"""
|
||||
|
||||
def test_INCLUDE_绩效加基本(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "INCLUDE") == Decimal("4000")
|
||||
|
||||
def test_DEDUCT_绩效减基本_可为负(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "DEDUCT") == Decimal("-2000")
|
||||
|
||||
def test_EXCLUDE_仅绩效(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), Decimal("3000"), "EXCLUDE") == Decimal("1000")
|
||||
|
||||
def test_基本缺失按0(self) -> None:
|
||||
assert monthly_salary(Decimal("1000"), None, "INCLUDE") == Decimal("1000")
|
||||
|
||||
def test_未知口径报错(self) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
monthly_salary(Decimal("1"), Decimal("1"), "UNKNOWN")
|
||||
|
||||
|
||||
class TestProbationAnnualNote:
|
||||
"""试用期年累计说明:超阈值才有 note,金额精确到分。"""
|
||||
|
||||
def test_未超阈值_无说明(self) -> None:
|
||||
assert probation_annual_note(Decimal("18000"), Decimal("18000")) is None
|
||||
assert probation_annual_note(Decimal("100"), Decimal("18000")) is None
|
||||
|
||||
def test_超额_给出精确差额(self) -> None:
|
||||
note = probation_annual_note(Decimal("20000.5"), Decimal("18000"))
|
||||
assert note is not None and "2000.50" in note
|
||||
|
||||
|
||||
class TestManagerTeamShare:
|
||||
"""经理 12.5%+5% 聚合(#12 暂口径:基数不含经理个人户业绩——由调用方
|
||||
传入的基数集合保证,本函数只做乘法与求和)。"""
|
||||
|
||||
def test_两段乘加_精确分数(self) -> None:
|
||||
# 入参为百分数(与种子一致:12.5 表示 12.5%):10000×12.5%=1250、50000×5%=2500
|
||||
got = manager_team_share(
|
||||
Decimal("10000"), Decimal("50000"), team_pct=Fraction(25, 2), company_pct=Fraction(5)
|
||||
)
|
||||
assert got == Decimal("3750")
|
||||
|
||||
def test_零基数_零结果(self) -> None:
|
||||
assert manager_team_share(
|
||||
Decimal("0"), Decimal("0"), team_pct=Fraction(0), company_pct=Fraction(0)
|
||||
) == Decimal("0")
|
||||
|
||||
@ -40,10 +40,6 @@ EXPECTED_TABLES = {
|
||||
"shipment_items",
|
||||
"receipts",
|
||||
"customer_rating_grades",
|
||||
"performance_ledgers",
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
"adjustment_orders",
|
||||
}
|
||||
|
||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||
|
||||
@ -18,7 +18,7 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项 + V7 调整单开关)
|
||||
# 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@ -38,15 +38,14 @@ EXPECTED_CODES = {
|
||||
"probation.salary.basic",
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共19项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 19
|
||||
def test_注册表共18项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 18
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
@ -71,11 +70,10 @@ class TestRegistry:
|
||||
assert spec.default_value in spec.options
|
||||
|
||||
def test_BOOL开关_仅接受0与1(self) -> None:
|
||||
"""审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。"""
|
||||
"""V6 首例 BOOL 注册的两个审批开关:合法值与非法值边界。"""
|
||||
for code in (
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
):
|
||||
spec = PARAM_REGISTRY[code]
|
||||
assert spec.value_type == "BOOL"
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
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`;
|
||||
@ -454,8 +450,7 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty
|
||||
(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15),
|
||||
(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16),
|
||||
(17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '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);
|
||||
(18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '预留参数:月度结算审批链开关(M3 ④ 月结子块接入时消费);0=结算生成即生效,1=走财务复核→领导审核审批流', 18);
|
||||
|
||||
INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES
|
||||
(1, 1, 'salesperson', '2026-02-17', '10', 1),
|
||||
@ -478,8 +473,7 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
|
||||
(18, 15, '', '2026-01-01', '18000', 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);
|
||||
(21, 18, '', '2026-01-01', '0', 1);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护)
|
||||
@ -740,7 +734,6 @@ CREATE TABLE business_secondary_allocations (
|
||||
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;
|
||||
@ -749,118 +742,6 @@ CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondar
|
||||
|
||||
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);
|
||||
|
||||
@ -872,4 +753,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('3bca400421c6');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('79bcc5aa98d4');
|
||||
|
||||
@ -53,8 +53,7 @@ CREATE TABLE business_secondary_allocations (
|
||||
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)
|
||||
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);
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
-- 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.
@ -1,70 +0,0 @@
|
||||
# 备份恢复手册(⑨ 备份恢复与运维检查)
|
||||
|
||||
> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。
|
||||
|
||||
## 一、备份(每日一次,建议凌晨低峰)
|
||||
|
||||
```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 客户端)
|
||||
@ -1,94 +0,0 @@
|
||||
# 待确认事项汇总(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 备份补增量能力
|
||||
@ -149,15 +149,6 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
|
||||
运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。
|
||||
|
||||
## 前端页面(2026-10-17 追加 · M3-core ④ 核算与月结)
|
||||
|
||||
| 页面 | 说明 |
|
||||
| --- | --- |
|
||||
| `/calc/ledger` 绩效台账(新组「绩效工资」order17) | 月/版本筛选、行展开计算快照、重算(原因对话框,管理员/财务) |
|
||||
| `/settlements` 月度结算(order18) | 生成快照(月份输入)→ 状态筛选 → 提交审批(开关关=自动通过锁定)→ 详情抽屉(按人三口径明细/试用期说明/审批单号) |
|
||||
|
||||
路由组「绩效工资」;api 封装 `src/api/{calc,settlements}.ts`。type-check 与 build 通过(2026-10-17)。
|
||||
|
||||
## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据)
|
||||
|
||||
| 脚本 | 覆盖 | 结果 |
|
||||
@ -168,13 +159,3 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
||||
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
||||
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
|
||||
|
||||
## 端到端测试(2026-10-16 追加 · M3 增量批次)
|
||||
|
||||
| 脚本 | 覆盖 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| `e2e/settings_flow.py` | 公司信息 4 步:进设置页改公司名(随机名防撞)→ 保存 → 退出登录 → **登录页品牌位与页脚即时显示新名**(public 端点)→ finally 还原默认名(防污染其它 e2e 的品牌断言) | ✓ 全通过(修复 public 缓存缺陷后) |
|
||||
| `e2e/secondary_allocation_flow.py` | 二次分配 6 步:路由直达业务管理 → 建客户 → 建 2 名在职营销员 → 新增业务录两行参与人(30%+40%)→ **编辑回显 2 行 + 合计条「余额 30.00%(归属待业务确认)」** → 删除清理 | ✓ 全通过(修复 logo 无关的三处 e2e 选择器后) |
|
||||
|
||||
运行前置同上(需 `E2E_ADMIN_PASSWORD`)。**期间修复的缺陷**:public 端点 300s 响应缓存让登录页拿旧公司名(改 `fetch cache: no-store` + 时间戳绕过磁盘缓存);业务弹窗分配表与产品明细共用 `items-table` 类导致选择器错位(分配表加专属 `alloc-table` 类)。
|
||||
**回归**:既有五脚本(登录 8 / 系统管理 13 / 基础数据 12 / 业务 16 / 参数 7 步)在 allowedRoles 路由扩展与品牌名动态化后全部复跑通过。
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
"""二次分配方案录入端到端验证。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
|
||||
并填比例 → 提交成功
|
||||
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
|
||||
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
|
||||
- 数据清理:删除本次创建的业务(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())
|
||||
@ -1,127 +0,0 @@
|
||||
"""公司信息设置端到端验证。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
- 登录 → 系统管理分组出现「公司信息」菜单 → 进入设置页
|
||||
- 修改公司名称并保存 → 全局提示「已保存」
|
||||
- 退出登录 → 登录页品牌位显示**新**公司名(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())
|
||||
@ -1,70 +0,0 @@
|
||||
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 } })
|
||||
}
|
||||
@ -34,13 +34,7 @@ export function uploadLogo(file: File): Promise<CompanySettings> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||
* 请求头 no-cache:要求中间层/浏览器重校验而非直接用缓存副本 —— 公司名
|
||||
* 保存后应立即可见,不能因 300s 短缓存拿到旧值。 */
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点) */
|
||||
export function getPublicSettings(): Promise<PublicSettings> {
|
||||
return request({
|
||||
url: '/v1/system/settings/public',
|
||||
method: 'get',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
return request({ url: '/v1/system/settings/public', method: 'get' })
|
||||
}
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
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' })
|
||||
}
|
||||
@ -137,20 +137,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/business/rating/index.vue'),
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 未匹配路由回首页;若未登录会被守卫拦到登录页
|
||||
|
||||
@ -835,7 +835,7 @@ onMounted(() => {
|
||||
</span>
|
||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||
</div>
|
||||
<el-table :data="allocationRows" stripe size="small" class="items-table alloc-table">
|
||||
<el-table :data="allocationRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="参与业务员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
<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>
|
||||
@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { getPublicSettings } from '@/api/settings'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
@ -29,21 +30,9 @@ 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
|
||||
const data = await getPublicSettings()
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url ?? null
|
||||
brandLogo.value = data.logo_url
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
|
||||
@ -1,245 +0,0 @@
|
||||
<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>
|
||||
@ -1,58 +0,0 @@
|
||||
#!/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
|
||||
@ -1,105 +0,0 @@
|
||||
#!/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
|
||||
@ -1,103 +0,0 @@
|
||||
#!/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
|
||||
Loading…
Reference in New Issue
Block a user