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:
liuwenxin 2026-10-13 11:22:34 +08:00
parent ca88ee7a0b
commit 20fcf06f51
12 changed files with 2138 additions and 138 deletions

View File

@ -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")

View File

@ -11,6 +11,7 @@ Revises: bfbda9c3191d
Create Date: 2026-10-13 09:36:02.219248 Create Date: 2026-10-13 09:36:02.219248
""" """
from collections.abc import Sequence from collections.abc import Sequence
from alembic import op from alembic import op
@ -18,8 +19,8 @@ import sqlalchemy as sa
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'e1133a7c670a' revision: str = "e1133a7c670a"
down_revision: str | Sequence[str] | None = 'bfbda9c3191d' down_revision: str | Sequence[str] | None = "bfbda9c3191d"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: 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: def upgrade() -> None:
"""应用迁移。""" """应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('products', op.create_table(
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), "products",
sa.Column('product_name', sa.String(length=128), nullable=False, comment='产品名称'), sa.Column(
sa.Column('spec_model', sa.String(length=128), server_default='', nullable=False, comment='规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)'), "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
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("product_name", sa.String(length=128), nullable=False, comment="产品名称"),
sa.Column('sale_price', sa.Numeric(precision=14, scale=4), nullable=True, comment='销售单价(元,T+ 同步)'), sa.Column(
sa.Column('product_category', sa.String(length=32), nullable=False, comment='产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)'), "spec_model",
sa.Column('status', mysql.TINYINT(), server_default='1', nullable=False, comment='状态:1启用 0停用'), sa.String(length=128),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), server_default="",
sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), nullable=False,
sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
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("unit", sa.String(length=16), nullable=True, comment="计量单位"),
sa.Column('sync_batch_no', sa.String(length=32), nullable=True, comment='同步批次号(同步模块写入)'), sa.Column(
sa.Column('synced_at', sa.DateTime(), nullable=True, comment='最近同步时间'), "stock_quantity",
sa.PrimaryKeyConstraint('id', name=op.f('pk_products')), sa.Numeric(precision=14, scale=2),
sa.UniqueConstraint('product_name', 'spec_model', name='uk_products_name_spec'), nullable=True,
comment='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)', comment="数量(T+ 同步原样存储,语义待 IT 确认)",
mysql_charset='utf8mb4', ),
mysql_collate='utf8mb4_general_ci', sa.Column(
mysql_engine='InnoDB' "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_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False)
op.create_table('ownership_change_logs', op.create_table(
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), "ownership_change_logs",
sa.Column('owner_type', mysql.TINYINT(), nullable=False, comment='归属类型:1客户归属 2业务归属'), sa.Column(
sa.Column('customer_id', mysql.BIGINT(unsigned=True), nullable=True, comment='客户ID(固化,owner_type=1 时非空)'), "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
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(
sa.Column('new_owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更后负责营销人员ID(固化)'), "owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属"
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(
sa.Column('change_reason', sa.String(length=255), nullable=True, comment='变更原因'), "customer_id",
sa.Column('operator_id', mysql.BIGINT(unsigned=True), nullable=True, comment='操作人ID(账号删除后置空)'), mysql.BIGINT(unsigned=True),
sa.Column('operator_name', sa.String(length=64), nullable=False, comment='操作人用户名(冗余固化)'), nullable=True,
sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), comment="客户ID(固化,owner_type=1 时非空)",
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')), sa.Column(
comment='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)', "business_id",
mysql_charset='utf8mb4', mysql.BIGINT(unsigned=True),
mysql_collate='utf8mb4_general_ci', nullable=True,
mysql_engine='InnoDB' 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.create_index(op.f('idx_ownership_change_logs_business_id'), 'ownership_change_logs', ['business_id'], unique=False) op.f("idx_ownership_change_logs_customer_id"),
op.create_index(op.f('idx_ownership_change_logs_create_time'), 'ownership_change_logs', ['create_time'], unique=False) "ownership_change_logs",
op.create_table('personnel', ["customer_id"],
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), unique=False,
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.create_index(op.f('idx_personnel_manager_id'), 'personnel', ['manager_id'], unique=False) op.f("idx_ownership_change_logs_business_id"),
op.create_index(op.f('idx_personnel_source_no'), 'personnel', ['source_no'], unique=False) "ownership_change_logs",
op.create_table('customers', ["business_id"],
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), unique=False,
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.create_index(op.f('idx_customers_owner_personnel_id'), 'customers', ['owner_personnel_id'], unique=False) op.f("idx_ownership_change_logs_create_time"),
op.create_index(op.f('idx_customers_source_no'), 'customers', ['source_no'], unique=False) "ownership_change_logs",
op.create_table('personnel_plans', ["create_time"],
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), unique=False,
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(
op.create_table('personnel_regions', "personnel",
sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), sa.Column(
sa.Column('personnel_id', mysql.BIGINT(unsigned=True), nullable=False, comment='人员ID(personnel.id)'), "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
sa.Column('region_name', sa.String(length=128), nullable=False, comment='区域名称(产品辐射地理范围)'), ),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), sa.Column(
sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), "business_role_id",
sa.ForeignKeyConstraint(['personnel_id'], ['personnel.id'], name=op.f('fk_personnel_regions_personnel_id'), ondelete='RESTRICT'), mysql.BIGINT(unsigned=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel_regions')), nullable=False,
comment='产品辐射区域表', comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
mysql_charset='utf8mb4', ),
mysql_collate='utf8mb4_general_ci', sa.Column(
mysql_engine='InnoDB' "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 ### # ### end Alembic commands ###
@ -173,9 +482,9 @@ def downgrade() -> None:
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
""" """
op.drop_table('personnel_regions') op.drop_table("personnel_regions")
op.drop_table('personnel_plans') op.drop_table("personnel_plans")
op.drop_table('customers') op.drop_table("customers")
op.drop_table('personnel') op.drop_table("personnel")
op.drop_table('ownership_change_logs') op.drop_table("ownership_change_logs")
op.drop_table('products') op.drop_table("products")

View File

@ -66,6 +66,16 @@ MODULE_PARAM = "计提参数"
MODULE_PERSONNEL = "人员管理" MODULE_PERSONNEL = "人员管理"
MODULE_PRODUCT = "产品管理" MODULE_PRODUCT = "产品管理"
MODULE_CUSTOMER = "客户管理" 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_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"

View File

@ -10,6 +10,7 @@
""" """
from app.models.base import Base, TimestampMixin 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.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog 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.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.product import Product 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.role import Permission, Role, RolePermission
from app.models.shipment import Shipment, ShipmentItem
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"Base", "Base",
"Business",
"BusinessItem",
"BusinessRole", "BusinessRole",
"BusinessStatusLog",
"Customer", "Customer",
"CustomerRatingGrade",
"LoginLog", "LoginLog",
"OperationLog", "OperationLog",
"OwnershipChangeLog", "OwnershipChangeLog",
@ -35,8 +43,11 @@ __all__ = [
"PersonnelPlan", "PersonnelPlan",
"PersonnelRegion", "PersonnelRegion",
"Product", "Product",
"Receipt",
"Role", "Role",
"RolePermission", "RolePermission",
"Shipment",
"ShipmentItem",
"TimestampMixin", "TimestampMixin",
"User", "User",
"UserRole", "UserRole",

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="创建时间"
)

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="说明")

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="备注")

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="行金额 = 数量×单价(服务层计算固化,不信任前端传值)",
)

View File

@ -29,6 +29,13 @@ EXPECTED_TABLES = {
"products", "products",
"customers", "customers",
"ownership_change_logs", "ownership_change_logs",
"businesses",
"business_items",
"business_status_logs",
"shipments",
"shipment_items",
"receipts",
"customer_rating_grades",
} }
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束

View File

@ -459,6 +459,210 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t
(18, 15, '', '2026-01-01', '18000', 1), (18, 15, '', '2026-01-01', '18000', 1),
(19, 16, '', '2026-01-01', '3000', 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 成为空操作 -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -467,4 +671,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('e1133a7c670a'); INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f');

View File

@ -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';

115
db/queries/业务数据.sql Normal file
View File

@ -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;