补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到 的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实 无处回溯。 - 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/ captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭 的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单 只读回退当前内容并显式告警,不回填历史快照 - 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为 business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认 只取生效行,业务详情新增 allocation_history - 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个 version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审) - 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取 - 月结提交取行加 for_update,防并发提交算出重复的审批单版本号 - 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00 - V11 迁移三件套同步(Alembic / db/migration / init-dev.sql) 测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
307 lines
14 KiB
Python
307 lines
14 KiB
Python
"""业务数据模型:业务单、产品明细与状态流转留痕。
|
||
|
||
- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点
|
||
重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN
|
||
聚合;代价是所有写路径必须重算(business_service 统一收口)
|
||
- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是
|
||
sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或
|
||
参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用
|
||
formula.profit_rate/converted_profit),M3 核算台账才是法定固化点
|
||
- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14)
|
||
- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算
|
||
不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1)
|
||
"""
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import (
|
||
Date,
|
||
DateTime,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
Numeric,
|
||
String,
|
||
UniqueConstraint,
|
||
text,
|
||
)
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
from app.models.base import (
|
||
BIGINT_UNSIGNED,
|
||
TINYINT_TYPE,
|
||
Base,
|
||
TimestampMixin,
|
||
TPlusSyncMixin,
|
||
mysql_table_args,
|
||
)
|
||
|
||
# 业务状态(businesses.status,流转规则见 business_service:终态不可变更)
|
||
BUSINESS_STATUS_ACTIVE = 1
|
||
BUSINESS_STATUS_DONE = 2
|
||
BUSINESS_STATUS_TERMINATED = 3
|
||
|
||
|
||
class SecondaryAllocation(Base, TimestampMixin):
|
||
"""二次分配方案明细表(2026-10-15 补充需求「二次分配方案」)。
|
||
|
||
口径(AI_CONTEXT 待澄清 #1/#2 未答复前的边界):
|
||
- 只出现在合同刚建立时;修改在首次月结前留痕放行,月结后只读(月结在 ④,
|
||
终态门槛先行承担「修改不影响已结算月份」)
|
||
- 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人
|
||
与比例,响应以 hint 提示合计/余额,不做强制校验
|
||
- T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护
|
||
- **版本行 append-only**:编辑全量替换与驳回清空都把旧行置
|
||
``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由
|
||
``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照
|
||
(``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只
|
||
补齐「方案事实留存」,不改变核算口径
|
||
|
||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||
冗余固化,人离职后方案回显仍有名。uk 锁
|
||
(business_id, version_no, personnel_id),同一版本内同一参与人仅一行,
|
||
重复提交在服务层聚合;跨版本允许同一人再次出现。
|
||
"""
|
||
|
||
__tablename__ = "business_secondary_allocations"
|
||
__table_args__ = mysql_table_args(
|
||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||
"业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)",
|
||
UniqueConstraint(
|
||
"business_id",
|
||
"version_no",
|
||
"personnel_id",
|
||
name="uk_business_secondary_allocations_business_version_personnel",
|
||
),
|
||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||
# 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1
|
||
Index(
|
||
"idx_business_secondary_allocations_business_active", "business_id", "is_active"
|
||
),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||
)
|
||
business_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
comment="业务ID(businesses.id;业务删除随删)",
|
||
)
|
||
version_no: Mapped[int] = mapped_column(
|
||
Integer,
|
||
nullable=False,
|
||
server_default="1",
|
||
comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)",
|
||
)
|
||
personnel_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||
)
|
||
personnel_name: Mapped[str] = mapped_column(
|
||
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
|
||
)
|
||
ratio: Mapped[Decimal] = mapped_column(
|
||
Numeric(5, 2),
|
||
nullable=False,
|
||
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||
)
|
||
is_active: Mapped[int] = mapped_column(
|
||
TINYINT_TYPE,
|
||
nullable=False,
|
||
server_default="1",
|
||
comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)",
|
||
)
|
||
|
||
|
||
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||
|
||
__tablename__ = "businesses"
|
||
__table_args__ = mysql_table_args(
|
||
"业务表(业务编号自动生成;T+同步不覆盖业务状态)",
|
||
Index("idx_businesses_customer_id", "customer_id"),
|
||
Index("idx_businesses_owner_personnel_id", "owner_personnel_id"),
|
||
Index("idx_businesses_status", "status"),
|
||
Index("idx_businesses_business_date", "business_date"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||
)
|
||
business_no: Mapped[str] = mapped_column(
|
||
String(32),
|
||
nullable=False,
|
||
unique=True,
|
||
comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)",
|
||
)
|
||
customer_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("customers.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
comment="客户ID(customers.id)",
|
||
)
|
||
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
|
||
status: Mapped[int] = mapped_column(
|
||
TINYINT_TYPE,
|
||
nullable=False,
|
||
server_default="1",
|
||
comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)",
|
||
)
|
||
owner_personnel_id: Mapped[int | None] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||
comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)",
|
||
)
|
||
assistant_personnel_id: Mapped[int | None] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||
comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)",
|
||
)
|
||
is_special: Mapped[int] = mapped_column(
|
||
TINYINT_TYPE,
|
||
nullable=False,
|
||
server_default="0",
|
||
comment="特殊业务标记:1是 0否(公司参与等)",
|
||
)
|
||
special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明")
|
||
total_sale_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
server_default="0",
|
||
comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)",
|
||
)
|
||
total_cost_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
server_default="0",
|
||
comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)",
|
||
)
|
||
total_ship_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
server_default="0",
|
||
comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)",
|
||
)
|
||
total_received_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
server_default="0",
|
||
comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)",
|
||
)
|
||
total_freight_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
server_default="0",
|
||
comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)",
|
||
)
|
||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||
|
||
|
||
class BusinessItem(Base, TimestampMixin):
|
||
"""业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。
|
||
|
||
product_id 可空=自由录入产品(配合「数据提取」一键入产品库);
|
||
产品名称/规格冗余固化,防产品改名影响历史业务。
|
||
sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。
|
||
cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。
|
||
"""
|
||
|
||
__tablename__ = "business_items"
|
||
__table_args__ = mysql_table_args(
|
||
"业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)",
|
||
Index("idx_business_items_business_id", "business_id"),
|
||
Index("idx_business_items_product_id", "product_id"),
|
||
Index("idx_business_items_product_category", "product_category"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||
)
|
||
business_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("businesses.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
comment="业务ID(businesses.id)",
|
||
)
|
||
product_id: Mapped[int | None] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("products.id", ondelete="RESTRICT"),
|
||
comment="产品ID(products.id;NULL=自由录入,未入产品库)",
|
||
)
|
||
product_name: Mapped[str] = mapped_column(
|
||
String(128), nullable=False, comment="产品名称(引用产品时冗余固化)"
|
||
)
|
||
spec_model: Mapped[str] = mapped_column(
|
||
String(128),
|
||
nullable=False,
|
||
server_default="''",
|
||
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||
)
|
||
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||
product_category: Mapped[str] = mapped_column(
|
||
String(32),
|
||
nullable=False,
|
||
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||
)
|
||
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
|
||
sale_price: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)"
|
||
)
|
||
sale_amount: Mapped[Decimal] = mapped_column(
|
||
Numeric(14, 2),
|
||
nullable=False,
|
||
comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)",
|
||
)
|
||
cost_amount: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 2),
|
||
comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)",
|
||
)
|
||
guided_price: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)"
|
||
)
|
||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||
|
||
|
||
class BusinessStatusLog(Base):
|
||
"""业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。
|
||
|
||
不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID
|
||
(businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径:
|
||
被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。
|
||
首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。
|
||
"""
|
||
|
||
__tablename__ = "business_status_logs"
|
||
__table_args__ = mysql_table_args(
|
||
"业务状态变更记录表(append-only)",
|
||
Index("idx_business_status_logs_business_id", "business_id"),
|
||
Index("idx_business_status_logs_create_time", "create_time"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||
)
|
||
business_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
comment="业务ID(固化,无外键;删除保护由服务层承担)",
|
||
)
|
||
old_status: Mapped[int] = mapped_column(
|
||
TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)"
|
||
)
|
||
new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态")
|
||
change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)")
|
||
operator_id: Mapped[int | None] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("users.id", ondelete="SET NULL"),
|
||
comment="操作人ID(账号删除后置空)",
|
||
)
|
||
operator_name: Mapped[str] = mapped_column(
|
||
String(64), nullable=False, comment="操作人用户名(冗余固化)"
|
||
)
|
||
create_time: Mapped[datetime] = mapped_column(
|
||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||
)
|