"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2) - businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列 由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点) - business_items:行级 product_category 为折算锚点(对照表 #14),指导价 人工录入、T+ 同步不覆盖 - business_status_logs:append-only,创建时写首条(old=new=1) - shipments/shipment_items:发货日为回款天数基准日(对照表 #1) - receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点 - customer_rating_grades:等级映射可配置,种子四条随迁移初始化 Revision ID: 8efc189b131f Revises: e1133a7c670a Create Date: 2026-10-13 11:14:17.545320 """ from collections.abc import Sequence from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. revision: str = "8efc189b131f" down_revision: str | Sequence[str] | None = "e1133a7c670a" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None # 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring) _GRADE_SEED = [ {"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"}, {"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"}, {"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"}, {"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"}, ] def upgrade() -> None: """应用迁移。""" # ### commands auto generated by Alembic - please adjust! ### op.create_table( "customer_rating_grades", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)" ), sa.Column( "min_score", sa.Numeric(precision=5, scale=1), nullable=False, comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", ), sa.Column( "max_score", sa.Numeric(precision=5, scale=1), nullable=False, comment="总分上限(展示用)", ), sa.Column( "sort_order", sa.Integer(), server_default="0", nullable=False, 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.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")), sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")), comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_table( "business_status_logs", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "business_id", mysql.BIGINT(unsigned=True), nullable=False, comment="业务ID(固化,无外键;删除保护由服务层承担)", ), sa.Column( "old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)" ), sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"), sa.Column( "change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)" ), sa.Column( "operator_id", mysql.BIGINT(unsigned=True), nullable=True, comment="操作人ID(账号删除后置空)", ), sa.Column( "operator_name", sa.String(length=64), nullable=False, comment="操作人用户名(冗余固化)", ), sa.Column( "create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间", ), sa.ForeignKeyConstraint( ["operator_id"], ["users.id"], name=op.f("fk_business_status_logs_operator_id"), ondelete="SET NULL", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")), comment="业务状态变更记录表(append-only)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index( "idx_business_status_logs_business_id", "business_status_logs", ["business_id"], unique=False, ) op.create_index( "idx_business_status_logs_create_time", "business_status_logs", ["create_time"], unique=False, ) op.create_table( "businesses", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "business_no", sa.String(length=32), nullable=False, comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)", ), sa.Column( "customer_id", mysql.BIGINT(unsigned=True), nullable=False, comment="客户ID(customers.id)", ), sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"), sa.Column( "status", mysql.TINYINT(), server_default="1", nullable=False, comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)", ), sa.Column( "owner_personnel_id", mysql.BIGINT(unsigned=True), nullable=True, comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)", ), sa.Column( "assistant_personnel_id", mysql.BIGINT(unsigned=True), nullable=True, comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)", ), sa.Column( "is_special", mysql.TINYINT(), server_default="0", nullable=False, comment="特殊业务标记:1是 0否(公司参与等)", ), sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"), sa.Column( "total_sale_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=True, comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)", ), sa.Column( "total_cost_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=True, comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)", ), sa.Column( "total_ship_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=True, comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)", ), sa.Column( "total_received_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=True, comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)", ), sa.Column( "total_freight_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=True, comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)", ), 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.Column( "source_no", sa.String(length=64), nullable=True, comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", ), sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), sa.Column( "sync_batch_no", sa.String(length=32), nullable=True, comment="同步批次号(同步模块写入)", ), sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), sa.ForeignKeyConstraint( ["assistant_personnel_id"], ["personnel.id"], name=op.f("fk_businesses_assistant_personnel_id"), ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["customer_id"], ["customers.id"], name=op.f("fk_businesses_customer_id"), ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["owner_personnel_id"], ["personnel.id"], name=op.f("fk_businesses_owner_personnel_id"), ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")), sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")), comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False) op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False) op.create_index( "idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False ) op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False) op.create_index("idx_businesses_status", "businesses", ["status"], unique=False) op.create_table( "business_items", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "business_id", mysql.BIGINT(unsigned=True), nullable=False, comment="业务ID(businesses.id)", ), sa.Column( "product_id", mysql.BIGINT(unsigned=True), nullable=True, comment="产品ID(products.id;NULL=自由录入,未入产品库)", ), sa.Column( "product_name", sa.String(length=128), nullable=False, comment="产品名称(引用产品时冗余固化)", ), sa.Column( "spec_model", sa.String(length=128), server_default="", nullable=False, comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", ), sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), sa.Column( "product_category", sa.String(length=32), nullable=False, comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", ), sa.Column( "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)" ), sa.Column( "sale_price", sa.Numeric(precision=14, scale=4), nullable=True, comment="销售单价(元;T+ 同步或人工)", ), sa.Column( "sale_amount", sa.Numeric(precision=14, scale=2), nullable=False, comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)", ), sa.Column( "cost_amount", sa.Numeric(precision=14, scale=2), nullable=True, comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)", ), sa.Column( "guided_price", sa.Numeric(precision=14, scale=4), nullable=True, comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)", ), 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( ["business_id"], ["businesses.id"], name=op.f("fk_business_items_business_id"), ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["product_id"], ["products.id"], name=op.f("fk_business_items_product_id"), ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")), comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index( "idx_business_items_business_id", "business_items", ["business_id"], unique=False ) op.create_index( "idx_business_items_product_category", "business_items", ["product_category"], unique=False ) op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False) op.create_table( "shipments", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "shipment_no", sa.String(length=32), nullable=False, comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", ), sa.Column( "business_id", mysql.BIGINT(unsigned=True), nullable=False, comment="业务ID(businesses.id;终态业务禁止新发货)", ), sa.Column( "ship_date", sa.Date(), nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)", ), sa.Column( "freight_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=False, comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", ), sa.Column( "total_amount", sa.Numeric(precision=14, scale=2), server_default="0", nullable=False, 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.Column( "source_no", sa.String(length=64), nullable=True, comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", ), sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), sa.Column( "sync_batch_no", sa.String(length=32), nullable=True, comment="同步批次号(同步模块写入)", ), sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), sa.ForeignKeyConstraint( ["business_id"], ["businesses.id"], name=op.f("fk_shipments_business_id"), ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")), sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")), comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False) op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False) op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False) op.create_table( "receipts", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "receipt_batch_no", sa.String(length=32), nullable=False, comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", ), sa.Column( "business_id", mysql.BIGINT(unsigned=True), nullable=False, comment="业务ID(businesses.id)", ), sa.Column( "shipment_id", mysql.BIGINT(unsigned=True), nullable=True, comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", ), sa.Column( "receipt_date", sa.Date(), nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)", ), sa.Column( "amount", sa.Numeric(precision=14, scale=2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)", ), sa.Column( "is_barter", mysql.TINYINT(), server_default="0", nullable=False, comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", ), sa.Column( "settlement_status", mysql.TINYINT(), server_default="0", nullable=False, comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", ), 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.Column( "source_no", sa.String(length=64), nullable=True, comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", ), sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), sa.Column( "sync_batch_no", sa.String(length=32), nullable=True, comment="同步批次号(同步模块写入)", ), sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), sa.ForeignKeyConstraint( ["business_id"], ["businesses.id"], name=op.f("fk_receipts_business_id"), ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["shipment_id"], ["shipments.id"], name=op.f("fk_receipts_shipment_id"), ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")), sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")), comment="回款表(手动指定发货批次;已核算回款禁改禁删)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False) op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False) op.create_index( "idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False ) op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False) op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False) op.create_table( "shipment_items", sa.Column( "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" ), sa.Column( "shipment_id", mysql.BIGINT(unsigned=True), nullable=False, comment="发货单ID(shipments.id)", ), sa.Column( "product_id", mysql.BIGINT(unsigned=True), nullable=True, comment="产品ID(products.id;NULL=自由录入)", ), sa.Column( "product_name", sa.String(length=128), nullable=False, comment="产品名称(引用产品时冗余固化)", ), sa.Column( "spec_model", sa.String(length=128), server_default="", nullable=False, comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", ), sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), sa.Column( "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)" ), sa.Column( "sale_price", sa.Numeric(precision=14, scale=4), nullable=True, comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)", ), sa.Column( "amount", sa.Numeric(precision=14, scale=2), 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( ["product_id"], ["products.id"], name=op.f("fk_shipment_items_product_id"), ondelete="RESTRICT", ), sa.ForeignKeyConstraint( ["shipment_id"], ["shipments.id"], name=op.f("fk_shipment_items_shipment_id"), ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")), comment="发货产品明细表", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", mysql_engine="InnoDB", ) op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False) op.create_index( "idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False ) # ### end Alembic commands ### # 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入) op.bulk_insert( sa.table( "customer_rating_grades", sa.column("grade_name", sa.String), sa.column("min_score", sa.Numeric), sa.column("max_score", sa.Numeric), sa.column("sort_order", sa.Integer), sa.column("remark", sa.String), ), _GRADE_SEED, ) def downgrade() -> None: """回滚迁移。 **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 """ # 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键); # 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553 op.drop_table("shipment_items") op.drop_table("receipts") op.drop_table("shipments") op.drop_table("business_items") op.drop_table("businesses") op.drop_table("business_status_logs") op.drop_table("customer_rating_grades")