diff --git a/backend/alembic/versions/8efc189b131f_add_business_data_tables.py b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py new file mode 100644 index 0000000..42c6ce1 --- /dev/null +++ b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py @@ -0,0 +1,696 @@ +"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(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") diff --git a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py index 2f4c3bf..7a81083 100644 --- a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py +++ b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py @@ -11,6 +11,7 @@ Revises: bfbda9c3191d Create Date: 2026-10-13 09:36:02.219248 """ + from collections.abc import Sequence from alembic import op @@ -18,8 +19,8 @@ import sqlalchemy as sa from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. -revision: str = 'e1133a7c670a' -down_revision: str | Sequence[str] | None = 'bfbda9c3191d' +revision: str = "e1133a7c670a" +down_revision: str | Sequence[str] | None = "bfbda9c3191d" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -27,141 +28,449 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: """应用迁移。""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('products', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('product_name', sa.String(length=128), nullable=False, comment='产品名称'), - sa.Column('spec_model', sa.String(length=128), server_default='', nullable=False, comment='规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)'), - sa.Column('unit', sa.String(length=16), nullable=True, comment='计量单位'), - sa.Column('stock_quantity', sa.Numeric(precision=14, scale=2), nullable=True, comment='数量(T+ 同步原样存储,语义待 IT 确认)'), - sa.Column('sale_price', sa.Numeric(precision=14, scale=4), nullable=True, comment='销售单价(元,T+ 同步)'), - sa.Column('product_category', sa.String(length=32), nullable=False, comment='产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)'), - sa.Column('status', mysql.TINYINT(), server_default='1', nullable=False, comment='状态:1启用 0停用'), - 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.PrimaryKeyConstraint('id', name=op.f('pk_products')), - sa.UniqueConstraint('product_name', 'spec_model', name='uk_products_name_spec'), - comment='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_table( + "products", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "stock_quantity", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="数量(T+ 同步原样存储,语义待 IT 确认)", + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="销售单价(元,T+ 同步)", + ), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0停用", + ), + 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.PrimaryKeyConstraint("id", name=op.f("pk_products")), + sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"), + comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", ) - op.create_index(op.f('idx_products_source_no'), 'products', ['source_no'], unique=False) - op.create_table('ownership_change_logs', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('owner_type', mysql.TINYINT(), nullable=False, comment='归属类型:1客户归属 2业务归属'), - sa.Column('customer_id', mysql.BIGINT(unsigned=True), nullable=True, comment='客户ID(固化,owner_type=1 时非空)'), - sa.Column('business_id', mysql.BIGINT(unsigned=True), nullable=True, comment='业务ID(固化,owner_type=2 时非空)'), - sa.Column('old_owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更前负责营销人员ID(固化)'), - sa.Column('new_owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更后负责营销人员ID(固化)'), - sa.Column('old_assistant_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更前协助营销人员ID(固化,仅业务归属使用)'), - sa.Column('new_assistant_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更后协助营销人员ID(固化,仅业务归属使用)'), - 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_ownership_change_logs_operator_id'), ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_ownership_change_logs')), - comment='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False) + op.create_table( + "ownership_change_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属" + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="客户ID(固化,owner_type=1 时非空)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="业务ID(固化,owner_type=2 时非空)", + ), + sa.Column( + "old_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前负责营销人员ID(固化)", + ), + sa.Column( + "new_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后负责营销人员ID(固化)", + ), + sa.Column( + "old_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前协助营销人员ID(固化,仅业务归属使用)", + ), + sa.Column( + "new_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后协助营销人员ID(固化,仅业务归属使用)", + ), + 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_ownership_change_logs_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")), + comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", ) - op.create_index(op.f('idx_ownership_change_logs_customer_id'), 'ownership_change_logs', ['customer_id'], unique=False) - op.create_index(op.f('idx_ownership_change_logs_business_id'), 'ownership_change_logs', ['business_id'], unique=False) - op.create_index(op.f('idx_ownership_change_logs_create_time'), 'ownership_change_logs', ['create_time'], unique=False) - op.create_table('personnel', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_name', sa.String(length=64), nullable=False, comment='姓名'), - sa.Column('business_role_id', mysql.BIGINT(unsigned=True), nullable=False, comment='岗位角色ID(决定计提公式模板;T+同步不覆盖)'), - sa.Column('manager_id', mysql.BIGINT(unsigned=True), nullable=True, comment='直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)'), - sa.Column('phone', sa.String(length=20), nullable=True, comment='联系电话'), - sa.Column('hire_date', sa.Date(), nullable=True, comment='入职日期'), - sa.Column('is_probation', mysql.TINYINT(), server_default='0', nullable=False, comment='是否试用期:1是 0否(试用期绩效照算不发放)'), - sa.Column('salary_standard', sa.Numeric(precision=12, scale=2), nullable=True, comment='月工资标准(元;T+同步不覆盖)'), - sa.Column('status', mysql.TINYINT(), server_default='1', nullable=False, comment='状态:1在职 0离职(离职人员不可再被业务/客户归属引用)'), - 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_role_id'], ['business_roles.id'], name=op.f('fk_personnel_business_role_id'), ondelete='RESTRICT'), - sa.ForeignKeyConstraint(['manager_id'], ['personnel.id'], name=op.f('fk_personnel_manager_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel')), - comment='营销人员表(T+同步不覆盖岗位与工资标准)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_customer_id"), + "ownership_change_logs", + ["customer_id"], + unique=False, ) - op.create_index(op.f('idx_personnel_business_role_id'), 'personnel', ['business_role_id'], unique=False) - op.create_index(op.f('idx_personnel_manager_id'), 'personnel', ['manager_id'], unique=False) - op.create_index(op.f('idx_personnel_source_no'), 'personnel', ['source_no'], unique=False) - op.create_table('customers', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('company_name', sa.String(length=128), nullable=False, comment='公司名称'), - sa.Column('owner_name', sa.String(length=64), nullable=False, comment='客户负责人姓名(客户公司负责人的人名,非系统人员)'), - sa.Column('contact_person', sa.String(length=64), nullable=True, comment='联系人'), - sa.Column('contact_phone', sa.String(length=20), nullable=True, comment='联系电话'), - sa.Column('address', sa.String(length=255), nullable=True, comment='地址'), - sa.Column('industry', sa.String(length=64), nullable=True, comment='所属行业(自由文本)'), - sa.Column('customer_type', sa.String(length=32), server_default='personal', nullable=False, comment='客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)'), - sa.Column('owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)'), - 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(['owner_personnel_id'], ['personnel.id'], name=op.f('fk_customers_owner_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_customers')), - sa.UniqueConstraint('company_name', 'owner_name', name='uk_customers_company_owner'), - comment='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_business_id"), + "ownership_change_logs", + ["business_id"], + unique=False, ) - op.create_index(op.f('idx_customers_customer_type'), 'customers', ['customer_type'], unique=False) - op.create_index(op.f('idx_customers_owner_personnel_id'), 'customers', ['owner_personnel_id'], unique=False) - op.create_index(op.f('idx_customers_source_no'), 'customers', ['source_no'], unique=False) - op.create_table('personnel_plans', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_id', mysql.BIGINT(unsigned=True), nullable=False, comment='人员ID(personnel.id)'), - sa.Column('plan_period', sa.String(length=32), nullable=False, comment='计划期间(如 2026Q1、2026-05)'), - sa.Column('plan_content', sa.String(length=500), nullable=False, comment='计划内容'), - sa.Column('target_desc', sa.String(length=255), nullable=True, comment='指标说明(文字描述)'), - sa.Column('source_no', sa.String(length=64), nullable=True, comment='T+ 原始条目号(预留)'), - 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(['personnel_id'], ['personnel.id'], name=op.f('fk_personnel_plans_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel_plans')), - comment='市场拓展计划表', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_create_time"), + "ownership_change_logs", + ["create_time"], + unique=False, ) - op.create_index(op.f('idx_personnel_plans_personnel_id'), 'personnel_plans', ['personnel_id'], unique=False) - op.create_table('personnel_regions', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_id', mysql.BIGINT(unsigned=True), nullable=False, comment='人员ID(personnel.id)'), - sa.Column('region_name', sa.String(length=128), 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.ForeignKeyConstraint(['personnel_id'], ['personnel.id'], name=op.f('fk_personnel_regions_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel_regions')), - comment='产品辐射区域表', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_table( + "personnel", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"), + sa.Column( + "business_role_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)", + ), + sa.Column( + "manager_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)", + ), + sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"), + sa.Column( + "is_probation", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否试用期:1是 0否(试用期绩效照算不发放)", + ), + sa.Column( + "salary_standard", + sa.Numeric(precision=12, scale=2), + nullable=True, + comment="月工资标准(元;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)", + ), + 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_role_id"], + ["business_roles.id"], + name=op.f("fk_personnel_business_role_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["manager_id"], + ["personnel.id"], + name=op.f("fk_personnel_manager_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")), + comment="营销人员表(T+同步不覆盖岗位与工资标准)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False + ) + op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False) + op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False) + op.create_table( + "customers", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"), + sa.Column( + "owner_name", + sa.String(length=64), + nullable=False, + comment="客户负责人姓名(客户公司负责人的人名,非系统人员)", + ), + sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"), + sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("address", sa.String(length=255), nullable=True, comment="地址"), + sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"), + sa.Column( + "customer_type", + sa.String(length=32), + server_default="personal", + nullable=False, + comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", + ), + sa.Column( + "owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", + ), + 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( + ["owner_personnel_id"], + ["personnel.id"], + name=op.f("fk_customers_owner_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")), + sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"), + comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False + ) + op.create_index( + op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False + ) + op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False) + op.create_table( + "personnel_plans", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "plan_period", + sa.String(length=32), + nullable=False, + comment="计划期间(如 2026Q1、2026-05)", + ), + sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"), + sa.Column( + "target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)" + ), + sa.Column( + "source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)" + ), + 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( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_plans_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")), + comment="市场拓展计划表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False + ) + op.create_table( + "personnel_regions", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "region_name", + sa.String(length=128), + 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.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_regions_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")), + comment="产品辐射区域表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_regions_personnel_id"), + "personnel_regions", + ["personnel_id"], + unique=False, ) - op.create_index(op.f('idx_personnel_regions_personnel_id'), 'personnel_regions', ['personnel_id'], unique=False) # ### end Alembic commands ### @@ -173,9 +482,9 @@ def downgrade() -> None: 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 """ - op.drop_table('personnel_regions') - op.drop_table('personnel_plans') - op.drop_table('customers') - op.drop_table('personnel') - op.drop_table('ownership_change_logs') - op.drop_table('products') + op.drop_table("personnel_regions") + op.drop_table("personnel_plans") + op.drop_table("customers") + op.drop_table("personnel") + op.drop_table("ownership_change_logs") + op.drop_table("products") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index a8c33f3..8566e60 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -66,6 +66,16 @@ MODULE_PARAM = "计提参数" MODULE_PERSONNEL = "人员管理" MODULE_PRODUCT = "产品管理" MODULE_CUSTOMER = "客户管理" +MODULE_BUSINESS = "业务管理" +MODULE_SHIPMENT = "发货管理" +MODULE_RECEIPT = "回款管理" +MODULE_RATING = "客户评级" + +# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== +# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 +NO_PREFIX_BUSINESS = "YW" +NO_PREFIX_SHIPMENT = "SH" +NO_PREFIX_RECEIPT = "HK" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index be20a84..c8b1493 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ """ from app.models.base import Base, TimestampMixin +from app.models.business import Business, BusinessItem, BusinessStatusLog from app.models.business_role import BusinessRole from app.models.customer import Customer, OwnershipChangeLog from app.models.login_log import LoginLog @@ -17,13 +18,20 @@ from app.models.operation_log import OperationLog from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.product import Product +from app.models.rating import CustomerRatingGrade +from app.models.receipt import Receipt from app.models.role import Permission, Role, RolePermission +from app.models.shipment import Shipment, ShipmentItem from app.models.user import User, UserRole __all__ = [ "Base", + "Business", + "BusinessItem", "BusinessRole", + "BusinessStatusLog", "Customer", + "CustomerRatingGrade", "LoginLog", "OperationLog", "OwnershipChangeLog", @@ -35,8 +43,11 @@ __all__ = [ "PersonnelPlan", "PersonnelRegion", "Product", + "Receipt", "Role", "RolePermission", + "Shipment", + "ShipmentItem", "TimestampMixin", "User", "UserRole", diff --git a/backend/app/models/business.py b/backend/app/models/business.py new file mode 100644 index 0000000..518b254 --- /dev/null +++ b/backend/app/models/business.py @@ -0,0 +1,218 @@ +"""业务数据模型:业务单、产品明细与状态流转留痕。 + +- 汇总冗余列(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 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="创建时间" + ) diff --git a/backend/app/models/rating.py b/backend/app/models/rating.py new file mode 100644 index 0000000..f0310c3 --- /dev/null +++ b/backend/app/models/rating.py @@ -0,0 +1,46 @@ +"""客户评级等级配置模型(需求 2.2.5)。 + +评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 +不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 +第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 +「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 +""" + +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class CustomerRatingGrade(Base, TimestampMixin): + """客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。 + + 种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 / + D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。 + """ + + __tablename__ = "customer_rating_grades" + __table_args__ = mysql_table_args( + "客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)" + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + grade_name: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)" + ) + min_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), + nullable=False, + comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", + ) + max_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, comment="总分上限(展示用)" + ) + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="说明") diff --git a/backend/app/models/receipt.py b/backend/app/models/receipt.py new file mode 100644 index 0000000..5930083 --- /dev/null +++ b/backend/app/models/receipt.py @@ -0,0 +1,83 @@ +"""回款模型:回款记录与批次追溯。 + +- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定; + 必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达 + 跨表同属关系);指定后回款天数方可由 formula.payment_days 派生 +- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次, + 台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后 + 由核算服务写入,M2 仅维护 0(未核算) +- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数) +- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 绩效核算状态(receipts.settlement_status) +SETTLEMENT_PENDING = 0 +SETTLEMENT_DONE = 1 + + +class Receipt(Base, TimestampMixin, TPlusSyncMixin): + """回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。""" + + __tablename__ = "receipts" + __table_args__ = mysql_table_args( + "回款表(手动指定发货批次;已核算回款禁改禁删)", + Index("idx_receipts_business_id", "business_id"), + Index("idx_receipts_receipt_date", "receipt_date"), + Index("idx_receipts_settlement_status", "settlement_status"), + Index("idx_receipts_shipment_id", "shipment_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + receipt_batch_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id)", + ) + shipment_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", + ) + receipt_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)" + ) + is_barter: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", + ) + settlement_status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/shipment.py b/backend/app/models/shipment.py new file mode 100644 index 0000000..89c466c --- /dev/null +++ b/backend/app/models/shipment.py @@ -0,0 +1,115 @@ +"""发货模型:发货单与发货产品明细。 + +- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天), + 回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库 +- 运费仅记录展示,不计入毛利润口径(对照表 #2) +- total_amount 为明细行汇总冗余,服务层事务内维护 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + + +class Shipment(Base, TimestampMixin, TPlusSyncMixin): + """发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。""" + + __tablename__ = "shipments" + __table_args__ = mysql_table_args( + "发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", + Index("idx_shipments_business_id", "business_id"), + Index("idx_shipments_ship_date", "ship_date"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id;终态业务禁止新发货)", + ) + ship_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)" + ) + freight_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", + ) + total_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class ShipmentItem(Base, TimestampMixin): + """发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。 + + 价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补, + amount 由服务层按 数量×单价 计算固化。 + """ + + __tablename__ = "shipment_items" + __table_args__ = mysql_table_args( + "发货产品明细表", + Index("idx_shipment_items_shipment_id", "shipment_id"), + Index("idx_shipment_items_product_id", "product_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + nullable=False, + comment="发货单ID(shipments.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="计量单位") + quantity: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="发货数量(>0)" + ) + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)", + ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index ec0227b..6b4b7fd 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -29,6 +29,13 @@ EXPECTED_TABLES = { "products", "customers", "ownership_change_logs", + "businesses", + "business_items", + "business_status_logs", + "shipments", + "shipment_items", + "receipts", + "customer_rating_grades", } # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 468e351..885769f 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -459,6 +459,210 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t (18, 15, '', '2026-01-01', '18000', 1), (19, 16, '', '2026-01-01', '3000', 1); +-- --------------------------------------------------------------------------- +-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) +-- --------------------------------------------------------------------------- +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount DECIMAL(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount DECIMAL(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount DECIMAL(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount DECIMAL(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount DECIMAL(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT uk_businesses_business_no UNIQUE (business_no), + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +-- --------------------------------------------------------------------------- +-- 业务产品明细表(V5 迁移;行级类别为折算锚点,指导价 T+ 同步不覆盖) +-- --------------------------------------------------------------------------- +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity DECIMAL(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount DECIMAL(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount DECIMAL(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price DECIMAL(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +-- --------------------------------------------------------------------------- +-- 业务状态变更记录表(V5 迁移,append-only;创建时写首条 old=new=1) +-- --------------------------------------------------------------------------- +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +-- --------------------------------------------------------------------------- +-- 发货单表(V5 迁移;发货日为回款天数基准日,发货日首日计第 1 天) +-- --------------------------------------------------------------------------- +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount DECIMAL(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount DECIMAL(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +-- --------------------------------------------------------------------------- +-- 发货产品明细表(V5 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity DECIMAL(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount DECIMAL(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +-- --------------------------------------------------------------------------- +-- 回款表(V5 迁移;手动指定发货批次,已核算回款禁改禁删) +-- --------------------------------------------------------------------------- +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount DECIMAL(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +-- --------------------------------------------------------------------------- +-- 客户评级等级配置表(V5 迁移;判定按 sort_order 升序首个 min_score≤总分) +-- --------------------------------------------------------------------------- +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score DECIMAL(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score DECIMAL(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INT NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 字典数据:评级等级(与 V5 迁移种子一致;评分项固定 5 项×20 分为前端常量,对照表 #25) +-- --------------------------------------------------------------------------- +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES +('A', 90, 100, 1, '优秀'), +('B', 75, 89, 2, '良好'), +('C', 60, 74, 3, '合格'), +('D', 0, 59, 4, '待改进'); + -- --------------------------------------------------------------------------- -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- @@ -467,4 +671,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('e1133a7c670a'); +INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f'); diff --git a/db/migration/V5__add_business_data_tables.sql b/db/migration/V5__add_business_data_tables.sql new file mode 100644 index 0000000..5fe52f1 --- /dev/null +++ b/db/migration/V5__add_business_data_tables.sql @@ -0,0 +1,186 @@ +-- Running upgrade e1133a7c670a -> 8efc189b131f + +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score NUMERIC(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score NUMERIC(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INTEGER NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount NUMERIC(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount NUMERIC(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount NUMERIC(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount NUMERIC(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount NUMERIC(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT uk_businesses_business_no UNIQUE (business_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity NUMERIC(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount NUMERIC(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount NUMERIC(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price NUMERIC(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount NUMERIC(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount NUMERIC(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount NUMERIC(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity NUMERIC(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount NUMERIC(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('A', 90, 100, 1, '优秀'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('B', 75, 89, 2, '良好'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('C', 60, 74, 3, '合格'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('D', 0, 59, 4, '待改进'); + +UPDATE alembic_version SET version_num='8efc189b131f' WHERE alembic_version.version_num = 'e1133a7c670a'; + diff --git a/db/queries/业务数据.sql b/db/queries/业务数据.sql new file mode 100644 index 0000000..306402a --- /dev/null +++ b/db/queries/业务数据.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- 业务数据查询留档(M2 业务数据管理) +-- +-- 设计要点: +-- - businesses 的 total_* 五列为服务层维护的汇总冗余,查询直接读冗余列 +-- 而非实时 JOIN 聚合;本文件同时给出「对账用」的实时聚合口径 +-- - 回款天数 = formula.payment_days(ship_date, receipt_date),即 +-- DATEDIFF(receipt_date, ship_date) + 1(发货日首日计第 1 天,对照表 #1), +-- 应用层以纯函数为准,SQL 版仅供人工对账 +-- - 毛利润率/折算毛利润额不落库:视图按行实时计算(阈值取 M1 参数快照) +-- ============================================================================ + +-- 1. 业务台账(列表页;冗余列直读) +SELECT b.id, + b.business_no, + c.company_name, + c.customer_type, + b.status, + b.business_date, + p.personnel_name AS owner_name, + pa.personnel_name AS assistant_name, + b.total_sale_amount, + b.total_cost_amount, + b.total_ship_amount, + b.total_received_amount, + b.total_freight_amount +FROM businesses b +JOIN customers c ON c.id = b.customer_id +LEFT JOIN personnel p ON p.id = b.owner_personnel_id +LEFT JOIN personnel pa ON pa.id = b.assistant_personnel_id +WHERE (:keyword IS NULL OR b.business_no LIKE CONCAT('%', :keyword, '%') + OR c.company_name LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR b.status = :status) + AND (:customer_id IS NULL OR b.customer_id = :customer_id) + AND (:owner_personnel_id IS NULL OR b.owner_personnel_id = :owner_personnel_id) +ORDER BY b.id DESC +LIMIT :limit OFFSET :offset; + +-- 2. 汇总冗余对账(五列与明细/发货/回款实时聚合的一致性校验,T11 集成用例同口径) +SELECT b.id, + b.total_sale_amount, + COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) AS live_sale, + b.total_ship_amount, + COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) AS live_ship, + b.total_received_amount, + COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) AS live_received, + b.total_freight_amount, + COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0) AS live_freight +FROM businesses b +WHERE b.total_sale_amount <> COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) + OR b.total_ship_amount <> COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) + OR b.total_received_amount <> COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) + OR b.total_freight_amount <> COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0); + +-- 3. 未指定发货批次的回款(M3 核算前置校验名单;列表页筛选项) +SELECT r.id, r.receipt_batch_no, b.business_no, r.receipt_date, r.amount, r.is_barter +FROM receipts r +JOIN businesses b ON b.id = r.business_id +WHERE r.shipment_id IS NULL + AND r.settlement_status = 0 +ORDER BY r.receipt_date; + +-- 4. 回款天数对账视图(SQL 版仅供人工核对;应用层以 formula.payment_days 为准) +SELECT r.id, + r.receipt_batch_no, + b.business_no, + s.shipment_no, + s.ship_date, + r.receipt_date, + DATEDIFF(r.receipt_date, s.ship_date) + 1 AS payment_days +FROM receipts r +JOIN businesses b ON b.id = r.business_id +LEFT JOIN shipments s ON s.id = r.shipment_id +WHERE r.shipment_id IS NOT NULL +ORDER BY r.receipt_date DESC; + +-- 5. 按产品类别的毛利润汇总(毛利润视图的类别汇总口径;阈值需按 M1 参数快照逐日匹配, +-- 此处仅聚合源数据,折算在应用层以 formula.converted_profit 计算) +SELECT i.product_category, + SUM(i.sale_amount) AS sale_total, + SUM(i.cost_amount) AS cost_total, + SUM(i.sale_amount - COALESCE(i.cost_amount, 0)) AS profit_total +FROM business_items i +WHERE i.business_id = :business_id +GROUP BY i.product_category; + +-- 6. 业务状态流转时间线(详情页;创建首条 old=new=1) +SELECT l.old_status, l.new_status, l.change_reason, l.operator_name, l.create_time +FROM business_status_logs l +WHERE l.business_id = :business_id +ORDER BY l.create_time, l.id; + +-- 7. 发货单与明细(列表 + 行展开) +SELECT s.id, s.shipment_no, b.business_no, s.ship_date, s.freight_amount, s.total_amount +FROM shipments s +JOIN businesses b ON b.id = s.business_id +WHERE (:business_id IS NULL OR s.business_id = :business_id) + AND (:ship_date_from IS NULL OR s.ship_date >= :ship_date_from) + AND (:ship_date_to IS NULL OR s.ship_date <= :ship_date_to) +ORDER BY s.id DESC +LIMIT :limit OFFSET :offset; + +SELECT si.product_name, si.spec_model, si.unit, si.quantity, si.sale_price, si.amount +FROM shipment_items si +WHERE si.shipment_id = :shipment_id +ORDER BY si.id; + +-- 8. 评级等级判定(前端按 sort_order 升序首个 min_score ≤ 总分取等级;SQL 版供后端复用) +SELECT grade_name, min_score, max_score +FROM customer_rating_grades +WHERE min_score <= :total_score +ORDER BY sort_order +LIMIT 1;