feat(base): 新增基础数据六表及 V4 迁移(人员/辐射区域/拓展计划/产品/客户/归属留痕)

- TPlusSyncMixin 统一 T+ 同步预留四列(可空+普通索引,唯一键待 IT 定键后升级)
- 留痕表实体 ID 无外键冗余固化,删除保护由服务层承担
- init-dev.sql 同步六表 DDL,alembic_version 标记更新

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-13 09:49:05 +08:00
parent 4552f2e703
commit f22d14e439
12 changed files with 1083 additions and 4 deletions

View File

@ -0,0 +1,181 @@
"""基础数据管理六表:人员/辐射区域/拓展计划/产品/客户/归属变更留痕(M2)
- personnel / personnel_regions / personnel_plans:人员档案及其 1:N 子表
- products:产品主数据(类别锚定折算阈值参数)
- customers:客户档案(公司名称+负责人为业务唯一标识)
- ownership_change_logs:归属变更留痕(append-only,不回溯历史)
- 六张表的 T+ 同步预留列(source_no 等)全部可空、不设唯一键(对照表 #26)
Revision ID: e1133a7c670a
Revises: bfbda9c3191d
Create Date: 2026-10-13 09:36:02.219248
"""
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 = 'e1133a7c670a'
down_revision: str | Sequence[str] | None = 'bfbda9c3191d'
branch_labels: str | Sequence[str] | None = None
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_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_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)
# ### end Alembic commands ###
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 会连带删除索引与外键。
"""
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')

View File

@ -47,12 +47,25 @@ PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT} PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 客户分类码(customers.customer_type,计提规则匹配锚点)=====
CUSTOMER_TYPE_PERSONAL = "personal"
CUSTOMER_TYPE_MANAGER_PERSONAL = "manager_personal"
CUSTOMER_TYPE_ENTERPRISE = "enterprise"
CUSTOMER_TYPES = {
CUSTOMER_TYPE_PERSONAL,
CUSTOMER_TYPE_MANAGER_PERSONAL,
CUSTOMER_TYPE_ENTERPRISE,
}
# ===== 操作日志词表(operation_logs 的 module / action 取值)===== # ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理" MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理" MODULE_ROLE = "角色管理"
MODULE_BUSINESS_ROLE = "岗位角色" MODULE_BUSINESS_ROLE = "岗位角色"
MODULE_PARAM = "计提参数" MODULE_PARAM = "计提参数"
MODULE_PERSONNEL = "人员管理"
MODULE_PRODUCT = "产品管理"
MODULE_CUSTOMER = "客户管理"
ACTION_CREATE = "新增" ACTION_CREATE = "新增"
ACTION_UPDATE = "修改" ACTION_UPDATE = "修改"

View File

@ -11,21 +11,30 @@
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
from app.models.business_role import BusinessRole from app.models.business_role import BusinessRole
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog 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.product import Product
from app.models.role import Permission, Role, RolePermission from app.models.role import Permission, Role, RolePermission
from app.models.user import User, UserRole from app.models.user import User, UserRole
__all__ = [ __all__ = [
"Base", "Base",
"BusinessRole", "BusinessRole",
"Customer",
"LoginLog", "LoginLog",
"OperationLog", "OperationLog",
"OwnershipChangeLog",
"ParamChangeBatch", "ParamChangeBatch",
"ParamDefinition", "ParamDefinition",
"ParamValue", "ParamValue",
"Permission", "Permission",
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"Product",
"Role", "Role",
"RolePermission", "RolePermission",
"TimestampMixin", "TimestampMixin",

View File

@ -7,7 +7,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, MetaData, text from sqlalchemy import DateTime, MetaData, String, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
# 状态类字段统一用 TINYINT(0/1 或少量枚举值) # 状态类字段统一用 TINYINT(0/1 或少量枚举值)
TINYINT_TYPE = TINYINT TINYINT_TYPE = TINYINT
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"] __all__ = [
"BIGINT_UNSIGNED",
"TINYINT_TYPE",
"Base",
"TPlusSyncMixin",
"TimestampMixin",
"mysql_table_args",
]
class Base(DeclarativeBase): class Base(DeclarativeBase):
@ -62,6 +69,30 @@ class TimestampMixin:
) )
class TPlusSyncMixin:
"""T+(用友)数据同步预留列,六张业务主表共用。
对照表 #26:T+ 对接的字段映射与幂等键语义未定(IT 牵头),故四列全部
可空、不设唯一键 —— 键语义未定就锁死唯一约束,对接时大概率返工;
source_no 先建普通索引承担查询与去重,IT 定键后以小迁移升级唯一约束。
同步守则(M4 同步模块必须遵守):同步只写 T+ 侧字段与本组列,
**不覆盖人工维护字段**(岗位/工资标准/归属/客户分类/业务状态等,
各表注释已标注)。同步频率:一周一次定时 + 手动触发(对照表 #10)。
"""
source_no: Mapped[str | None] = mapped_column(
String(64),
index=True,
comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)",
)
t_plus_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 系统内部标识")
sync_batch_no: Mapped[str | None] = mapped_column(
String(32), comment="同步批次号(同步模块写入)"
)
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]: def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。 """构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。

View File

@ -0,0 +1,121 @@
"""客户模型:客户档案与归属变更留痕。
- 「公司名称 + 负责人」是业务唯一标识(需规 2.1.3);负责人是客户公司的
负责人**人名**(字符串),不是系统内的营销人员 —— 系统内归属另用
owner_personnel_id 外键表达,两者语义不同
- 归属变更只改主表当前值 + 插一条留痕,**不回溯历史**(对照表 #19):
历史核算结果在 M3 台账已固化,无需 effective_date 时点化设计
- 留痕表(OwnershipChangeLog)对客户/业务/人员 ID 均为**无外键的冗余固化**:
留痕是历史记录,被引用方被删除后记录仍须完整可读;RESTRICT 外键反而会
把「删除客户」逼成 500。业务实体删除保护由服务层检查承担
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
# 归属类型(ownership_change_logs.owner_type)
OWNER_TYPE_CUSTOMER = 1
OWNER_TYPE_BUSINESS = 2
class Customer(Base, TimestampMixin, TPlusSyncMixin):
"""客户表(需求 2.1.3)。customer_type 是计提规则匹配锚点(M3 消费)。"""
__tablename__ = "customers"
__table_args__ = mysql_table_args(
"客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)",
UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
company_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="公司名称")
owner_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="客户负责人姓名(客户公司负责人的人名,非系统人员)"
)
contact_person: Mapped[str | None] = mapped_column(String(64), comment="联系人")
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="personal",
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)
owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class OwnershipChangeLog(Base):
"""归属变更记录表(append-only,需求 2.1.3 + 对照表 #19)。
owner_type 区分客户归属/业务归属:1 → customer_id 非空,2 → business_id
非空,由应用层校验(无外键,见模块 docstring 的固化论证)。
前后值冗余固化(无外键):人员被删除后留痕仍可读。
"""
__tablename__ = "ownership_change_logs"
__table_args__ = mysql_table_args(
"归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)",
Index("idx_ownership_change_logs_customer_id", "customer_id"),
Index("idx_ownership_change_logs_business_id", "business_id"),
Index("idx_ownership_change_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
owner_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, comment="归属类型:1客户归属 2业务归属"
)
customer_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="客户ID(固化,owner_type=1 时非空)",
)
business_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
comment="业务ID(固化,owner_type=2 时非空)",
)
old_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前负责营销人员ID(固化)"
)
new_owner_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后负责营销人员ID(固化)"
)
old_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更前协助营销人员ID(固化,仅业务归属使用)"
)
new_assistant_personnel_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED, comment="变更后协助营销人员ID(固化,仅业务归属使用)"
)
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,123 @@
"""营销人员模型:人员档案、产品辐射区域与市场拓展计划。
- 岗位(business_role_id)决定计提公式模板,与工资标准同属「同步不覆盖」的
人工维护字段(对照表 #26 落地口径,表注释标注供 M4 同步模块遵守)
- manager_id 自引用表达「经理带团队」:NULL=经理本人或未分配;M3 经理
12.5% 聚合、M4 部门汇总均按该关系展开,无需独立团队表
- 一期营销人员不登录系统,不与 users 关联;若后续开放自助查工资再加列
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。
表名 personnel 为集合名词(单复数同形),本身即复数语义。
试用期人员绩效照算不发放(对照表 #17),is_probation 供 M3 区分。
"""
__tablename__ = "personnel"
__table_args__ = mysql_table_args("营销人员表(T+同步不覆盖岗位与工资标准)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="姓名")
business_role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("business_roles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)",
)
manager_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
index=True,
comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)",
)
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职日期")
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
)
salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="1",
comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelRegion(Base, TimestampMixin):
"""产品辐射区域表(需求 2.1.1):人员负责的产品辐射地理范围,1:N 子表。
随人员编辑整表替换维护,无独立管理诉求,故不加 T+ 预留列。
"""
__tablename__ = "personnel_regions"
__table_args__ = mysql_table_args("产品辐射区域表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
region_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="区域名称(产品辐射地理范围)"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
class PersonnelPlan(Base, TimestampMixin):
"""市场拓展计划表(需求 2.1.1):计划期间 + 计划内容 + 指标说明的自由条目。
结构化指标粒度待 T+ 字段映射确认,先以自由条目容器落地;条目最终由
T+ 同步确定,故预留 source_no 单列(无索引:查询按 personnel_id 走)。
"""
__tablename__ = "personnel_plans"
__table_args__ = mysql_table_args("市场拓展计划表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
personnel_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("personnel.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="人员ID(personnel.id)",
)
plan_period: Mapped[str] = mapped_column(
String(32), nullable=False, comment="计划期间(如 2026Q1、2026-05)"
)
plan_content: Mapped[str] = mapped_column(String(500), nullable=False, comment="计划内容")
target_desc: Mapped[str | None] = mapped_column(String(255), comment="指标说明(文字描述)")
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始条目号(预留)")

View File

@ -0,0 +1,58 @@
"""产品模型:产品主数据。
产品类别(product_category)是**折算阈值的行级锚点**:混合业务按产品逐条
计算(对照表 #14),阈值本身由 M1 参数 `product.threshold` 按 PRODUCT_CATEGORY
维度承担(#28「全部阈值统一在系统设置维护」),本表不建阈值列 —— 两处维护
必然漂移。类别值域与 M1 参数维度共用 PRODUCT_CATEGORIES 常量。
"""
from decimal import Decimal
from sqlalchemy import Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
BIGINT_UNSIGNED,
TINYINT_TYPE,
Base,
TimestampMixin,
TPlusSyncMixin,
mysql_table_args,
)
class Product(Base, TimestampMixin, TPlusSyncMixin):
"""产品表(需求 2.1.2)。停用(status=0)为主,不物理删除。"""
__tablename__ = "products"
__table_args__ = mysql_table_args(
"产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)",
UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
product_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="产品名称")
spec_model: Mapped[str] = mapped_column(
String(128),
nullable=False,
server_default="''",
comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
stock_quantity: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="数量(T+ 同步原样存储,语义待 IT 确认)"
)
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -23,8 +23,17 @@ EXPECTED_TABLES = {
"param_definitions", "param_definitions",
"param_change_batches", "param_change_batches",
"param_values", "param_values",
"personnel",
"personnel_regions",
"personnel_plans",
"products",
"customers",
"ownership_change_logs",
} }
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
PLURAL_EXCEPTIONS = {"personnel"}
def _ddl(table_name: str) -> str: def _ddl(table_name: str) -> str:
"""返回某张表的建表 DDL 文本。""" """返回某张表的建表 DDL 文本。"""
@ -43,7 +52,9 @@ class TestTableSet:
def test_所有表名均为小写蛇形复数(self) -> None: def test_所有表名均为小写蛇形复数(self) -> None:
for name in Base.metadata.tables: for name in Base.metadata.tables:
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形" assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
assert name.endswith("s"), f"表名 {name} 应为复数" # personnel 等集合名词单复数同形,本身即复数语义
if name not in PLURAL_EXCEPTIONS:
assert name.endswith("s"), f"表名 {name} 应为复数"
class TestNamingConvention: class TestNamingConvention:

View File

@ -254,6 +254,153 @@ CREATE INDEX idx_param_values_effective_date ON param_values (effective_date);
CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date); CREATE INDEX idx_param_values_param_id_dimension_value_effective_date ON param_values (param_id, dimension_value, effective_date);
-- ---------------------------------------------------------------------------
-- 营销人员表(V4 迁移;T+ 同步不覆盖岗位与工资标准)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
phone VARCHAR(20) COMMENT '联系电话',
hire_date DATE COMMENT '入职日期',
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
salary_standard DECIMAL(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
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_personnel PRIMARY KEY (id),
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
-- ---------------------------------------------------------------------------
-- 产品辐射区域表(V4 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel_regions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
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_personnel_regions PRIMARY KEY (id),
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
-- ---------------------------------------------------------------------------
-- 市场拓展计划表(V4 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE personnel_plans (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
-- ---------------------------------------------------------------------------
-- 产品表(V4 迁移;类别锚定折算阈值参数,T+ 同步不覆盖产品类别)
-- ---------------------------------------------------------------------------
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
stock_quantity DECIMAL(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
sale_price DECIMAL(14, 4) COMMENT '销售单价(元,T+ 同步)',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
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_products PRIMARY KEY (id),
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_products_source_no ON products (source_no);
-- ---------------------------------------------------------------------------
-- 客户表(V4 迁移;公司名称+负责人为业务唯一标识,T+ 同步不覆盖客户分类)
-- ---------------------------------------------------------------------------
CREATE TABLE customers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
contact_person VARCHAR(64) COMMENT '联系人',
contact_phone VARCHAR(20) COMMENT '联系电话',
address VARCHAR(255) COMMENT '地址',
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
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_customers PRIMARY KEY (id),
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
CREATE INDEX idx_customers_source_no ON customers (source_no);
-- ---------------------------------------------------------------------------
-- 归属变更记录表(V4 迁移,append-only;客户/业务归属变更留痕,不回溯历史)
-- ---------------------------------------------------------------------------
CREATE TABLE ownership_change_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
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_ownership_change_logs PRIMARY KEY (id),
CONSTRAINT fk_ownership_change_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_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- 字典数据:角色(与 V1 迁移中的初始化数据一致)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -320,4 +467,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 ('bfbda9c3191d'); INSERT INTO alembic_version (version_num) VALUES ('e1133a7c670a');

View File

@ -0,0 +1,133 @@
-- Running upgrade bfbda9c3191d -> e1133a7c670a
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
product_name VARCHAR(128) NOT NULL COMMENT '产品名称',
spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)' DEFAULT '',
unit VARCHAR(16) COMMENT '计量单位',
stock_quantity NUMERIC(14, 2) COMMENT '数量(T+ 同步原样存储,语义待 IT 确认)',
sale_price NUMERIC(14, 4) COMMENT '销售单价(元,T+ 同步)',
product_category VARCHAR(32) NOT NULL COMMENT '产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1',
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_products PRIMARY KEY (id),
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_products_source_no ON products (source_no);
CREATE TABLE ownership_change_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
owner_type TINYINT NOT NULL COMMENT '归属类型:1客户归属 2业务归属',
customer_id BIGINT UNSIGNED COMMENT '客户ID(固化,owner_type=1 时非空)',
business_id BIGINT UNSIGNED COMMENT '业务ID(固化,owner_type=2 时非空)',
old_owner_personnel_id BIGINT UNSIGNED COMMENT '变更前负责营销人员ID(固化)',
new_owner_personnel_id BIGINT UNSIGNED COMMENT '变更后负责营销人员ID(固化)',
old_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更前协助营销人员ID(固化,仅业务归属使用)',
new_assistant_personnel_id BIGINT UNSIGNED COMMENT '变更后协助营销人员ID(固化,仅业务归属使用)',
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_ownership_change_logs PRIMARY KEY (id),
CONSTRAINT fk_ownership_change_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_ownership_change_logs_customer_id ON ownership_change_logs (customer_id);
CREATE INDEX idx_ownership_change_logs_business_id ON ownership_change_logs (business_id);
CREATE INDEX idx_ownership_change_logs_create_time ON ownership_change_logs (create_time);
CREATE TABLE personnel (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_name VARCHAR(64) NOT NULL COMMENT '姓名',
business_role_id BIGINT UNSIGNED NOT NULL COMMENT '岗位角色ID(决定计提公式模板;T+同步不覆盖)',
manager_id BIGINT UNSIGNED COMMENT '直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)',
phone VARCHAR(20) COMMENT '联系电话',
hire_date DATE COMMENT '入职日期',
is_probation TINYINT NOT NULL COMMENT '是否试用期:1是 0否(试用期绩效照算不发放)' DEFAULT '0',
salary_standard NUMERIC(12, 2) COMMENT '月工资标准(元;T+同步不覆盖)',
status TINYINT NOT NULL COMMENT '状态:1在职 0离职(离职人员不可再被业务/客户归属引用)' DEFAULT '1',
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_personnel PRIMARY KEY (id),
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='营销人员表(T+同步不覆盖岗位与工资标准)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_business_role_id ON personnel (business_role_id);
CREATE INDEX idx_personnel_manager_id ON personnel (manager_id);
CREATE INDEX idx_personnel_source_no ON personnel (source_no);
CREATE TABLE customers (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
owner_name VARCHAR(64) NOT NULL COMMENT '客户负责人姓名(客户公司负责人的人名,非系统人员)',
contact_person VARCHAR(64) COMMENT '联系人',
contact_phone VARCHAR(20) COMMENT '联系电话',
address VARCHAR(255) COMMENT '地址',
industry VARCHAR(64) COMMENT '所属行业(自由文本)',
customer_type VARCHAR(32) NOT NULL COMMENT '客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)' DEFAULT 'personal',
owner_personnel_id BIGINT UNSIGNED COMMENT '归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)',
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_customers PRIMARY KEY (id),
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_customers_customer_type ON customers (customer_type);
CREATE INDEX idx_customers_owner_personnel_id ON customers (owner_personnel_id);
CREATE INDEX idx_customers_source_no ON customers (source_no);
CREATE TABLE personnel_plans (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
plan_period VARCHAR(32) NOT NULL COMMENT '计划期间(如 2026Q1、2026-05)',
plan_content VARCHAR(500) NOT NULL COMMENT '计划内容',
target_desc VARCHAR(255) COMMENT '指标说明(文字描述)',
source_no VARCHAR(64) COMMENT 'T+ 原始条目号(预留)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_personnel_plans PRIMARY KEY (id),
CONSTRAINT fk_personnel_plans_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='市场拓展计划表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_plans_personnel_id ON personnel_plans (personnel_id);
CREATE TABLE personnel_regions (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '人员ID(personnel.id)',
region_name VARCHAR(128) NOT NULL COMMENT '区域名称(产品辐射地理范围)',
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_personnel_regions PRIMARY KEY (id),
CONSTRAINT fk_personnel_regions_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品辐射区域表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_personnel_regions_personnel_id ON personnel_regions (personnel_id);
UPDATE alembic_version SET version_num='e1133a7c670a' WHERE alembic_version.version_num = 'bfbda9c3191d';

View File

@ -0,0 +1,98 @@
-- ============================================================================
-- 基础数据查询留档(M2 基础数据管理)
--
-- 设计要点:
-- - 人员团队关系用 manager_id 自引用展开(NULL=经理本人或未分配),
-- M3 经理 12.5% 聚合与 M4 部门汇总均按此关系,无独立团队表
-- - 归属变更留痕(ownership_change_logs)对客户/业务/人员 ID 为无外键的
-- 冗余固化:被引用方删除后记录仍须完整可读,join 不到时以固化值为准
-- - T+ 预留列(source_no 等)当前全部为空,同步模块(M4)上线后启用
-- ============================================================================
-- 1. 人员列表(含岗位名称与直属经理姓名;分页 + 关键字筛选)
SELECT p.id,
p.personnel_name,
p.phone,
p.hire_date,
p.is_probation,
p.salary_standard,
p.status,
br.role_name,
br.role_code,
m.personnel_name AS manager_name
FROM personnel p
JOIN business_roles br ON br.id = p.business_role_id
LEFT JOIN personnel m ON m.id = p.manager_id
WHERE (:keyword IS NULL OR p.personnel_name LIKE CONCAT('%', :keyword, '%'))
AND (:status IS NULL OR p.status = :status)
ORDER BY p.id DESC
LIMIT :limit OFFSET :offset;
-- 2. 某经理的团队成员(M3 经理 12.5% 聚合的取数口径)
SELECT p.id, p.personnel_name, p.business_role_id, p.status
FROM personnel p
WHERE p.manager_id = :manager_id
AND p.status = 1;
-- 3. 人员的辐射区域与拓展计划(编辑抽屉回显,随人员整表替换维护)
SELECT region_name, remark
FROM personnel_regions
WHERE personnel_id = :personnel_id
ORDER BY id;
SELECT plan_period, plan_content, target_desc, source_no
FROM personnel_plans
WHERE personnel_id = :personnel_id
ORDER BY id;
-- 4. 产品目录(名称/规格关键字 + 类别筛选;名称+规格为组合唯一键)
SELECT id, product_name, spec_model, unit, stock_quantity, sale_price,
product_category, status
FROM products
WHERE (:keyword IS NULL OR product_name LIKE CONCAT('%', :keyword, '%')
OR spec_model LIKE CONCAT('%', :keyword, '%'))
AND (:product_category IS NULL OR product_category = :product_category)
AND (:status IS NULL OR status = :status)
ORDER BY id DESC
LIMIT :limit OFFSET :offset;
-- 5. 客户列表(公司/负责人关键字 + 分类 + 归属人员筛选)
SELECT c.id, c.company_name, c.owner_name, c.customer_type, c.status, c.owner_personnel_id,
p.personnel_name AS owner_personnel_name
FROM customers c
LEFT JOIN personnel p ON p.id = c.owner_personnel_id
WHERE (:keyword IS NULL OR c.company_name LIKE CONCAT('%', :keyword, '%')
OR c.owner_name LIKE CONCAT('%', :keyword, '%'))
AND (:customer_type IS NULL OR c.customer_type = :customer_type)
AND (:owner_personnel_id IS NULL OR c.owner_personnel_id = :owner_personnel_id)
ORDER BY c.id DESC
LIMIT :limit OFFSET :offset;
-- 6. 归属变更留痕(客户或业务维度;join 人员出姓名,人员已删时显示固化 ID)
SELECT l.id,
l.owner_type,
l.customer_id,
l.business_id,
l.old_owner_personnel_id,
l.new_owner_personnel_id,
po.personnel_name AS old_owner_name,
pn.personnel_name AS new_owner_name,
l.change_reason,
l.operator_name,
l.create_time
FROM ownership_change_logs l
LEFT JOIN personnel po ON po.id = l.old_owner_personnel_id
LEFT JOIN personnel pn ON pn.id = l.new_owner_personnel_id
WHERE (:customer_id IS NULL OR l.customer_id = :customer_id)
AND (:business_id IS NULL OR l.business_id = :business_id)
ORDER BY l.create_time DESC, l.id DESC
LIMIT :limit OFFSET :offset;
-- 7. 归属变更留痕(幂等校验辅助:人员删除保护——被归属引用者不可物理删除)
SELECT COUNT(*) AS ref_count
FROM customers c
WHERE c.owner_personnel_id = :personnel_id
UNION ALL
SELECT COUNT(*)
FROM personnel m
WHERE m.manager_id = :personnel_id;

View File

@ -0,0 +1,154 @@
# 衍水集团营销人员工资管理系统项目阶段报告
- **报告日期**:2026-10-13
- **报告范围**:需求澄清、技术底座、系统管理、M1 参数体系、M2 当前进展与后续计划
- **项目分支**:`feat/base-business-data`
- **阶段结论**:项目已从“需求与底座建设”进入 **M2 基础数据管理开发期**;M1 已形成可验收闭环,M2 数据模型与迁移初稿已落地,但接口、页面与模块级测试尚未完成。
## 一、3P 阶段摘要
### Progress(进展)
已完成需求裁决、开发环境、数据库基础设计、登录鉴权、系统管理、前端框架与 M1 系统设置参数体系。M1 覆盖岗位角色、公式模板、16 项参数种子、时点化取值、版本留痕、参数预览与 Excel 导出;最近一次全量回归为后端 406 个用例与 28 项端到端检查通过。当前 M2 已完成人员、区域、计划、产品、客户、归属留痕六类表的模型与 V4 迁移初稿。
### Plans(计划)
短期优先完成 M2:在既有六表基础上补齐仓储、服务、接口、前端页面、审计与模块测试,并完成双轨迁移一致性验证。随后进入业务数据管理,为绩效核算提供业务单据与回款数据输入。报表与 T+ 同步继续后置,待业务数据模型稳定和 IT 字段映射确认后实施。
### Problems(问题)
当前无业务需求阻塞,但 M2 代码尚未提交,需防止结构设计与后续接口实现长期分叉。T+ 字段映射、幂等唯一键与失败重试策略仍待 IT 确认;经理个人户业绩进入 12.5%+5% 的衔接口径需在核算模块开工前确认。原需求文档存在已裁决作废条款,对外引用前需修订或明确以对照表 v2 为准。
## 二、阶段定位与完成度
| 维度 | 当前状态 | 说明 |
| --- | --- | --- |
| 项目阶段 | 开发期 | M1 已交付,M2 进行中 |
| 整体完成度 | 14% | 管理口径;M2 尚未达到可交付节点,仅按结构设计进展小幅上调 |
| 交付主线 | M2 基础数据管理 | 六表结构与 T+ 预留已形成初稿 |
| 质量状态 | M1 回归通过 | 后端 406 用例、e2e 登录 8 项 / 系统管理 13 项 / 参数 7 项通过 |
| 主要风险 | 外部对接与口径追溯 | T+ 映射未定;需规含作废条款 |
> 说明:整体完成度用于管理沟通,不等同于代码行占比。M2 仅完成数据结构层,接口、页面和模块级测试未完成,因此不能标记为模块交付。
## 三、已完成成果
### 1. 需求与执行口径
- 业务方已回执《问题与解决方案对照表_v2.xlsx》,28 项争议全部落定。
- 2026-10-12 后,需求以对照表 v2 与《2026.9.8修改方案.docx》的裁决口径为准。
- 关键口径已固化:回款天数自然日累计且发货日计第 1 天;毛利润率不含运费;业务员计提按 1~360 天线性递减、361 天及以上固定 4‰;企业户 3‰;信息员按销售额 1‰;参数按回款到账日时点取值。
- 支付环节的起付比例、支付权重、预付款、以物抵顶均按可配置参数预留。
### 2. 技术与运行底座
- 后端六层结构建立:`api → services → repositories → models`,`schemas` 承载请求响应,`core` 处理横切能力。
- 统一响应、全局异常、结构化日志、审计脱敏、配置安全校验已落地。
- 本地环境基于 Docker Compose 编排 MySQL 8.0 与 Redis 7,使用命名卷持久化、业务账号最小权限、utf8mb4 字符集。
- 迁移采用 Alembic 生成并导出 `db/migration/V{n}__*.sql` 的双轨方式,兼顾开发迭代与生产脚本留存。
### 3. 登录鉴权与系统管理
- 登录链路包含图形验证码、JWT、登录失败限流、Token 黑名单与登录日志。
- 系统管理包含用户、角色、操作日志与日志查询,前后端闭环。
- 审计机制记录成功与失败操作,敏感字段按键名结构化脱敏,日志列表与详情分段加载。
### 4. M1 系统设置与参数体系
- 建立岗位角色、参数定义、变更批次、参数值四表。
- 公式模板以代码内有限集合注册,参数按生效日期时点化存储,避免用当前值重算历史。
- 提供 16 个管理端点,支持参数分组、版本管理、批次撤回、两日期对比、计算预览与 xlsx 导出。
- 前端交付岗位角色配置页、计提参数配置页、Bento 仪表盘与统一视觉体系。
- Code review 发现的 15 项问题已全部修复,并完成回归。
## 四、当前进行中工作
当前分支 `feat/base-business-data` 正在推进 M2 基础数据管理。已形成的结构设计包括:
| 数据对象 | 设计要点 | 后续消费方 |
| --- | --- | --- |
| `personnel` | 人员档案、岗位角色、直属经理、试用期、工资标准与状态 | 基础数据、业务归属、绩效核算 |
| `personnel_regions` | 人员负责的产品辐射区域,1:N 子表 | 基础数据维护 |
| `personnel_plans` | 市场拓展计划与指标说明,先做自由条目容器 | T+ 字段映射确认后细化 |
| `products` | 产品名称与规格唯一,类别锚定折算阈值参数 | 业务数据与绩效核算 |
| `customers` | 公司名称加负责人唯一,客户分类作为计提规则锚点 | 业务数据与绩效核算 |
| `ownership_change_logs` | 客户与业务归属变更留痕,append-only,不回溯历史 | 审计与核算追溯 |
设计上已为 T+ 同步预留 `source_no`、`t_plus_id`、`sync_batch_no`、`synced_at`。由于 IT 尚未确认字段映射与幂等键,这些列暂为可空并仅对 `source_no` 建普通索引,待键语义确定后再以小迁移升级唯一约束。
**当前边界**:以上属于模型、Alembic 迁移、生产 SQL 与开发初始化脚本层面的初稿;M2 的 CRUD API、服务事务、前端页面、权限审计和完整测试仍待实现。相关文件当前未提交,不能视为已交付功能。
## 五、下一步计划
| 优先级 | 工作项 | 验收标准 |
| --- | --- | --- |
| P0 | 完成 M2 六表接口与服务 | 列表、详情、创建、更新、停用 / 删除保护可用;事务与审计完整 |
| P0 | 完成 M2 前端页面 | 人员、产品、客户管理页面复用现有分页与确认交互,类型检查通过 |
| P0 | 补齐 M2 测试与迁移一致性检查 | 单元测试、集成测试、schema 同步测试通过 |
| P1 | 提交并评审 M2 | 提交信息符合规范,不直推共享主干 |
| P1 | 启动业务数据管理设计 | 明确业务、发货、回款、批次匹配与核销状态模型 |
| P1 | 确认核算前置口径 | 经理个人户业绩是否计入 12.5% 团队基数 |
| P2 | T+ 对接专项 | 字段映射、唯一键、去重、同步日志与失败重试策略由 IT 确认 |
## 六、风险与应对
| 风险 | 影响 | 当前应对 |
| --- | --- | --- |
| M2 结构初稿长期未提交 | 后续接口实现与表结构评审结论容易分叉 | 尽快完成最小可测闭环后提交,评审后再扩展 |
| T+ 字段映射未定 | 同步模块无法最终定幂等键与字段清洗规则 | 目标表预留同步字段;同步模块后置;不覆盖人工维护字段 |
| 需求文档存在作废条款 | 新成员或外部方按旧需规理解会产生口径冲突 | 以对照表 v2 与 9.8 修改方案为准;后续修订需规或加显著说明 |
| 经理个人户衔接口径待确认 | 影响绩效台账归属字段与经理绩效基数 | 在核算模块开工前作为必须裁决项跟踪 |
| 绩效金额精度风险 | 浮点误差会直接造成算错钱 | 金额使用 `Decimal`,比例斜率使用 `Fraction`;关键边界已有纯函数测试 |
## 七、技术选型理由
### 1. 后端:Python 3.12 + FastAPI
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Python 3.12 | `Decimal` 与 `Fraction` 能精确处理金额、千分比和 `4/360` 斜率;数据处理、Excel、后续 T+ 清洗生态成熟;版本锁定 3.12 避免新版本第三方轮子覆盖不稳 | Python 3.14:生态轮子覆盖尚不完备,兼容风险高 |
| FastAPI | 自动 OpenAPI、Pydantic 参数校验、依赖注入和统一异常模型适合内部管理系统;显式路由便于把算钱逻辑限定在 service 层 | Django:Admin 对 T+ 来源的数据 CRUD 复用价值有限,隐式 ORM 行为不利于工资核算追溯 |
| 同步 SQLAlchemy 2.0 | 项目并发压力低;绩效批量核算、Excel 导出和数据清洗是同步阻塞任务,同步路由由 FastAPI 放入线程池,避免阻塞事件循环 | 异步 SQLAlchemy:增加复杂度但没有对应吞吐收益 |
### 2. 数据与缓存:MySQL 8.0 + Redis 7
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| MySQL 8.0 + InnoDB + utf8mb4 | 事务、外键、约束与审计留痕可靠;团队与运维熟悉度高;`DECIMAL` 支撑金额精度;utf8mb4 覆盖中文与特殊字符 | Excel / 文件库:无法承载并发、事务、权限与审计;NoSQL:缺少关系约束,不利薪酬追溯 |
| Alembic 双轨迁移 | 开发期可自动生成演进,生产保留按序 SQL 脚本,符合审计与发布要求 | 仅手写 SQL:模型与库结构易漂移;仅 Alembic:不满足生产脚本留存约定 |
| Redis 7 | 验证码、登录限流、Token 黑名单都需要带 TTL 的短生命周期状态;`GETDEL` 支撑验证码一次性消费 | 仅 MySQL:一次性状态表会增加清理与原子性复杂度 |
### 3. 前端:Vue 3 + TypeScript + Vite + Element Plus + Pinia
| 选型 | 理由 | 放弃的替代方案 |
| --- | --- | --- |
| Vue 3 + TypeScript | 组件生态成熟,类型约束能降低表单和接口字段错误;项目已沉淀路由、状态、分页与确认交互 | 新换框架:无业务收益,徒增迁移成本 |
| Vite | 启动与热更新快,开发体验好;生产构建链路简单 | Webpack:配置与维护成本更高 |
| Element Plus | 表格、表单、抽屉、分页、日期选择等管理端组件齐全,可快速交付后台页面 | 自研组件库:一期交付效率低 |
| Pinia | 轻量状态管理,适合登录态、用户信息与模块状态 | 大型全局状态方案:当前规模过度设计 |
### 4. 工程与质量:uv + pnpm + Docker Compose + pytest + Playwright
| 选型 | 理由 |
| --- | --- |
| uv | 锁定 Python 与依赖版本,安装速度快,避免“本机能跑、别处失败” |
| pnpm | 依赖硬链接节省空间,lockfile 保证前端可重复安装 |
| Docker Compose | 一条命令拉起 MySQL 与 Redis,版本固定、环境一致,不依赖开发者本机散装数据库 |
| pytest + fakeredis + Playwright | 单元、集成、端到端分层验证;fakeredis 让验证码和限流测试无需容器,浏览器测试覆盖真实登录与管理链路 |
### 5. 业务计算架构:公式模板 + 参数版本,而非规则引擎
一期公式骨架固定,变化的是比例、阈值、生效日期和岗位绑定,因此使用“代码内有限公式模板 + 参数时点化版本”足够,并且可测试、可审计、排错直观。引入规则引擎会增加公式调试、权限、留痕与回归成本,却不能消除业务口径澄清本身。若未来业务要求公式可由管理员自由编写,再评估规则引擎或表达式引擎,并保持计算入口兼容。
## 八、报告结论
项目基础能力和 M1 参数地基已经稳定,当前最短路径是完成 M2 基础数据管理的可测闭环,再进入业务数据管理。技术上应继续坚持“关系数据库约束 + 精确数值类型 + 时点化参数 + 审计留痕”的路线,不在 T+ 映射未定时提前锁死同步唯一键。管理上需重点跟踪 M2 提交、核算前口径确认和 T+ 对接三项。
## 附录:信息来源
- 项目入口与运行说明:[README.md](../README.md)
- 剩余开发路线:[2026-10-12-剩余开发计划.md](superpowers/plans/2026-10-12-剩余开发计划.md)
- 需求裁决:`docs/问题与解决方案对照表_v2.xlsx`
- 修改方案:`docs/2026.9.8修改方案.docx`
- 后端测试记录:[backend/tests/README.md](../backend/tests/README.md)
- 前端约定与测试:[frontend/README.md](../frontend/README.md)