feat(business): 新增业务数据七表及 V5 迁移(业务/明细/状态留痕/发货/回款/评级配置)

- businesses 五项汇总冗余列服务层事务内维护;毛利润不落库(派生值,M3 台账固化)
- 行级 product_category 折算锚点(#14);回款 settlement_status 预置为 M3 防重锚点
- 评级等级种子四条(迁移+init-dev 双写);编号前缀常量 YW/SH/HK
- 双轨 SQL 同步(V4/V5 重导,schema_sync+test_models 164 例全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-13 11:22:34 +08:00
1 parent ca88ee7a0b
commit 20fcf06f51
12 files changed
+2138 -138

No files matched your search

@@ -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")
@@ -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")
+10
View File
@@ -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 = "修改"
+11
View File
@@ -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",
+218
View File
@@ -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="创建时间"
)
+46
View File
@@ -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="说明")
+83
View File
@@ -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="备注")
+115
View File
@@ -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="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
)
+7
View File
@@ -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 结尾」检查约束