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:
parent
4552f2e703
commit
f22d14e439
181
backend/alembic/versions/e1133a7c670a_add_base_data_tables.py
Normal file
181
backend/alembic/versions/e1133a7c670a_add_base_data_tables.py
Normal 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')
|
||||
@ -47,12 +47,25 @@ PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
|
||||
PRODUCT_CATEGORY_RAIL_TRANSIT = "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 取值)=====
|
||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||
MODULE_USER = "用户管理"
|
||||
MODULE_ROLE = "角色管理"
|
||||
MODULE_BUSINESS_ROLE = "岗位角色"
|
||||
MODULE_PARAM = "计提参数"
|
||||
MODULE_PERSONNEL = "人员管理"
|
||||
MODULE_PRODUCT = "产品管理"
|
||||
MODULE_CUSTOMER = "客户管理"
|
||||
|
||||
ACTION_CREATE = "新增"
|
||||
ACTION_UPDATE = "修改"
|
||||
|
||||
@ -11,21 +11,30 @@
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.business_role import BusinessRole
|
||||
from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.models.product import Product
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"BusinessRole",
|
||||
"Customer",
|
||||
"LoginLog",
|
||||
"OperationLog",
|
||||
"OwnershipChangeLog",
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
"PersonnelPlan",
|
||||
"PersonnelRegion",
|
||||
"Product",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"TimestampMixin",
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
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.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
@ -28,7 +28,14 @@ BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||
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):
|
||||
@ -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, ...]:
|
||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||
|
||||
|
||||
121
backend/app/models/customer.py
Normal file
121
backend/app/models/customer.py
Normal 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="创建时间"
|
||||
)
|
||||
123
backend/app/models/personnel.py
Normal file
123
backend/app/models/personnel.py
Normal 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+ 原始条目号(预留)")
|
||||
58
backend/app/models/product.py
Normal file
58
backend/app/models/product.py
Normal 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="备注")
|
||||
@ -23,8 +23,17 @@ EXPECTED_TABLES = {
|
||||
"param_definitions",
|
||||
"param_change_batches",
|
||||
"param_values",
|
||||
"personnel",
|
||||
"personnel_regions",
|
||||
"personnel_plans",
|
||||
"products",
|
||||
"customers",
|
||||
"ownership_change_logs",
|
||||
}
|
||||
|
||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||
PLURAL_EXCEPTIONS = {"personnel"}
|
||||
|
||||
|
||||
def _ddl(table_name: str) -> str:
|
||||
"""返回某张表的建表 DDL 文本。"""
|
||||
@ -43,7 +52,9 @@ class TestTableSet:
|
||||
def test_所有表名均为小写蛇形复数(self) -> None:
|
||||
for name in Base.metadata.tables:
|
||||
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:
|
||||
|
||||
@ -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);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 营销人员表(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 迁移中的初始化数据一致)
|
||||
-- ---------------------------------------------------------------------------
|
||||
@ -320,4 +467,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('e1133a7c670a');
|
||||
|
||||
133
db/migration/V4__add_base_data_tables.sql
Normal file
133
db/migration/V4__add_base_data_tables.sql
Normal 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';
|
||||
|
||||
98
db/queries/基础数据.sql
Normal file
98
db/queries/基础数据.sql
Normal 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;
|
||||
154
docs/项目阶段报告_2026-10-13.md
Normal file
154
docs/项目阶段报告_2026-10-13.md
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user