- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
276 lines
13 KiB
Python
276 lines
13 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, 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="创建时间"
|
||
)
|