- 新增 system_settings(公司信息单行)/ business_secondary_allocations(二次 分配明细,比例不强制合计满额,分摊计算待澄清另批)/ approval_orders+ approval_steps(三级审批状态机,开关关闭自动通过留痕) - roles 种子新增营销后勤/财务/领导三职能角色;内置保护改 BUILTIN_ROLE_CODES 白名单(roles 表无 is_builtin 列,不加列) - PARAM_REGISTRY 首例 BOOL:两个审批开关(16→18 项,种子双写) - 双轨同步:V6 导出 SQL + init-dev.sql(表/索引/种子/版本号 79bcc5aa98d4) - 测试同步:EXPECTED_TABLES 四新表、角色种子断言 5 角色、目录断言 18 项 八分组、BOOL 校验边界;迁移 downgrade 修 1553(只保留 drop_table)并补 行级种子反向清理 Co-Authored-By: Claude Code <noreply@anthropic.com>
271 lines
12 KiB
Python
271 lines
12 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, Numeric, String, 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 回馈):参与人全为系统内人工维护
|
||
- **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按
|
||
核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表
|
||
|
||
personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录
|
||
不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name
|
||
冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id),
|
||
同一合同的同一参与人仅一行,重复提交在服务层聚合。
|
||
"""
|
||
|
||
__tablename__ = "business_secondary_allocations"
|
||
__table_args__ = mysql_table_args(
|
||
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
|
||
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
|
||
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
|
||
Index("idx_business_secondary_allocations_business_id", "business_id"),
|
||
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||
)
|
||
business_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("businesses.id", ondelete="CASCADE"),
|
||
nullable=False,
|
||
comment="业务ID(businesses.id;业务删除随删)",
|
||
)
|
||
personnel_id: Mapped[int] = mapped_column(
|
||
BIGINT_UNSIGNED,
|
||
ForeignKey("personnel.id", ondelete="RESTRICT"),
|
||
nullable=False,
|
||
comment="参与业务员ID(personnel.id;被方案引用禁删)",
|
||
)
|
||
personnel_name: Mapped[str] = mapped_column(
|
||
String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)"
|
||
)
|
||
ratio: Mapped[Decimal] = mapped_column(
|
||
Numeric(5, 2),
|
||
nullable=False,
|
||
comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)",
|
||
)
|
||
|
||
|
||
class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||
"""业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。"""
|
||
|
||
__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="创建时间"
|
||
)
|