"""业务数据模型:业务单、产品明细与状态流转留痕。 - 汇总冗余列(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, 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 回馈):参与人全为系统内人工维护 - **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按 核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表 personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录 不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name 冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id), 同一合同的同一参与人仅一行,重复提交在服务层聚合。 """ __tablename__ = "business_secondary_allocations" __table_args__ = mysql_table_args( # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, # 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例) "业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", UniqueConstraint( "business_id", "personnel_id", name="uk_business_secondary_allocations_business_personnel", ), Index("idx_business_secondary_allocations_business_id", "business_id"), Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), ) id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" ) business_id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, comment="业务ID(businesses.id;业务删除随删)", ) personnel_id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, ForeignKey("personnel.id", ondelete="RESTRICT"), nullable=False, comment="参与业务员ID(personnel.id;被方案引用禁删)", ) personnel_name: Mapped[str] = mapped_column( String(64), nullable=False, comment="参与业务员姓名(冗余固化,离职后回显有名)" ) ratio: Mapped[Decimal] = mapped_column( Numeric(5, 2), nullable=False, comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)", ) class Business(Base, TimestampMixin, TPlusSyncMixin): """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" __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="创建时间" )