From ad5fe4a4113a90952b5543d530b8408faeaf70ae Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 10:44:05 +0800 Subject: [PATCH 001/100] =?UTF-8?q?feat(system):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97=E8=A1=A8=E4=B8=8E=20V2=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 2.5.1 要求记录用户管理操作,先落表结构: - operation_logs 表:冗余 username 保证账号删除后仍可追溯, user_id 外键 ON DELETE SET NULL,success 默认 '0' 与 login_logs 口径一致 - 截断常量(USERNAME/ERROR_MESSAGE/REQUEST_DATA)集中定义,供写入方引用 - 修复既有 V1 迁移与迁移模板的 downgrade:索引是外键唯一支撑索引, 先 drop_index 会报 errno 1553,改为只 drop_table - 同步 db/migration/V2 与 init-dev.sql,迁移版本号更新为 a45a8087831b Co-Authored-By: Claude Code --- backend/alembic/script.py.mako | 18 ++- .../6165691326d4_init_rbac_and_login_logs.py | 18 +-- .../a45a8087831b_add_operation_logs.py | 109 ++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/operation_log.py | 98 ++++++++++++++++ backend/tests/integration/test_schema_sync.py | 63 ++++++++-- backend/tests/unit/test_models.py | 12 +- db/init/init-dev.sql | 43 ++++++- db/migration/V2__add_operation_logs.sql | 32 +++++ 9 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 backend/alembic/versions/a45a8087831b_add_operation_logs.py create mode 100644 backend/app/models/operation_log.py create mode 100644 db/migration/V2__add_operation_logs.sql diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako index 1101630..c743402 100644 --- a/backend/alembic/script.py.mako +++ b/backend/alembic/script.py.mako @@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n} Create Date: ${create_date} """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op import sqlalchemy as sa @@ -13,16 +13,22 @@ ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: - """Upgrade schema.""" + """应用迁移。""" ${upgrades if upgrades else "pass"} def downgrade() -> None: - """Downgrade schema.""" + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py index 44db76a..db6cf8c 100644 --- a/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py +++ b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py @@ -337,17 +337,19 @@ def upgrade() -> None: def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("idx_user_roles_role_id", table_name="user_roles") + """回滚迁移。 + + 只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + 原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与 + idx_role_permissions_permission_id 分别是外键的唯一支撑索引, + MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。 + + 删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs), + 再父表(users / roles / permissions)。 + """ op.drop_table("user_roles") - op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions") op.drop_table("role_permissions") - op.drop_index("idx_login_logs_username", table_name="login_logs") - op.drop_index("idx_login_logs_create_time", table_name="login_logs") op.drop_table("login_logs") - op.drop_index(op.f("idx_users_phone"), table_name="users") op.drop_table("users") op.drop_table("roles") op.drop_table("permissions") - # ### end Alembic commands ### diff --git a/backend/alembic/versions/a45a8087831b_add_operation_logs.py b/backend/alembic/versions/a45a8087831b_add_operation_logs.py new file mode 100644 index 0000000..5c3936e --- /dev/null +++ b/backend/alembic/versions/a45a8087831b_add_operation_logs.py @@ -0,0 +1,109 @@ +"""新增操作日志表 + +Revision ID: a45a8087831b +Revises: 6165691326d4 +Create Date: 2026-10-10 10:06:17.435697 + +""" + +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 = "a45a8087831b" +down_revision: str | Sequence[str] | None = "6165691326d4" +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( + "operation_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "user_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "username", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余存储便于追溯)", + ), + sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"), + sa.Column( + "action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出" + ), + sa.Column( + "summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)" + ), + sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"), + sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"), + sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"), + sa.Column( + "user_agent", + sa.String(length=255), + nullable=True, + comment="客户端User-Agent(截断存储)", + ), + sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"), + sa.Column( + "success", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否成功:1成功 0失败", + ), + sa.Column( + "error_message", + sa.String(length=500), + nullable=True, + comment="失败原因(写入方须截断)", + ), + sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")), + comment="操作日志表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False + ) + op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False) + op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False) + op.create_index("idx_operation_logs_username", "operation_logs", ["username"], 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 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + # 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键 + # fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。 + op.drop_table("operation_logs") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 821725f..c99f584 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,12 +11,14 @@ from app.models.base import Base, TimestampMixin from app.models.login_log import LoginLog +from app.models.operation_log import OperationLog from app.models.role import Permission, Role, RolePermission from app.models.user import User, UserRole __all__ = [ "Base", "LoginLog", + "OperationLog", "Permission", "Role", "RolePermission", diff --git a/backend/app/models/operation_log.py b/backend/app/models/operation_log.py new file mode 100644 index 0000000..fbce11b --- /dev/null +++ b/backend/app/models/operation_log.py @@ -0,0 +1,98 @@ +"""操作日志模型。 + +需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。 +登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文); +本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。 + +不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args + +# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406 +# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 —— +# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。 +USERNAME_MAX_LEN = 64 +MODULE_MAX_LEN = 64 +ACTION_MAX_LEN = 32 +SUMMARY_MAX_LEN = 255 +PATH_MAX_LEN = 255 +IP_MAX_LEN = 45 +USER_AGENT_MAX_LEN = 255 +ERROR_MESSAGE_MAX_LEN = 500 +# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限 +# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。 +REQUEST_DATA_MAX_BYTES = 60000 + + +class OperationLog(Base): + """操作日志表。 + + 记录原则与 `login_logs` 一致: + - **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏 + 密码与令牌的地方 + - 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者, + 故 `username` 单独建索引(与 `login_logs` 保持一致) + - 写入失败不得影响业务(审计可丢,业务不可断) + """ + + __tablename__ = "operation_logs" + __table_args__ = mysql_table_args( + "操作日志表", + Index("idx_operation_logs_user_id", "user_id"), + # 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引, + # 否则日志表增长后查询退化为全表扫描 + Index("idx_operation_logs_username", "username"), + Index("idx_operation_logs_module", "module"), + Index("idx_operation_logs_create_time", "create_time"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + user_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + username: Mapped[str] = mapped_column( + String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)" + ) + module: Mapped[str] = mapped_column( + String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理" + ) + action: Mapped[str] = mapped_column( + String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出" + ) + summary: Mapped[str] = mapped_column( + String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)" + ) + + method: Mapped[str | None] = mapped_column(String(8), comment="HTTP 方法") + path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径") + ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)") + user_agent: Mapped[str | None] = mapped_column( + String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)" + ) + # 请求参数,**入库前已脱敏**;长文本故用 TEXT + request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)") + + # 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 —— + # 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是 + # 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。 + success: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败" + ) + error_message: Mapped[str | None] = mapped_column( + String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)" + ) + duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)") + + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py index 0634e11..322de41 100644 --- a/backend/tests/integration/test_schema_sync.py +++ b/backend/tests/integration/test_schema_sync.py @@ -1,13 +1,16 @@ """DDL 漂移检测。 结构定义同时存在于两处: -- `db/migration/V1__*.sql` —— 事实来源,生产迁移用 +- `db/migration/V*.sql` —— 事实来源,生产迁移用 - `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置 两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致, 新人克隆下来建出的库和生产不一样,等到上线才发现。 本测试把「靠自觉」变成「可自动拦截」,成本极低。 +**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后, +两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。 + 注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。 """ @@ -17,8 +20,9 @@ from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[3] -MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql" +MIGRATION_DIR = PROJECT_ROOT / "db" / "migration" INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" +ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions" _CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) _COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE) @@ -38,9 +42,38 @@ def _parse_tables(sql: str) -> dict[str, set[str]]: return tables +def _alembic_head() -> str: + """从迁移脚本推断当前 head 版本号。 + + 纯文本解析而非调用 alembic 运行时 —— 后者需要连数据库,会让本测试 + 从「秒级、无需容器」变成「必须起库」。 + head 的定义:没有被任何其他迁移的 down_revision 引用过的那个 revision。 + """ + revisions: set[str] = set() + referenced: set[str] = set() + for path in ALEMBIC_VERSIONS_DIR.glob("*.py"): + text = path.read_text(encoding="utf-8") + if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE): + revisions.add(found.group(1)) + if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE): + referenced.add(found.group(1)) + + heads = revisions - referenced + assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}" + return heads.pop() + + @pytest.fixture(scope="module") def migration_tables() -> dict[str, set[str]]: - return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8")) + """合并解析 db/migration/ 下全部 V*.sql。""" + files = sorted(MIGRATION_DIR.glob("V*.sql")) + assert files, f"未找到迁移脚本:{MIGRATION_DIR}" + + merged: dict[str, set[str]] = {} + for path in files: + for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items(): + merged.setdefault(table, set()).update(columns) + return merged @pytest.fixture(scope="module") @@ -52,7 +85,7 @@ class TestSchemaSync: """两份 DDL 的结构一致性。""" def test_文件存在(self) -> None: - assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}" + assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}" assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}" def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None: @@ -74,16 +107,28 @@ class TestSchemaSync: class TestSeedData: - """字典数据在两处应保持同步。""" + """字典数据与版本标记。""" @pytest.mark.parametrize("role_code", ["admin", "user"]) def test_角色初始化数据在两处都存在(self, role_code: str) -> None: - migration_sql = MIGRATION_FILE.read_text(encoding="utf-8") + migration_sql = "\n".join( + path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql")) + ) init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") assert f"'{role_code}'" in migration_sql assert f"'{role_code}'" in init_sql - def test_开发脚本标记了迁移版本(self) -> None: - """不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。""" + def test_开发脚本标记的版本号等于当前head(self) -> None: + """版本号必须等于 head,且不能只断言「存在」。 + + 落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移 + 尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。 + """ init_sql = INIT_DEV_FILE.read_text(encoding="utf-8") - assert "INSERT INTO alembic_version" in init_sql + found = re.search( + r"INSERT INTO alembic_version \(version_num\) VALUES \('(\w+)'\)", init_sql + ) + assert found, "init-dev.sql 未写入 alembic_version" + assert found.group(1) == _alembic_head(), ( + f"init-dev.sql 标记的版本号 {found.group(1)!r} 落后于当前 head {_alembic_head()!r}" + ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 139bf13..528ce19 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -11,7 +11,15 @@ from sqlalchemy.dialects import mysql from app.models import Base -EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"} +EXPECTED_TABLES = { + "users", + "roles", + "user_roles", + "permissions", + "role_permissions", + "login_logs", + "operation_logs", +} def _ddl(table_name: str) -> str: @@ -101,7 +109,7 @@ class TestTimestampColumns: def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None: assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name) - @pytest.mark.parametrize("table_name", ["login_logs"]) + @pytest.mark.parametrize("table_name", ["login_logs", "operation_logs"]) def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None: """日志是不可变记录,没有"更新时间"语义。""" assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name) diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 51f9c85..4a6e6fc 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -5,16 +5,18 @@ -- 数据卷已存在时修改本文件不会生效,需要: -- docker compose -f docker/docker-compose.dev.yml down -v && up -d -- --- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置) --- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致, +-- 内容 = db/migration/ 下**全部迁移**的最终结构(额外带 DROP,开发环境允许重置) +-- 必须与 db/migration/V*.sql 的合集保持一致, -- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移 -- --- 结尾写入 alembic_version 记录,使 `alembic upgrade head` 在本库上成为空操作 +-- 结尾写入 alembic_version 记录(**须等于当前 head**,否则开发库重建后 +-- 执行 alembic upgrade head 会重复建表并报 1050 Table already exists) -- ============================================================================ SET NAMES utf8mb4; -- 删除顺序遵循外键依赖:先删子表 +DROP TABLE IF EXISTS `operation_logs`; DROP TABLE IF EXISTS `role_permissions`; DROP TABLE IF EXISTS `user_roles`; DROP TABLE IF EXISTS `login_logs`; @@ -134,6 +136,37 @@ CREATE TABLE user_roles ( CREATE INDEX idx_user_roles_role_id ON user_roles (role_id); +-- --------------------------------------------------------------------------- +-- 操作日志表(V2 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE operation_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)', + module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理', + action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出', + summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)', + method VARCHAR(8) COMMENT 'HTTP 方法', + path VARCHAR(255) COMMENT '请求路径', + ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)', + user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)', + request_data TEXT COMMENT '请求参数(已脱敏)', + success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0', + error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)', + duration_ms INTEGER COMMENT '处理耗时(毫秒)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_operation_logs PRIMARY KEY (id), + CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time); + +CREATE INDEX idx_operation_logs_module ON operation_logs (module); + +CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); + +CREATE INDEX idx_operation_logs_username ON operation_logs (username); + -- --------------------------------------------------------------------------- -- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- --------------------------------------------------------------------------- @@ -142,11 +175,11 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); -- --------------------------------------------------------------------------- --- 迁移版本记录:标记 V1 已应用,使 alembic upgrade head 成为空操作 +-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- CREATE TABLE alembic_version ( version_num VARCHAR(32) NOT NULL, CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('6165691326d4'); +INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b'); diff --git a/db/migration/V2__add_operation_logs.sql b/db/migration/V2__add_operation_logs.sql new file mode 100644 index 0000000..0a1e923 --- /dev/null +++ b/db/migration/V2__add_operation_logs.sql @@ -0,0 +1,32 @@ +-- Running upgrade 6165691326d4 -> a45a8087831b + +CREATE TABLE operation_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)', + module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理', + action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出', + summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)', + method VARCHAR(8) COMMENT 'HTTP 方法', + path VARCHAR(255) COMMENT '请求路径', + ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)', + user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)', + request_data TEXT COMMENT '请求参数(已脱敏)', + success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0', + error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)', + duration_ms INTEGER COMMENT '处理耗时(毫秒)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_operation_logs PRIMARY KEY (id), + CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time); + +CREATE INDEX idx_operation_logs_module ON operation_logs (module); + +CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); + +CREATE INDEX idx_operation_logs_username ON operation_logs (username); + +UPDATE alembic_version SET version_num='a45a8087831b' WHERE alembic_version.version_num = '6165691326d4'; + -- 2.27.0 From e22c4bcdc9b16f7a09fcc4214b6aa3d1a7e3e6bf Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 10:44:13 +0800 Subject: [PATCH 002/100] =?UTF-8?q?feat(system):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 2.5.1 用户账号管理,全部要求管理员权限(require_admin 依赖): - 接口:列表/详情/新增/编辑/删除/重置密码/启停/角色下拉, 角色下拉放 /users/meta/roles 以避开 /{user_id} 的路由匹配冲突 - 删除、禁用、改角色三处自我保护:不允许对当前登录账号执行, 避免管理员误操作后立刻失去全部管理权限且无法自行恢复 - 重置密码与禁用均提升 token_version,使已签发令牌立即失效, 不依赖 Redis 黑名单 - 密码按字节数校验(bcrypt 5.0 硬上限 72 字节,中文一字 3 字节) - 用户列表按 id 倒序:create_time 为秒级精度,同秒数据分页结果不确定 - 修复 replace_roles 未同步关系集合的缺陷:改用 user.user_roles 集合追加,此前 session.add(UserRole(...)) 会表现为保存成功但返回空角色 - update_user 的自我保护原先依赖返回 None 的占位函数,恒不生效, 改为显式传入 operator_id Co-Authored-By: Claude Code --- backend/app/api/deps.py | 20 +- backend/app/api/router.py | 7 + backend/app/api/v1/system/__init__.py | 7 + backend/app/api/v1/system/users.py | 131 +++++++++++ backend/app/repositories/user_repository.py | 131 ++++++++++- backend/app/schemas/user.py | 126 +++++++++++ backend/app/services/user_service.py | 232 ++++++++++++++++++++ 7 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/v1/system/__init__.py create mode 100644 backend/app/api/v1/system/users.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6115b33..3dba999 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -6,8 +6,9 @@ from fastapi import Depends from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.core.constants import ROLE_ADMIN from app.core.db import get_db -from app.core.exceptions import AuthError +from app.core.exceptions import AuthError, ForbiddenError from app.core.security import decode_token from app.models import User from app.repositories import user_repository @@ -71,3 +72,20 @@ def _is_digits(value: object) -> bool: 令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。 """ return isinstance(value, str) and value.isdigit() + + +def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User: + """要求当前用户具备管理员角色。 + + 需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个 + 角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions` + 与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。 + + :raises ForbiddenError: 已登录但不具备管理员角色(403) + """ + if ROLE_ADMIN not in user_repository.get_role_codes(current_user): + raise ForbiddenError("需要管理员权限") + return current_user + + +AdminUser = Annotated[User, Depends(require_admin)] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 692fa8a..67b1529 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.system import users as system_users api_router = APIRouter() @@ -17,4 +18,10 @@ api_router.include_router(health.router) # 业务接口:/api/v1/* v1_router = APIRouter(prefix="/v1") v1_router.include_router(auth.router) + +# 系统管理(需求 2.5.1):/api/v1/system/* +system_router = APIRouter(prefix="/system") +system_router.include_router(system_users.router) +v1_router.include_router(system_router) + api_router.include_router(v1_router) diff --git a/backend/app/api/v1/system/__init__.py b/backend/app/api/v1/system/__init__.py new file mode 100644 index 0000000..6e2813b --- /dev/null +++ b/backend/app/api/v1/system/__init__.py @@ -0,0 +1,7 @@ +"""系统管理路由(需求 2.5.1)。 + +含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。 + +与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录, +以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。 +""" diff --git a/backend/app/api/v1/system/users.py b/backend/app/api/v1/system/users.py new file mode 100644 index 0000000..c9e6432 --- /dev/null +++ b/backend/app/api/v1/system/users.py @@ -0,0 +1,131 @@ +"""用户管理接口(需求 2.5.1:用户账号管理)。 + +全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」, +用户管理属管理员专属。 + +删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行, +否则管理员误操作会立刻失去全部管理权限且无法自行恢复。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.user import ( + PasswordResetIn, + RoleOut, + UserCreateIn, + UserOut, + UserQuery, + UserStatusIn, + UserUpdateIn, +) +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["系统管理 · 用户"]) + + +@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表") +def list_users( + session: DbSession, + _: AdminUser, + query: Annotated[UserQuery, Query()], +) -> ApiResponse[PageResult[UserOut]]: + """分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。""" + return ok(user_service.list_users(session, query)) + + +@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情") +def get_user( + session: DbSession, + _: AdminUser, + user_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[UserOut]: + """查询用户详情。""" + return ok(user_service.get_user(session, user_id)) + + +@router.post("", response_model=ApiResponse[UserOut], summary="新增用户") +def create_user( + session: DbSession, + _: AdminUser, + payload: UserCreateIn, +) -> ApiResponse[UserOut]: + """新增用户。用户名须唯一,至少指定一个角色。""" + return ok(user_service.create_user(session, payload), message="用户已创建") + + +@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户") +def update_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserUpdateIn, +) -> ApiResponse[UserOut]: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。 + """ + return ok( + user_service.update_user(session, user_id, payload, operator_id=current_user.id), + message="已保存", + ) + + +@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户") +def delete_user( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[None]: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。 + 业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。 + """ + user_service.delete_user(session, user_id, operator_id=current_user.id) + return ok(message="用户已删除") + + +@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码") +def reset_password( + session: DbSession, + _: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: PasswordResetIn, +) -> ApiResponse[None]: + """重置用户密码。 + + 会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** —— + 否则改密后旧令牌在有效期内仍可用。 + """ + user_service.reset_password(session, user_id, payload.new_password) + return ok(message="密码已重置,该用户需重新登录") + + +@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户") +def set_status( + session: DbSession, + current_user: AdminUser, + user_id: Annotated[int, Path(ge=1)], + payload: UserStatusIn, +) -> ApiResponse[UserOut]: + """启用或禁用用户。 + + 禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌 + 继续访问直到过期(最长 2 小时)。 + """ + result = user_service.set_status(session, user_id, payload.status, operator_id=current_user.id) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项") +def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]: + """取全部启用角色,供用户表单的角色选择使用。 + + 路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与 + `/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。 + """ + return ok(user_service.list_roles(session)) diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 314968b..c99c3bc 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -5,10 +5,18 @@ from datetime import datetime -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session, selectinload -from app.models import User +from app.models import Role, User, UserRole + + +def _base_query() -> Select[tuple[User]]: + """用户查询基座。 + + 预加载角色关联,避免列表渲染时逐行查角色(N+1)。 + """ + return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role)) def get_by_username(session: Session, username: str) -> User | None: @@ -34,3 +42,120 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None: """更新最后登录时间。只 flush,提交由 service 决定。""" user.last_login_time = moment session.flush() + + +# ==================== 列表查询 ==================== + + +def list_users( + session: Session, + *, + offset: int, + limit: int, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> list[User]: + """分页查询用户列表。 + + **按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户 + 可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。 + id 是自增主键,天然唯一且与创建顺序一致。 + """ + stmt = _base_query() + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + # 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略 + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars().unique()) + + +def count_users( + session: Session, + *, + username: str | None = None, + real_name: str | None = None, + status: int | None = None, + role_code: str | None = None, +) -> int: + """统计符合条件的用户总数,供分页使用。""" + stmt = select(func.count(User.id)) + stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status) + if role_code: + stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + username: str | None, + real_name: str | None, + status: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if username: + stmt = stmt.where(User.username.like(f"%{username}%")) + if real_name: + stmt = stmt.where(User.real_name.like(f"%{real_name}%")) + if status is not None: + stmt = stmt.where(User.status == status) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, user: User) -> User: + """新增用户。只 flush 以取得自增 id。""" + session.add(user) + session.flush() + return user + + +def delete(session: Session, user: User) -> None: + """物理删除用户。 + + 本项目对用户采用「删除即禁用」的**业务**约定(见模型注释), + 但保留物理删除能力供测试清理与误建账号的彻底移除使用。 + `user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL, + 故不会留下悬挂引用。 + """ + session.delete(user) + session.flush() + + +# ==================== 角色 ==================== + + +def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]: + """按编码批量取角色。用于校验前端传来的角色是否存在。""" + if not role_codes: + return [] + stmt = select(Role).where(Role.role_code.in_(role_codes)) + return list(session.execute(stmt).scalars()) + + +def replace_roles(session: Session, user: User, roles: list[Role]) -> None: + """整体替换用户的角色。 + + 采用「先清后建」而非逐条比对:角色数量极少(本期仅两个), + 差异计算的复杂度换不来收益,且整体替换的语义更直白。 + + **必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**: + 后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内 + 再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色 + 列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。 + """ + user.user_roles.clear() + for role in roles: + user.user_roles.append(UserRole(role_id=role.id)) + session.flush() + + +def get_all_roles(session: Session) -> list[Role]: + """取全部启用角色,供前端下拉选择。""" + stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id) + return list(session.execute(stmt).scalars()) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..be21775 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,126 @@ +"""用户与角色的请求/响应模型。 + +与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户), +本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。 + +字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES +from app.core.response import PageQuery + + +def _validate_password_bytes(value: str) -> str: + """按**字节数**校验密码长度。 + + 必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError, + 而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。 + 与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。 + """ + length = len(value.encode("utf-8")) + if length < PASSWORD_MIN_BYTES: + raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节") + if length > PASSWORD_MAX_BYTES: + raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限") + return value + + +class UserOut(BaseModel): + """用户列表/详情响应。 + + 不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + real_name: str | None = None + phone: str | None = None + email: str | None = None + status: int = Field(description="状态:1启用 0禁用") + roles: list[str] = Field(default_factory=list, description="角色编码列表") + last_login_time: datetime | None = None + remark: str | None = None + create_time: datetime | None = None + + +class UserCreateIn(BaseModel): + """新增用户。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一") + password: str = Field(description="初始密码") + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserUpdateIn(BaseModel): + """编辑用户。 + + **不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史 + 操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + real_name: str | None = Field(default=None, max_length=64) + phone: str | None = Field(default=None, max_length=20) + email: str | None = Field(default=None, max_length=128) + roles: list[str] | None = Field(default=None, description="传 null 表示不改角色") + remark: str | None = Field(default=None, max_length=255) + + +class PasswordResetIn(BaseModel): + """重置密码(管理员操作,不需要原密码)。""" + + new_password: str = Field(description="新密码") + + @field_validator("new_password") + @classmethod + def _check_password(cls, value: str) -> str: + return _validate_password_bytes(value) + + +class UserStatusIn(BaseModel): + """启用/禁用用户。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class UserQuery(PageQuery): + """用户列表查询条件。 + + 继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。 + """ + + username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配") + real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") + role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选") + + +class RoleOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + description: str | None = None + sort_order: int = 0 + status: int = 1 diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..8811a49 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,232 @@ +"""用户管理业务编排。 + +**事务边界在本层**:每个公开方法自行决定何时 commit。 +repository 只 flush,事务由这里控制。 + +设计取舍: +- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议 + (停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了 + 「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username + 保证删除后仍可追溯 +- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限, + 且没有其他账号可以救回来 +""" + +import logging + +from sqlalchemy.orm import Session + +from app.core.constants import ROLE_ADMIN +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.core.security import hash_password +from app.models import User +from app.repositories import user_repository +from app.schemas.user import RoleOut, UserCreateIn, UserOut, UserQuery, UserUpdateIn + +logger = logging.getLogger(__name__) + + +def _to_out(user: User) -> UserOut: + """ORM 对象转响应模型。 + + 禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。 + """ + return UserOut( + id=user.id, + username=user.username, + real_name=user.real_name, + phone=user.phone, + email=user.email, + status=user.status, + roles=user_repository.get_role_codes(user), + last_login_time=user.last_login_time, + remark=user.remark, + create_time=user.create_time, + ) + + +def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]: + """分页查询用户列表。""" + users = user_repository.list_users( + session, + offset=query.offset, + limit=query.page_size, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + total = user_repository.count_users( + session, + username=query.username, + real_name=query.real_name, + status=query.status, + role_code=query.role_code, + ) + return PageResult[UserOut]( + records=[_to_out(user) for user in users], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_user(session: Session, user_id: int) -> UserOut: + """查询用户详情。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + return _to_out(user) + + +def create_user(session: Session, payload: UserCreateIn) -> UserOut: + """新增用户。 + + :raises BadRequestError: 用户名已存在,或角色编码无效 + """ + if user_repository.get_by_username(session, payload.username) is not None: + raise BadRequestError(f"用户名 {payload.username!r} 已存在") + + roles = _resolve_roles(session, payload.roles) + + user = User( + username=payload.username, + password=hash_password(payload.password), + real_name=payload.real_name, + phone=payload.phone, + email=payload.email, + status=1, + remark=payload.remark, + ) + user_repository.create(session, user) + user_repository.replace_roles(session, user, roles) + session.commit() + + logger.info("新增用户 | username=%s | roles=%s", user.username, payload.roles) + return _to_out(user) + + +def update_user( + session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int +) -> UserOut: + """编辑用户的基本信息与角色。 + + **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口, + 以免"改资料"顺手改掉密码这类越权操作。 + + :raises NotFoundError: 用户不存在 + :raises BadRequestError: 角色编码无效 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if payload.real_name is not None: + user.real_name = payload.real_name + if payload.phone is not None: + user.phone = payload.phone + if payload.email is not None: + user.email = payload.email + if payload.remark is not None: + user.remark = payload.remark + + # 不允许把自己降级掉管理角色,否则改完立刻失去管理权限 + if payload.roles is not None and user_id == operator_id and ROLE_ADMIN not in payload.roles: + raise ForbiddenError("不能移除自己的管理员角色") + + session.flush() + + if payload.roles is not None: + roles = _resolve_roles(session, payload.roles) + user_repository.replace_roles(session, user, roles) + + session.commit() + logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles) + return _to_out(user) + + +def reset_password(session: Session, user_id: int, new_password: str) -> None: + """重置用户密码。 + + 同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** —— + 改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。 + + :raises NotFoundError: 用户不存在 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + user.password = hash_password(new_password) + user.token_version += 1 + session.commit() + logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id) + + +def set_status(session: Session, user_id: int, status: int, *, operator_id: int) -> UserOut: + """启用或禁用用户。 + + 禁用时同样提升 `token_version`,使已签发令牌立即失效 —— + 否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图禁用自己 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if status == 0 and user_id == operator_id: + raise ForbiddenError("不能禁用自己") + + user.status = status + if status == 0: + user.token_version += 1 + session.commit() + logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status) + return _to_out(user) + + +def delete_user(session: Session, user_id: int, *, operator_id: int) -> None: + """删除用户。 + + 物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。 + + :raises NotFoundError: 用户不存在 + :raises ForbiddenError: 试图删除自己 + """ + user = user_repository.get_by_id(session, user_id) + if user is None: + raise NotFoundError("用户不存在") + + if user_id == operator_id: + raise ForbiddenError("不能删除自己") + + username = user.username + user_repository.delete(session, user) + session.commit() + logger.info("删除用户 | username=%s", username) + + +def list_roles(session: Session) -> list[RoleOut]: + """取全部启用角色,供前端下拉选择。""" + return [RoleOut.model_validate(role) for role in user_repository.get_all_roles(session)] + + +def _resolve_roles(session: Session, role_codes: list[str]) -> list: + """把角色编码解析为角色对象,并校验全部存在。 + + :raises BadRequestError: 存在无效编码 + """ + if not role_codes: + raise BadRequestError("至少需要指定一个角色") + roles = user_repository.get_roles_by_codes(session, role_codes) + if len(roles) != len(set(role_codes)): + valid = {role.role_code for role in roles} + invalid = sorted(set(role_codes) - valid) + raise BadRequestError(f"角色编码无效:{invalid}") + return list(roles) -- 2.27.0 From c83287819a6bdef6d4cdf281ad44ecd045870aa3 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 16:28:04 +0800 Subject: [PATCH 003/100] =?UTF-8?q?feat(system):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=EF=BC=88?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1/=E8=A7=92=E8=89=B2/=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97/=E6=97=A5=E5=BF=97=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code --- .gitignore | 1 + backend/README.md | 37 +- backend/app/api/deps.py | 26 +- backend/app/api/router.py | 5 + backend/app/api/v1/auth.py | 14 +- backend/app/api/v1/system/__init__.py | 2 +- backend/app/api/v1/system/logs.py | 67 ++++ backend/app/api/v1/system/roles.py | 100 +++++ backend/app/api/v1/system/users.py | 33 +- backend/app/core/constants.py | 12 + backend/app/models/operation_log.py | 3 +- .../app/repositories/login_log_repository.py | 39 ++ .../repositories/operation_log_repository.py | 200 ++++++++++ backend/app/repositories/query_utils.py | 54 +++ backend/app/repositories/role_repository.py | 123 ++++++ backend/app/repositories/user_repository.py | 35 +- backend/app/schemas/log.py | 105 +++++ backend/app/schemas/role.py | 80 ++++ backend/app/schemas/user.py | 15 +- backend/app/services/audit_service.py | 173 +++++++++ backend/app/services/log_service.py | 82 ++++ backend/app/services/role_service.py | 253 ++++++++++++ backend/app/services/user_service.py | 280 ++++++++++---- backend/tests/README.md | 30 +- backend/tests/integration/conftest.py | 169 ++++++++ backend/tests/integration/test_log_query.py | 198 ++++++++++ .../tests/integration/test_role_management.py | 209 ++++++++++ .../tests/integration/test_user_management.py | 364 ++++++++++++++++++ .../tests/unit/test_audit_serialization.py | 128 ++++++ backend/tests/unit/test_audit_service.py | 268 +++++++++++++ backend/tests/unit/test_log_schemas.py | 92 +++++ backend/tests/unit/test_role_schemas.py | 98 +++++ 32 files changed, 3158 insertions(+), 137 deletions(-) create mode 100644 backend/app/api/v1/system/logs.py create mode 100644 backend/app/api/v1/system/roles.py create mode 100644 backend/app/repositories/operation_log_repository.py create mode 100644 backend/app/repositories/query_utils.py create mode 100644 backend/app/repositories/role_repository.py create mode 100644 backend/app/schemas/log.py create mode 100644 backend/app/schemas/role.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/log_service.py create mode 100644 backend/app/services/role_service.py create mode 100644 backend/tests/integration/conftest.py create mode 100644 backend/tests/integration/test_log_query.py create mode 100644 backend/tests/integration/test_role_management.py create mode 100644 backend/tests/integration/test_user_management.py create mode 100644 backend/tests/unit/test_audit_serialization.py create mode 100644 backend/tests/unit/test_audit_service.py create mode 100644 backend/tests/unit/test_log_schemas.py create mode 100644 backend/tests/unit/test_role_schemas.py diff --git a/.gitignore b/.gitignore index 6f27c66..2ffbab2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ frontend/e2e/*.png # 备份 backup/ +.claude diff --git a/backend/README.md b/backend/README.md index 4300b80..5a0c45d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,11 +1,12 @@ # 后端 -> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。 +> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。 ## 职责 - 提供 REST 接口(统一前缀 `/api`) - 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志 +- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询 - 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步 ## 技术栈与版本 @@ -53,6 +54,24 @@ app/ | POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 | | GET | `/api/v1/auth/me` | 是 | 当前用户与角色 | +### 系统管理接口(需求 2.5.1,全部要求管理员权限) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 | +| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) | +| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) | +| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) | +| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) | +| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 | +| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) | +| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) | +| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) | +| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) | +| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) | + +全部写操作自动写入操作日志(成功与失败都记,机制见下)。 + 统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。 状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。 @@ -118,6 +137,22 @@ Redis 黑名单只负责「用户主动登出」。 bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。 中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。 +### 操作日志:审计上下文 + 独立提交 + +写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`): + +- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry` +- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播 +- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的 +- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉) +- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service +- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计 +- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」 +- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载 +- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐 + +**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。 + ## 常见问题 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 3dba999..e867ba0 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -13,6 +13,7 @@ from app.core.security import decode_token from app.models import User from app.repositories import user_repository from app.services import token_blacklist +from app.services.audit_service import RequestMeta # auto_error=False:令牌缺失时由我们自己抛 AuthError, # 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"} @@ -22,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear DbSession = Annotated[Session, Depends(get_db)] +def client_ip(request: Request) -> str | None: + """取客户端 IP。 + + 当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址, + 届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头 + 由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。 + """ + return request.client.host if request.client else None + + +def get_request_meta(request: Request) -> RequestMeta: + """提取 HTTP 请求元信息,供操作日志审计使用。""" + return RequestMeta( + ip=client_ip(request), + user_agent=request.headers.get("user-agent"), + method=request.method, + path=request.url.path, + ) + + +RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)] + + def get_raw_token(credentials: BearerCredentials) -> str: """取原始令牌字符串。登出时需要它来解析 jti。""" if credentials is None or not credentials.credentials: diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 67b1529..5a89cf7 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,8 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.system import logs as system_logs +from app.api.v1.system import roles as system_roles from app.api.v1.system import users as system_users api_router = APIRouter() @@ -22,6 +24,9 @@ v1_router.include_router(auth.router) # 系统管理(需求 2.5.1):/api/v1/system/* system_router = APIRouter(prefix="/system") system_router.include_router(system_users.router) +system_router.include_router(system_roles.router) +system_router.include_router(system_logs.operation_router) +system_router.include_router(system_logs.login_router) v1_router.include_router(system_router) api_router.include_router(v1_router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 62150c4..07aced3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -4,7 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request -from app.api.deps import DbSession, get_current_user, get_raw_token +from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token from app.core.response import ApiResponse, ok from app.models import User from app.repositories.user_repository import get_role_codes @@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service router = APIRouter(prefix="/auth", tags=["认证"]) -def _client_ip(request: Request) -> str | None: - """取客户端 IP。 - - 当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址, - 届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头 - 由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。 - """ - return request.client.host if request.client else None - - @router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码") def get_captcha() -> ApiResponse[CaptchaOut]: """签发图形验证码。无需认证。 @@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse password=payload.password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code, - ip=_client_ip(request), + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) return ok(result, message="登录成功") diff --git a/backend/app/api/v1/system/__init__.py b/backend/app/api/v1/system/__init__.py index 6e2813b..add9495 100644 --- a/backend/app/api/v1/system/__init__.py +++ b/backend/app/api/v1/system/__init__.py @@ -1,6 +1,6 @@ """系统管理路由(需求 2.5.1)。 -含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。 +含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。 与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录, 以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。 diff --git a/backend/app/api/v1/system/logs.py b/backend/app/api/v1/system/logs.py new file mode 100644 index 0000000..74b673c --- /dev/null +++ b/backend/app/api/v1/system/logs.py @@ -0,0 +1,67 @@ +"""日志查询接口(需求 2.5.1:支持日志查询)。 + +操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/ +请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是 +两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。 + +均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.log import ( + LoginLogOut, + LoginLogQuery, + OperationLogDetailOut, + OperationLogOut, + OperationLogQuery, +) +from app.services import log_service + +operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"]) +login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"]) + + +@operation_router.get( + "", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志" +) +def list_operation_logs( + session: DbSession, + _: AdminUser, + query: Annotated[OperationLogQuery, Query()], +) -> ApiResponse[PageResult[OperationLogOut]]: + """分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。 + + 按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段), + 展开详情请调 `GET /operation-logs/{log_id}`。 + """ + return ok(log_service.list_operation_logs(session, query)) + + +@operation_router.get( + "/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情" +) +def get_operation_log( + session: DbSession, + _: AdminUser, + log_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[OperationLogDetailOut]: + """查询单条操作日志详情(含已脱敏的请求参数)。 + + 前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。 + """ + return ok(log_service.get_operation_log(session, log_id)) + + +@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志") +def list_login_logs( + session: DbSession, + _: AdminUser, + query: Annotated[LoginLogQuery, Query()], +) -> ApiResponse[PageResult[LoginLogOut]]: + """分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。""" + return ok(log_service.list_login_logs(session, query)) diff --git a/backend/app/api/v1/system/roles.py b/backend/app/api/v1/system/roles.py new file mode 100644 index 0000000..cfbbbc1 --- /dev/null +++ b/backend/app/api/v1/system/roles.py @@ -0,0 +1,100 @@ +"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。 + +保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见 +`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。 + +全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service +层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn +from app.services import role_service + +router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"]) + + +@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表") +def list_roles( + session: DbSession, + _: AdminUser, + query: Annotated[RoleQuery, Query()], +) -> ApiResponse[PageResult[RoleManageOut]]: + """分页查询角色。 + + 与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。 + 每行附带 `user_count`(绑定用户数),供前端提示删除保护。 + """ + return ok(role_service.list_roles(session, query)) + + +@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色") +def create_role( + session: DbSession, + current_user: AdminUser, + payload: RoleCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """新增角色。编码全局唯一、创建后不可修改;新角色默认启用。""" + return ok( + role_service.create_role(session, payload, operator=current_user, meta=meta), + message="角色已创建", + ) + + +@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色") +def update_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: RoleUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """编辑角色名称/说明/排序。 + + **不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。 + """ + return ok( + role_service.update_role(session, role_id, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色") +def set_status( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: RoleStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[RoleManageOut]: + """启用或禁用角色。 + + 停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态); + 内置管理员角色不可停用。 + """ + result = role_service.set_status( + session, role_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色") +def delete_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除角色。 + + 内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)—— + `user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。 + """ + role_service.delete_role(session, role_id, operator=current_user, meta=meta) + return ok(message="角色已删除") diff --git a/backend/app/api/v1/system/users.py b/backend/app/api/v1/system/users.py index c9e6432..ab71157 100644 --- a/backend/app/api/v1/system/users.py +++ b/backend/app/api/v1/system/users.py @@ -5,17 +5,20 @@ 删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行, 否则管理员误操作会立刻失去全部管理权限且无法自行恢复。 + +全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志 +(成功与失败都记),操作人为当前登录管理员。 """ from typing import Annotated from fastapi import APIRouter, Path, Query -from app.api.deps import AdminUser, DbSession +from app.api.deps import AdminUser, DbSession, RequestMetaDep from app.core.response import ApiResponse, PageResult, ok +from app.schemas.role import RoleOut from app.schemas.user import ( PasswordResetIn, - RoleOut, UserCreateIn, UserOut, UserQuery, @@ -50,11 +53,15 @@ def get_user( @router.post("", response_model=ApiResponse[UserOut], summary="新增用户") def create_user( session: DbSession, - _: AdminUser, + current_user: AdminUser, payload: UserCreateIn, + meta: RequestMetaDep, ) -> ApiResponse[UserOut]: """新增用户。用户名须唯一,至少指定一个角色。""" - return ok(user_service.create_user(session, payload), message="用户已创建") + return ok( + user_service.create_user(session, payload, operator=current_user, meta=meta), + message="用户已创建", + ) @router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户") @@ -63,13 +70,14 @@ def update_user( current_user: AdminUser, user_id: Annotated[int, Path(ge=1)], payload: UserUpdateIn, + meta: RequestMetaDep, ) -> ApiResponse[UserOut]: """编辑用户的基本信息与角色。 **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。 """ return ok( - user_service.update_user(session, user_id, payload, operator_id=current_user.id), + user_service.update_user(session, user_id, payload, operator=current_user, meta=meta), message="已保存", ) @@ -79,29 +87,33 @@ def delete_user( session: DbSession, current_user: AdminUser, user_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, ) -> ApiResponse[None]: """删除用户。 物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。 业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。 """ - user_service.delete_user(session, user_id, operator_id=current_user.id) + user_service.delete_user(session, user_id, operator=current_user, meta=meta) return ok(message="用户已删除") @router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码") def reset_password( session: DbSession, - _: AdminUser, + current_user: AdminUser, user_id: Annotated[int, Path(ge=1)], payload: PasswordResetIn, + meta: RequestMetaDep, ) -> ApiResponse[None]: """重置用户密码。 会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** —— 否则改密后旧令牌在有效期内仍可用。 """ - user_service.reset_password(session, user_id, payload.new_password) + user_service.reset_password( + session, user_id, payload.new_password, operator=current_user, meta=meta + ) return ok(message="密码已重置,该用户需重新登录") @@ -111,13 +123,16 @@ def set_status( current_user: AdminUser, user_id: Annotated[int, Path(ge=1)], payload: UserStatusIn, + meta: RequestMetaDep, ) -> ApiResponse[UserOut]: """启用或禁用用户。 禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌 继续访问直到过期(最长 2 小时)。 """ - result = user_service.set_status(session, user_id, payload.status, operator_id=current_user.id) + result = user_service.set_status( + session, user_id, payload.status, operator=current_user, meta=meta + ) return ok(result, message="已启用" if payload.status == 1 else "已禁用") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 5074c0d..fc44fd9 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -36,3 +36,15 @@ PASSWORD_MAX_BYTES = 72 # 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率 CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" CAPTCHA_LENGTH = 4 + +# ===== 操作日志词表(operation_logs 的 module / action 取值)===== +# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 +MODULE_USER = "用户管理" +MODULE_ROLE = "角色管理" + +ACTION_CREATE = "新增" +ACTION_UPDATE = "修改" +ACTION_DELETE = "删除" +ACTION_RESET_PASSWORD = "重置密码" +ACTION_ENABLE = "启用" +ACTION_DISABLE = "禁用" diff --git a/backend/app/models/operation_log.py b/backend/app/models/operation_log.py index fbce11b..23a15ff 100644 --- a/backend/app/models/operation_log.py +++ b/backend/app/models/operation_log.py @@ -21,6 +21,7 @@ USERNAME_MAX_LEN = 64 MODULE_MAX_LEN = 64 ACTION_MAX_LEN = 32 SUMMARY_MAX_LEN = 255 +METHOD_MAX_LEN = 8 PATH_MAX_LEN = 255 IP_MAX_LEN = 45 USER_AGENT_MAX_LEN = 255 @@ -73,7 +74,7 @@ class OperationLog(Base): String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)" ) - method: Mapped[str | None] = mapped_column(String(8), comment="HTTP 方法") + method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法") path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径") ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)") user_agent: Mapped[str | None] = mapped_column( diff --git a/backend/app/repositories/login_log_repository.py b/backend/app/repositories/login_log_repository.py index 5c230bc..122e9e6 100644 --- a/backend/app/repositories/login_log_repository.py +++ b/backend/app/repositories/login_log_repository.py @@ -1,8 +1,10 @@ """登录日志数据访问。""" +from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from app.models import LoginLog +from app.repositories.query_utils import LogFilters, apply_log_filters # user_agent 列宽,超长需截断否则插入报错 _USER_AGENT_MAX_LEN = 255 @@ -36,3 +38,40 @@ def add( ) ) session.flush() + + +def list_logs( + session: Session, + *, + offset: int, + limit: int, + filters: LogFilters, + ip: str | None = None, +) -> list[LoginLog]: + """分页查询登录日志。 + + **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 + 不确定,翻页会重复或遗漏(与用户列表同一理由)。 + """ + stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip) + stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_logs( + session: Session, + *, + filters: LogFilters, + ip: str | None = None, +) -> int: + """统计符合条件的登录日志总数,供分页使用。""" + stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select: + """应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。""" + stmt = apply_log_filters(stmt, LoginLog, filters) + if ip: + stmt = stmt.where(LoginLog.ip == ip) + return stmt diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py new file mode 100644 index 0000000..977feb4 --- /dev/null +++ b/backend/app/repositories/operation_log_repository.py @@ -0,0 +1,200 @@ +"""操作日志数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 + +写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository` +的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。 +""" + +import json +from dataclasses import dataclass + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session, defer + +from app.core.logging import scrub +from app.models import OperationLog +from app.models.operation_log import ( + ACTION_MAX_LEN, + ERROR_MESSAGE_MAX_LEN, + IP_MAX_LEN, + METHOD_MAX_LEN, + MODULE_MAX_LEN, + PATH_MAX_LEN, + REQUEST_DATA_MAX_BYTES, + SUMMARY_MAX_LEN, + USER_AGENT_MAX_LEN, + USERNAME_MAX_LEN, +) +from app.repositories.query_utils import LogFilters, apply_log_filters + +# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。 +# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 —— +# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、 +# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。 +_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key") + +_MASK = "***" + + +@dataclass(frozen=True, slots=True) +class OperationLogEntry: + """一条待写入的操作日志(全字段 DTO)。 + + 替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处 + 重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错 + (success 默认 0 的语义使漏传尤其隐蔽)。 + """ + + user_id: int | None + username: str + module: str + action: str + summary: str + success: bool + method: str | None = None + path: str | None = None + ip: str | None = None + user_agent: str | None = None + request_data: dict | None = None + error_message: str | None = None + duration_ms: int | None = None + + +def add(session: Session, entry: OperationLogEntry) -> None: + """写入一条操作日志。只 flush 不 commit。 + + 所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406, + 而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**, + 事后极难排查。截断是唯一防线,必须在此层无条件执行。 + """ + session.add( + OperationLog( + user_id=entry.user_id, + username=entry.username[:USERNAME_MAX_LEN], + module=entry.module[:MODULE_MAX_LEN], + action=entry.action[:ACTION_MAX_LEN], + summary=entry.summary[:SUMMARY_MAX_LEN], + method=_clip(entry.method, METHOD_MAX_LEN), + path=_clip(entry.path, PATH_MAX_LEN), + ip=_clip(entry.ip, IP_MAX_LEN), + user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN), + request_data=_serialize_request_data(entry.request_data), + success=1 if entry.success else 0, + error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN), + duration_ms=entry.duration_ms, + ) + ) + session.flush() + + +def get_by_id(session: Session, log_id: int) -> OperationLog | None: + """按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。""" + return session.get(OperationLog, log_id) + + +def list_logs( + session: Session, + *, + offset: int, + limit: int, + filters: LogFilters, + module: str | None = None, + action: str | None = None, +) -> list[OperationLog]: + """分页查询操作日志。 + + **按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序 + 不确定,翻页会重复或遗漏(与用户列表同一理由)。 + + **排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不 + 展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id` + 按需读取。 + """ + stmt = _apply_filters( + select(OperationLog).options(defer(OperationLog.request_data)), + filters=filters, + module=module, + action=action, + ) + stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_logs( + session: Session, + *, + filters: LogFilters, + module: str | None = None, + action: str | None = None, +) -> int: + """统计符合条件的日志总数,供分页使用。""" + stmt = _apply_filters( + select(func.count(OperationLog.id)), filters=filters, module=module, action=action + ) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + filters: LogFilters, + module: str | None, + action: str | None, +) -> Select: + """应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。""" + stmt = apply_log_filters(stmt, OperationLog, filters) + if module: + stmt = stmt.where(OperationLog.module == module) + if action: + stmt = stmt.where(OperationLog.action == action) + return stmt + + +def _clip(text: str | None, limit: int) -> str | None: + """按字符数截断(长度常量与数据库列宽均按字符定义)。""" + return text[:limit] if text else None + + +def _is_sensitive_key(key: object) -> bool: + name = str(key).lower() + return any(part in name for part in _SENSITIVE_KEY_PARTS) + + +def _sanitize_sensitive(value: object) -> object: + """递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。""" + if isinstance(value, dict): + return { + key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_sanitize_sensitive(item) for item in value] + return value + + +def _serialize_request_data(data: dict | None) -> str | None: + """请求参数 → JSON 文本:两段脱敏 → 字节边界截断。 + + 脱敏分两层,各司其职: + + - **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``, + 不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响 + - `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名 + 判断的值 + + 截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节 + 上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片, + 中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片 + 则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 → + 宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 —— + 请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛 + UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。 + """ + if not data: + return None + text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str)) + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= REQUEST_DATA_MAX_BYTES: + return text + return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore") diff --git a/backend/app/repositories/query_utils.py b/backend/app/repositories/query_utils.py new file mode 100644 index 0000000..bba28b7 --- /dev/null +++ b/backend/app/repositories/query_utils.py @@ -0,0 +1,54 @@ +"""repositories 层公共查询工具。 + +集中两类易错点,避免各 repository 各写一份、口径漂移: + +- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的 + 下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非 + 理论),百分号则命中全表并触发全表扫描 +- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志 + 之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上 +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sqlalchemy import Select + + +@dataclass(frozen=True, slots=True) +class LogFilters: + """两类日志(操作/登录)共有的筛选条件。""" + + username: str | None = None + success: int | None = None + start_time: datetime | None = None + end_time: datetime | None = None + + +def fuzzy(stmt: Select, column: Any, value: str) -> Select: + """模糊匹配(含通配符自动转义)。 + + ``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE`` + 子句,把用户输入当字面量匹配。 + """ + return stmt.where(column.contains(value, autoescape=True)) + + +def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select: + """应用两类日志共有的筛选条件。 + + ``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。 + + 列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」—— + 漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。 + """ + if filters.username: + stmt = fuzzy(stmt, model.username, filters.username) + if filters.success is not None: + stmt = stmt.where(model.success == filters.success) + if filters.start_time: + stmt = stmt.where(model.create_time >= filters.start_time) + if filters.end_time: + stmt = stmt.where(model.create_time <= filters.end_time) + return stmt diff --git a/backend/app/repositories/role_repository.py b/backend/app/repositories/role_repository.py new file mode 100644 index 0000000..cdd2c13 --- /dev/null +++ b/backend/app/repositories/role_repository.py @@ -0,0 +1,123 @@ +"""角色数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 + +`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`, +因角色管理成为独立模块而迁入本文件。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import Role, UserRole + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None: + """按主键查询角色。不存在返回 None。 + + ``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的 + 「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定, + `user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的 + 外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。 + """ + if for_update: + stmt = select(Role).where(Role.id == role_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Role, role_id) + + +def get_by_code(session: Session, role_code: str) -> Role | None: + """按编码查询角色。不存在返回 None。""" + return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none() + + +def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]: + """按编码批量取角色。用于校验前端传来的角色是否存在。""" + if not role_codes: + return [] + stmt = select(Role).where(Role.role_code.in_(role_codes)) + return list(session.execute(stmt).scalars()) + + +def get_all_roles(session: Session) -> list[Role]: + """取全部启用角色,供前端下拉选择。""" + stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id) + return list(session.execute(stmt).scalars()) + + +def list_roles( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, +) -> list[Role]: + """分页查询角色(管理列表:含停用角色)。 + + 排序与下拉口径一致(sort_order, id),保持同一套相对次序。 + """ + stmt = _apply_filters(select(Role), keyword=keyword, status=status) + stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int: + """统计符合条件的角色总数,供分页使用。""" + stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)—— + 角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + Role.role_code.contains(keyword, autoescape=True), + Role.role_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(Role.status == status) + return stmt + + +def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]: + """批量统计各角色关联的用户数。 + + 一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。 + """ + if not role_ids: + return {} + stmt = ( + select(UserRole.role_id, func.count()) + .where(UserRole.role_id.in_(role_ids)) + .group_by(UserRole.role_id) + ) + return {int(role_id): int(count) for role_id, count in session.execute(stmt)} + + +# ==================== 写入 ==================== + + +def create(session: Session, role: Role) -> Role: + """新增角色。只 flush 以取得自增 id。""" + session.add(role) + session.flush() + return role + + +def delete(session: Session, role: Role) -> None: + """删除角色。 + + 调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是 + CASCADE,直接删会静默剥离绑定用户的权限。 + """ + session.delete(role) + session.flush() diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index c99c3bc..6b18d5b 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -1,4 +1,4 @@ -"""用户与角色数据访问。 +"""用户数据访问(角色相关的读取与维护见 `role_repository`)。 本层只 flush 不 commit —— 事务边界由 services 层控制。 """ @@ -9,6 +9,7 @@ from sqlalchemy import Select, func, select from sqlalchemy.orm import Session, selectinload from app.models import Role, User, UserRole +from app.repositories.query_utils import fuzzy def _base_query() -> Select[tuple[User]]: @@ -95,11 +96,15 @@ def _apply_filters( real_name: str | None, status: int | None, ) -> Select: - """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍 + 含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。 + """ if username: - stmt = stmt.where(User.username.like(f"%{username}%")) + stmt = fuzzy(stmt, User.username, username) if real_name: - stmt = stmt.where(User.real_name.like(f"%{real_name}%")) + stmt = fuzzy(stmt, User.real_name, real_name) if status is not None: stmt = stmt.where(User.status == status) return stmt @@ -127,15 +132,7 @@ def delete(session: Session, user: User) -> None: session.flush() -# ==================== 角色 ==================== - - -def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]: - """按编码批量取角色。用于校验前端传来的角色是否存在。""" - if not role_codes: - return [] - stmt = select(Role).where(Role.role_code.in_(role_codes)) - return list(session.execute(stmt).scalars()) +# ==================== 角色关联 ==================== def replace_roles(session: Session, user: User, roles: list[Role]) -> None: @@ -144,18 +141,18 @@ def replace_roles(session: Session, user: User, roles: list[Role]) -> None: 采用「先清后建」而非逐条比对:角色数量极少(本期仅两个), 差异计算的复杂度换不来收益,且整体替换的语义更直白。 + **clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中 + **先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的 + (user_id, role_id)(编辑用户时保留既有角色就会发生)会撞 + `uk_user_roles_user_id_role_id` 唯一键,报 1062。 + **必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**: 后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内 再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色 列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。 """ user.user_roles.clear() + session.flush() for role in roles: user.user_roles.append(UserRole(role_id=role.id)) session.flush() - - -def get_all_roles(session: Session) -> list[Role]: - """取全部启用角色,供前端下拉选择。""" - stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id) - return list(session.execute(stmt).scalars()) diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000..79ce87e --- /dev/null +++ b/backend/app/schemas/log.py @@ -0,0 +1,105 @@ +"""日志查询的请求/响应模型。 + +操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因), +故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。 +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.response import PageQuery + + +class LogQueryBase(PageQuery): + """日志公共查询条件:模糊用户名 + 结果 + 时间范围。""" + + username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配") + success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败") + start_time: datetime | None = Field(default=None, description="起始时间(含)") + end_time: datetime | None = Field(default=None, description="结束时间(含)") + + @field_validator("start_time", "end_time") + @classmethod + def _to_local_naive(cls, value: datetime | None) -> datetime | None: + """统一为本地时区的 naive 时间。 + + 库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601 + (可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive + 混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会 + **静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。 + aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势; + naive 视为本地时间原样保留。 + """ + if value is not None and value.tzinfo is not None: + return value.astimezone().replace(tzinfo=None) + return value + + @model_validator(mode="after") + def _check_time_range(self) -> "LogQueryBase": + if self.start_time and self.end_time and self.start_time > self.end_time: + raise ValueError("起始时间不能晚于结束时间") + return self + + +class OperationLogQuery(LogQueryBase): + """操作日志查询条件。""" + + module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配") + action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配") + + +class OperationLogOut(BaseModel): + """操作日志列表项。 + + **不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它, + 随列表返回会让翻页负担随日志表增长线性放大;展开详情走 + `GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int | None = None + username: str + module: str + action: str + summary: str + method: str | None = None + path: str | None = None + ip: str | None = None + user_agent: str | None = None + success: int = 0 + error_message: str | None = None + duration_ms: int | None = None + create_time: datetime | None = None + + +class OperationLogDetailOut(OperationLogOut): + """操作日志详情:列表字段 + 请求参数。 + + `request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。 + """ + + request_data: str | None = None + + +class LoginLogQuery(LogQueryBase): + """登录日志查询条件。""" + + ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配") + + +class LoginLogOut(BaseModel): + """登录日志响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int | None = None + username: str + ip: str | None = None + user_agent: str | None = None + success: int = 0 + fail_reason: str | None = None + create_time: datetime | None = None diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..e31ad33 --- /dev/null +++ b/backend/app/schemas/role.py @@ -0,0 +1,80 @@ +"""角色管理的请求/响应模型。 + +`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 —— +用户表单的角色下拉与角色管理列表共用同一响应模型。 +""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。 +# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与 +# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难 +ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$") + + +class RoleOut(BaseModel): + """角色基础响应(用户表单下拉与角色列表共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + description: str | None = None + sort_order: int = 0 + status: int = 1 + + +class RoleManageOut(RoleOut): + """角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。""" + + user_count: int = 0 + + +class RoleCreateIn(BaseModel): + """新增角色。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改") + role_name: str = Field(min_length=1, max_length=64, description="角色名称") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + @field_validator("role_code") + @classmethod + def _check_code(cls, value: str) -> str: + if not ROLE_CODE_PATTERN.match(value): + raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线") + return value + + +class RoleUpdateIn(BaseModel): + """编辑角色。 + + **不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。 + 前端表单全量提交,故为全量替换语义。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + role_name: str = Field(min_length=1, max_length=64, description="角色名称") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + +class RoleStatusIn(BaseModel): + """启用/禁用角色。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class RoleQuery(PageQuery): + """角色列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index be21775..151f900 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,4 +1,4 @@ -"""用户与角色的请求/响应模型。 +"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。 与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户), 本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。 @@ -111,16 +111,3 @@ class UserQuery(PageQuery): real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选") - - -class RoleOut(BaseModel): - """角色响应。""" - - model_config = ConfigDict(from_attributes=True) - - id: int - role_code: str - role_name: str - description: str | None = None - sort_order: int = 0 - status: int = 1 diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..dcb80c1 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,173 @@ +"""操作日志审计服务。 + +需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs` +单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类 +操作**。 + +设计要点: + +- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计 + 对象,模型里专门有 `success` / `error_message` 两列承载它 +- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛 + (与 `auth_service._record` 同一原则:"审计可丢,业务不可断") +- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会 + 连带丢掉最该留痕的失败记录 + +用法(上下文管理器):: + + target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要 + with audit(session, operator=current_user, + record=AuditRecord(MODULE_USER, ACTION_DELETE, + f"删除用户 {target.username}"), + meta=meta): + ...业务操作与 session.commit()... + return out + +约定: + +- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后 + 的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或 + 用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会 + 产生与事实相反的审计记录 +- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功 +- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取, + 这样目标不存在(NotFound 失败)时也能留下可追溯的记录 +- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到 + 未提交变更会记 warning +""" + +import logging +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.core.exceptions import AppException +from app.models import User +from app.repositories import operation_log_repository +from app.repositories.operation_log_repository import OperationLogEntry + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class RequestMeta: + """HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。""" + + ip: str | None = None + user_agent: str | None = None + method: str | None = None + path: str | None = None + + +@dataclass(frozen=True, slots=True) +class AuditRecord: + """审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。""" + + module: str + action: str + summary: str + request_data: dict | None = None + + +@contextmanager +def audit( + session: Session, + *, + operator: User, + record: AuditRecord, + meta: RequestMeta | None = None, +) -> Iterator[None]: + """审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。""" + started = time.perf_counter() + meta = meta or RequestMeta() + + # 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback + # 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发 + # 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失 + base: dict = { + "user_id": operator.id, + "username": operator.username, + "module": record.module, + "action": record.action, + "summary": record.summary, + "method": meta.method, + "path": meta.path, + "ip": meta.ip, + "user_agent": meta.user_agent, + "request_data": record.request_data, + } + + # 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更 + # 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法 + # 撤销已提交的部分,审计与数据状态会不一致 + if session.new or session.dirty or session.deleted: + logger.warning( + "进入审计上下文时存在未提交的变更 | module=%s | action=%s", + record.module, + record.action, + ) + + try: + yield + except Exception as exc: + # 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误, + # 不回滚的话 session 处于「必须回滚」状态,写日志会抛 + # PendingRollbackError —— 最该留下的失败记录反而被丢掉 + _rollback_quietly(session) + _safe_write( + session, + entry=OperationLogEntry( + **base, + success=False, + error_message=_error_text(exc), + duration_ms=_elapsed_ms(started), + ), + ) + raise + else: + _safe_write( + session, + entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)), + ) + + +def _safe_write(session: Session, *, entry: OperationLogEntry) -> None: + """落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。""" + try: + operation_log_repository.add(session, entry) + session.commit() + except Exception: + logger.warning( + "操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s", + entry.module, + entry.action, + entry.summary, + exc_info=True, + ) + _rollback_quietly(session) + + +def _rollback_quietly(session: Session) -> None: + try: + session.rollback() + except Exception: + logger.warning("会话回滚失败", exc_info=True) + + +def _error_text(exc: Exception) -> str: + """失败原因文案。 + + 业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局 + 异常处理器记入应用日志,审计表不落内部细节。 + """ + if isinstance(exc, AppException): + return exc.message + return "服务端异常,请稍后重试" + + +def _elapsed_ms(started: float) -> int: + """度量业务处理耗时(不含响应序列化)。""" + return max(0, int((time.perf_counter() - started) * 1000)) diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py new file mode 100644 index 0000000..3ff945f --- /dev/null +++ b/backend/app/services/log_service.py @@ -0,0 +1,82 @@ +"""日志查询编排。 + +操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有 +单条详情(列表为性能排除的 `request_data` 走详情按需加载)。 +纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。 +""" + +from sqlalchemy.orm import Session + +from app.core.exceptions import NotFoundError +from app.core.response import PageResult +from app.repositories import login_log_repository, operation_log_repository +from app.repositories.query_utils import LogFilters +from app.schemas.log import ( + LoginLogOut, + LoginLogQuery, + OperationLogDetailOut, + OperationLogOut, + OperationLogQuery, +) + + +def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]: + """分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。""" + filters = _filters_from(query) + logs = operation_log_repository.list_logs( + session, + offset=query.offset, + limit=query.page_size, + filters=filters, + module=query.module, + action=query.action, + ) + total = operation_log_repository.count_logs( + session, filters=filters, module=query.module, action=query.action + ) + return PageResult[OperationLogOut]( + records=[OperationLogOut.model_validate(log) for log in logs], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut: + """查询单条操作日志详情(含已脱敏的 request_data)。 + + :raises NotFoundError: 日志不存在 + """ + log = operation_log_repository.get_by_id(session, log_id) + if log is None: + raise NotFoundError("日志不存在") + return OperationLogDetailOut.model_validate(log) + + +def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]: + """分页查询登录日志。""" + filters = _filters_from(query) + logs = login_log_repository.list_logs( + session, + offset=query.offset, + limit=query.page_size, + filters=filters, + ip=query.ip, + ) + total = login_log_repository.count_logs(session, filters=filters, ip=query.ip) + return PageResult[LoginLogOut]( + records=[LoginLogOut.model_validate(log) for log in logs], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters: + """两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。""" + return LogFilters( + username=query.username, + success=query.success, + start_time=query.start_time, + end_time=query.end_time, + ) diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py new file mode 100644 index 0000000..dd73912 --- /dev/null +++ b/backend/app/services/role_service.py @@ -0,0 +1,253 @@ +"""角色管理业务编排。 + +需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善** +(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置, +待业务方给出权限清单后再启用)。 + +保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): + +- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖 + 该编码存在,删除会使系统无法自举 +- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默 + 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` + 锁行,堵住「校验与删除之间并发绑定」的窗口 +- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点 +- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), + 但**不可再分配给用户**(`user_service._resolve_roles` 拦截) +""" + +import logging + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + MODULE_ROLE, + ROLE_ADMIN, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.models import Role, User +from app.repositories import role_repository +from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + + +def _to_out(role: Role, user_count: int) -> RoleManageOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return RoleManageOut( + id=role.id, + role_code=role.role_code, + role_name=role.role_name, + description=role.description, + sort_order=role.sort_order, + status=role.status, + user_count=user_count, + ) + + +def _user_count(session: Session, role_id: int) -> int: + """单个角色的绑定用户数(复用批量统计,保持口径一致)。""" + return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0) + + +def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]: + """分页查询角色。 + + 与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**, + 下拉只给启用项。 + """ + roles = role_repository.list_roles( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + ) + total = role_repository.count_roles(session, keyword=query.keyword, status=query.status) + counts = role_repository.count_users_by_role(session, [role.id for role in roles]) + return PageResult[RoleManageOut]( + records=[_to_out(role, counts.get(role.id, 0)) for role in roles], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def create_role( + session: Session, + payload: RoleCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """新增角色。编码唯一、创建后不可修改,新角色固定启用。 + + :raises BadRequestError: 角色编码已存在(含并发提交的兜底) + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_CREATE, + summary=f"新增角色 {payload.role_name}({payload.role_code})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role_repository.get_by_code(session, payload.role_code) is not None: + raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") + + role = Role( + role_code=payload.role_code, + role_name=payload.role_name, + description=payload.description, + sort_order=payload.sort_order, + status=1, + ) + try: + role_repository.create(session, role) + out = _to_out(role, 0) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。 + # 回滚并把唯一键冲突翻译为可读的 400,而非 500 + session.rollback() + raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc + + logger.info("新增角色 | role_code=%s", payload.role_code) + return out + + +def update_role( + session: Session, + role_id: int, + payload: RoleUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """编辑角色名称/说明/排序(全量替换,不含 role_code)。 + + :raises NotFoundError: 角色不存在 + """ + role = role_repository.get_by_id(session, role_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_UPDATE, + summary=f"修改角色 {role.role_name if role else f'#{role_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + role.role_name = payload.role_name + role.description = payload.description + role.sort_order = payload.sort_order + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(role, _user_count(session, role_id)) + session.commit() + logger.info("修改角色 | role_id=%s", role_id) + return out + + +def set_status( + session: Session, + role_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RoleManageOut: + """启用或禁用角色。 + + 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), + 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); + 内置管理员角色不可停用。 + + :raises NotFoundError: 角色不存在 + :raises ForbiddenError: 试图停用内置管理员角色 + """ + role = role_repository.get_by_id(session, role_id) + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + if role.role_code == ROLE_ADMIN and status == 0: + raise ForbiddenError("内置管理员角色不可停用") + + role.status = status + out = _to_out(role, _user_count(session, role_id)) + session.commit() + logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status) + return out + + +def delete_role( + session: Session, + role_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除角色。 + + 内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。 + 查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把 + 新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。 + + :raises NotFoundError: 角色不存在 + :raises ForbiddenError: 试图删除内置管理员角色 + :raises BadRequestError: 角色下仍有绑定的用户 + """ + role = role_repository.get_by_id(session, role_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_ROLE, + action=ACTION_DELETE, + summary=f"删除角色 {role.role_name if role else f'#{role_id}'}", + ), + meta=meta, + ): + if role is None: + raise NotFoundError("角色不存在") + + if role.role_code == ROLE_ADMIN: + raise ForbiddenError("内置管理员角色不可删除") + + # user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限 + user_count = _user_count(session, role_id) + if user_count > 0: + raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定") + + role_code = role.role_code # 固化:对象随删除失效 + role_repository.delete(session, role) + session.commit() + logger.info("删除角色 | role_code=%s", role_code) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 8811a49..f679e00 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -4,25 +4,45 @@ repository 只 flush,事务由这里控制。 设计取舍: + - 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议 (停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了 「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username 保证删除后仍可追溯 - 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限, 且没有其他账号可以救回来 +- 全部写操作经 `audit_service.audit()` 留痕,**成功与失败都记**。摘要需要 + 「目标用户名」,故对目标的只读预取放在审计上下文**之前** —— 这样目标 + 不存在(NotFound 失败)时也能留下可读的记录 +- **响应对象在 commit 之前构造**(`_to_out` 读的是内存中的已加载状态): + commit 后再触碰 ORM 对象会因过期而触发额外查询,该查询一旦抛错, + 「已提交成功」的操作会被审计记成失败 """ import logging +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.core.constants import ROLE_ADMIN +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_RESET_PASSWORD, + ACTION_UPDATE, + MODULE_USER, + ROLE_ADMIN, +) from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.response import PageResult from app.core.security import hash_password from app.models import User -from app.repositories import user_repository -from app.schemas.user import RoleOut, UserCreateIn, UserOut, UserQuery, UserUpdateIn +from app.repositories import role_repository, user_repository +from app.schemas.role import RoleOut +from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) @@ -83,73 +103,126 @@ def get_user(session: Session, user_id: int) -> UserOut: return _to_out(user) -def create_user(session: Session, payload: UserCreateIn) -> UserOut: +def create_user( + session: Session, + payload: UserCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> UserOut: """新增用户。 - :raises BadRequestError: 用户名已存在,或角色编码无效 + :raises BadRequestError: 用户名已存在,或角色编码无效/已停用 """ - if user_repository.get_by_username(session, payload.username) is not None: - raise BadRequestError(f"用户名 {payload.username!r} 已存在") + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_CREATE, + summary=f"新增用户 {payload.username}", + # password 会随字典进入审计管道,由 repository 层的结构化脱敏抹除其值 + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if user_repository.get_by_username(session, payload.username) is not None: + raise BadRequestError(f"用户名 {payload.username!r} 已存在") - roles = _resolve_roles(session, payload.roles) + roles = _resolve_roles(session, payload.roles) - user = User( - username=payload.username, - password=hash_password(payload.password), - real_name=payload.real_name, - phone=payload.phone, - email=payload.email, - status=1, - remark=payload.remark, - ) - user_repository.create(session, user) - user_repository.replace_roles(session, user, roles) - session.commit() + user = User( + username=payload.username, + password=hash_password(payload.password), + real_name=payload.real_name, + phone=payload.phone, + email=payload.email, + status=1, + remark=payload.remark, + ) + try: + user_repository.create(session, user) + user_repository.replace_roles(session, user, roles) + out = _to_out(user) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一用户名会撞 uk_users_username。 + # 回滚并把唯一键冲突翻译为可读的 400,而非 500 + session.rollback() + raise BadRequestError(f"用户名 {payload.username!r} 已存在") from exc - logger.info("新增用户 | username=%s | roles=%s", user.username, payload.roles) - return _to_out(user) + logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles) + return out def update_user( - session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int + session: Session, + user_id: int, + payload: UserUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, ) -> UserOut: """编辑用户的基本信息与角色。 **不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口, 以免"改资料"顺手改掉密码这类越权操作。 + 空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交, + 若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆); + 归一后「清空输入框」= 写回 NULL,语义正确。 + :raises NotFoundError: 用户不存在 - :raises BadRequestError: 角色编码无效 + :raises BadRequestError: 角色编码无效或已停用 """ user = user_repository.get_by_id(session, user_id) - if user is None: - raise NotFoundError("用户不存在") + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_UPDATE, + summary=f"修改用户 {user.username if user else f'#{user_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") - if payload.real_name is not None: - user.real_name = payload.real_name - if payload.phone is not None: - user.phone = payload.phone - if payload.email is not None: - user.email = payload.email - if payload.remark is not None: - user.remark = payload.remark + if payload.real_name is not None: + user.real_name = payload.real_name or None + if payload.phone is not None: + user.phone = payload.phone or None + if payload.email is not None: + user.email = payload.email or None + if payload.remark is not None: + user.remark = payload.remark or None - # 不允许把自己降级掉管理角色,否则改完立刻失去管理权限 - if payload.roles is not None and user_id == operator_id and ROLE_ADMIN not in payload.roles: - raise ForbiddenError("不能移除自己的管理员角色") + # 不允许把自己降级掉管理角色,否则改完立刻失去管理权限 + if payload.roles is not None and user_id == operator.id and ROLE_ADMIN not in payload.roles: + raise ForbiddenError("不能移除自己的管理员角色") - session.flush() + session.flush() - if payload.roles is not None: - roles = _resolve_roles(session, payload.roles) - user_repository.replace_roles(session, user, roles) + if payload.roles is not None: + roles = _resolve_roles(session, payload.roles) + user_repository.replace_roles(session, user, roles) - session.commit() - logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles) - return _to_out(user) + out = _to_out(user) + session.commit() + logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles) + return out -def reset_password(session: Session, user_id: int, new_password: str) -> None: +def reset_password( + session: Session, + user_id: int, + new_password: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: """重置用户密码。 同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** —— @@ -158,16 +231,35 @@ def reset_password(session: Session, user_id: int, new_password: str) -> None: :raises NotFoundError: 用户不存在 """ user = user_repository.get_by_id(session, user_id) - if user is None: - raise NotFoundError("用户不存在") + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_RESET_PASSWORD, + summary=f"重置用户 {user.username if user else f'#{user_id}'} 的密码", + # 明文进审计管道,由 repository 层的结构化脱敏整体抹除 + request_data={"new_password": new_password}, + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") - user.password = hash_password(new_password) - user.token_version += 1 - session.commit() - logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id) + user.password = hash_password(new_password) + user.token_version += 1 + session.commit() + logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id) -def set_status(session: Session, user_id: int, status: int, *, operator_id: int) -> UserOut: +def set_status( + session: Session, + user_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> UserOut: """启用或禁用用户。 禁用时同样提升 `token_version`,使已签发令牌立即失效 —— @@ -177,21 +269,40 @@ def set_status(session: Session, user_id: int, status: int, *, operator_id: int) :raises ForbiddenError: 试图禁用自己 """ user = user_repository.get_by_id(session, user_id) - if user is None: - raise NotFoundError("用户不存在") + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}用户 {user.username if user else f'#{user_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") - if status == 0 and user_id == operator_id: - raise ForbiddenError("不能禁用自己") + if status == 0 and user_id == operator.id: + raise ForbiddenError("不能禁用自己") - user.status = status - if status == 0: - user.token_version += 1 - session.commit() - logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status) - return _to_out(user) + user.status = status + if status == 0: + user.token_version += 1 + out = _to_out(user) + session.commit() + logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status) + return out -def delete_user(session: Session, user_id: int, *, operator_id: int) -> None: +def delete_user( + session: Session, + user_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: """删除用户。 物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。 @@ -200,33 +311,50 @@ def delete_user(session: Session, user_id: int, *, operator_id: int) -> None: :raises ForbiddenError: 试图删除自己 """ user = user_repository.get_by_id(session, user_id) - if user is None: - raise NotFoundError("用户不存在") + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_USER, + action=ACTION_DELETE, + summary=f"删除用户 {user.username if user else f'#{user_id}'}", + ), + meta=meta, + ): + if user is None: + raise NotFoundError("用户不存在") - if user_id == operator_id: - raise ForbiddenError("不能删除自己") + if user_id == operator.id: + raise ForbiddenError("不能删除自己") - username = user.username - user_repository.delete(session, user) - session.commit() - logger.info("删除用户 | username=%s", username) + username = user.username # 固化:对象随删除失效 + user_repository.delete(session, user) + session.commit() + logger.info("删除用户 | username=%s", username) def list_roles(session: Session) -> list[RoleOut]: - """取全部启用角色,供前端下拉选择。""" - return [RoleOut.model_validate(role) for role in user_repository.get_all_roles(session)] + """取全部启用角色,供用户表单的角色选择使用。""" + return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)] def _resolve_roles(session: Session, role_codes: list[str]) -> list: - """把角色编码解析为角色对象,并校验全部存在。 + """把角色编码解析为角色对象,校验全部存在且**未停用**。 - :raises BadRequestError: 存在无效编码 + 停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前 + 加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用 + 角色的用户需先转为启用角色再保存。 + + :raises BadRequestError: 存在无效编码,或角色已停用 """ if not role_codes: raise BadRequestError("至少需要指定一个角色") - roles = user_repository.get_roles_by_codes(session, role_codes) + roles = role_repository.get_roles_by_codes(session, role_codes) if len(roles) != len(set(role_codes)): valid = {role.role_code for role in roles} invalid = sorted(set(role_codes) - valid) raise BadRequestError(f"角色编码无效:{invalid}") + disabled = sorted(role.role_code for role in roles if role.status != 1) + if disabled: + raise BadRequestError(f"角色已停用,不可分配:{disabled}") return list(roles) diff --git a/backend/tests/README.md b/backend/tests/README.md index cc75c6a..28151c8 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -1,6 +1,6 @@ # 后端测试 -> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。 +> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。 ## 一、测试约定 @@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成 | `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 | | `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 | +### 系统管理 · 用户与角色管理 + +| 测试文件 | 守什么 | 用例数 | 关键覆盖点 | +| --- | --- | :---: | --- | +| `unit/test_audit_service.py` | 审计上下文的语义 | 16 | 正常退出记成功、抛异常记失败;**失败时先 rollback 再写日志**且原异常原样重抛(顺序颠倒时数据库异常会让失败记录丢成 PendingRollbackError);**操作人标量在进入时固化**(失败路径不再触碰会过期的 ORM 对象);进入时脏会话告警(防嵌套半提交);审计自身失败不外抛、不替换业务异常 | +| `unit/test_audit_serialization.py` | 入库前截断与脱敏不丢数据、不泄密 | 16 | 各列按模型常量截断;60KB 请求参数按 **UTF-8 字节边界**截断后是原串的干净前缀(中文不切半);**结构化抹除**(按敏感键名整值替换):前导空白/空格/逗号/引号密码全被抹除且 JSON 结构完好、嵌套 dict/list 与键名大小写、非敏感键不误伤;手机号仍由 scrub 文本兜底;孤立代理字符不再抛错丢审计 | +| `unit/test_role_schemas.py` | 角色编码格式红线 | 16 | `^[a-z][a-z0-9_]{1,31}$` 全边界(大写/中文/数字或下划线开头/单字符/超长被拒);编辑模型**不接受 role_code**(多余字段忽略);状态只能 0/1 | +| `integration/test_user_management.py` | 用户 CRUD 串联正确性 + **审计留痕** | 23 | 分页以 `pageNum/pageSize` 生效(钉死别名契约,改蛇形会静默失效);自删/自禁/移除自己 admin 角色 403;禁用与重置密码使旧令牌立即失效;**每个写操作(含失败的)在 operation_logs 有对应记录**、request_data 全文扫不到明文密码;账号删除后审计行 `user_id` 置空而 username 保留;**编辑时保留既有角色不撞唯一键**(1062 回归);**编辑未修改字段不把 NULL 污染成空串**;**含下划线用户名不误配**(LIKE 转义回归) | +| `integration/test_role_management.py` | 角色保护规则(系统自举与授权安全的最低防线) | 13 | admin 角色禁删禁停 403;有绑定用户的角色禁删 400 且消息含用户数、解绑后可删;编码创建后不可改;**停用角色不可再分配**(接口层拦截);停用后不在 `/users/meta/roles` 但仍在管理列表;`user_count` 正确;普通用户 403 | + +### 系统管理 · 日志查询 + +| 测试文件 | 守什么 | 用例数 | 关键覆盖点 | +| --- | --- | :---: | --- | +| `unit/test_log_schemas.py` | 时间范围校验与时区归一 | 13 | 起始晚于结束拒绝(两类日志共用基类,放过去则 SQL 条件互斥、查询恒空且无提示);**带时区输入转本地 naive**(Z 后缀 → 东八区 08:00;混用 aware/naive 不再抛 TypeError 穿透 500);边界相等通过;仅传一端不校验 | +| `integration/test_log_query.py` | 写入与查询的**口径一致** | 13 | 写操作产生的审计可按 username/module/action/success/时间范围检索到,摘要含目标用户名;失败记录带中文 `error_message`;**列表不含 request_data、详情端点返回已脱敏参数**(两段式,含 404/403);起始晚于结束 400;登录日志直插造数后可按 ip 精确筛选;两类日志普通用户 403 | + ### 应用骨架与交付产物 | 测试文件 | 守什么 | 用例数 | 关键覆盖点 | @@ -71,6 +88,8 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 | | 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) | | 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 | +| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 | +| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) | ## 四、测试发现并修复的缺陷 @@ -85,14 +104,19 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 | | 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 | | 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 | +| 2026-10-10 | `replace_roles` 先清后建但中间未落库 | SQLAlchemy 在同一 flush 中**先处理 INSERT 再处理 DELETE**,编辑用户保留既有角色时重插同 `(user_id, role_id)` 撞唯一键(1062)——编辑用户接口 500。**由前端 e2e「编辑用户」步骤暴露**:集成测试当时只覆盖了「换掉全部角色」(无交集)的路径 | `clear()` 后立即 `session.flush()` 把删除落库,再追加新集合;补集成回归用例(编辑时保留既有角色) | +| 2026-10-10 | 审计脱敏依赖文本正则 | 三种逃逸形态:密码**以空白开头时完全不脱敏**、含空格/逗号只抹首段、含引号既残留明文又破坏 JSON——明文/残段写入 `operation_logs.request_data` 且日志页可直接展开查看(**由 code-review 发现并逐条实证复现**;既有测试所用密码均不含空格,恰好掩盖该路径) | 改为按键名的**结构化抹除**(递归 dict/list 整值替换 `***`),scrub 降级为文本兜底;补前导空白/空格/引号/嵌套/大小写五类边界用例 | +| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 | +| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 | +| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 | ## 五、待补用例 | 模块 | 状态 | 说明 | | --- | --- | --- | -| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 | -| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) | | 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 | | 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) | | 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) | | 报表统计 | 未开始 | 依赖报表口径确认 | + +> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..be9fe07 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,169 @@ +"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生约定: + +- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有 + autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下, + 失败中断时会残留并影响后续用例的 total 断言) +- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id`` + 会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态 +""" + +import uuid + +import pytest +from sqlalchemy import delete, select + +from app.core.constants import ROLE_ADMIN, ROLE_USER +from app.core.db import get_session_factory +from app.core.security import create_access_token, hash_password +from app.models import OperationLog, Role, User, UserRole + +TEST_PASSWORD = "TestPwd@2026" +# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希 +# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT, +# 哈希值不被任何断言消费,复用一个哈希与每次重算等价 +_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD) + + +def _purge_test_data() -> None: + """按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。 + + ``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配, + 会把 tn... 这类无关账号一并卷入。 + """ + factory = get_session_factory() + with factory() as session: + users = list( + session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() + ) + ids = [row[0] for row in users] + names = [row[1] for row in users] + if ids: + session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids))) + if names: + session.execute(delete(OperationLog).where(OperationLog.username.in_(names))) + session.execute(delete(User).where(User.id.in_(ids))) + + role_ids = list( + session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars() + ) + if role_ids: + session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) + session.execute(delete(Role).where(Role.id.in_(role_ids))) + session.commit() + + +@pytest.fixture(autouse=True) +def _purge_after_each_test(): + """每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" + yield + _purge_test_data() + + +@pytest.fixture +def make_user(): + """独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。 + + 不复用真实 admin —— 测试会改密码、提 token_version、删除账号, + 污染真实账号会让后续手工验证产生困惑。 + """ + + def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict: + codes = role_codes if role_codes is not None else [ROLE_USER] + name = username or f"t_{uuid.uuid4().hex[:8]}" + factory = get_session_factory() + with factory() as session: + roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars()) + assert len(roles) == len(codes), f"角色不存在:{codes}" + user = User( + username=name, + password=_TEST_PASSWORD_HASH, + real_name="测试账号", + status=1, + ) + user.user_roles = [UserRole(role_id=role.id) for role in roles] + session.add(user) + session.commit() + return {"id": user.id, "username": name, "password": TEST_PASSWORD} + + return _factory + + +@pytest.fixture +def auth_headers(): + """令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。 + + 绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准 + (`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis + 限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效, + 再次调用本工厂可换新令牌。 + """ + + def _factory(user: dict) -> dict[str, str]: + factory = get_session_factory() + with factory() as session: + db_user = session.get(User, user["id"]) + assert db_user is not None, "账号已被删除,无法签发令牌" + roles = [link.role.role_code for link in db_user.user_roles if link.role] + token, _, _ = create_access_token( + user_id=db_user.id, + token_version=db_user.token_version, + roles=roles, + ) + return {"Authorization": f"Bearer {token}"} + + return _factory + + +@pytest.fixture +def make_role(): + """独立测试角色工厂(ORM 直建,用后删除)。 + + 清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。 + """ + created: list[int] = [] + + def _factory(**overrides: object) -> dict: + code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}")) + name = str(overrides.pop("role_name", "测试角色")) + factory = get_session_factory() + with factory() as session: + role = Role( + role_code=code, + role_name=name, + description=overrides.pop("description", None), + sort_order=overrides.pop("sort_order", 99), + status=overrides.pop("status", 1), + **overrides, + ) + session.add(role) + session.commit() + created.append(role.id) + return {"id": role.id, "role_code": code, "role_name": name} + + yield _factory + + factory = get_session_factory() + with factory() as session: + session.execute(delete(UserRole).where(UserRole.role_id.in_(created))) + session.execute(delete(Role).where(Role.id.in_(created))) + session.commit() + + +@pytest.fixture +def admin(make_user, auth_headers) -> dict: + """管理员账号(含请求头)。""" + user = make_user([ROLE_ADMIN]) + user["headers"] = auth_headers(user) + return user + + +@pytest.fixture +def normal(make_user, auth_headers) -> dict: + """普通用户账号(含请求头)。""" + user = make_user([ROLE_USER]) + user["headers"] = auth_headers(user) + return user diff --git a/backend/tests/integration/test_log_query.py b/backend/tests/integration/test_log_query.py new file mode 100644 index 0000000..29206bd --- /dev/null +++ b/backend/tests/integration/test_log_query.py @@ -0,0 +1,198 @@ +"""日志查询接口集成测试(需要 MySQL 与 Redis)。 + +守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** —— +写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入 +没查询"或"查询条件对不上",日志功能形同虚设)。 + +登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确 +控制 success/fail_reason/ip 组合。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid +from datetime import datetime, timedelta + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.core.db import get_session_factory +from app.models import LoginLog + +pytestmark = pytest.mark.integration + +_OPERATION_LOGS = "/api/v1/system/operation-logs" +_LOGIN_LOGS = "/api/v1/system/login-logs" +_USERS = "/api/v1/system/users" + + +def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None: + """触发一条审计记录(失败操作最省事、不留业务数据)。""" + resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + assert action == "删除" + + +class TestOperationLogQuery: + """操作日志查询。""" + + def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 200 + + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "module": "用户管理", "action": "删除"}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] >= 1 + row = data["list"][0] + assert row["success"] == 1 + assert target["username"] in row["summary"] + assert row["module"] == "用户管理" + + def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None: + _make_audit(client, admin) + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "success": 0}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] >= 1 + assert data["list"][0]["error_message"] + + def test_时间范围筛选(self, client: TestClient, admin: dict) -> None: + _make_audit(client, admin) + now = datetime.now() + fmt = "%Y-%m-%d %H:%M:%S" + + resp = client.get( + _OPERATION_LOGS, + params={ + "username": admin["username"], + "start_time": (now - timedelta(minutes=5)).strftime(fmt), + "end_time": (now + timedelta(minutes=5)).strftime(fmt), + }, + headers=admin["headers"], + ) + assert resp.json()["data"]["total"] >= 1 + + # 已过去的时间窗(排除测试刚产生的记录) + resp = client.get( + _OPERATION_LOGS, + params={ + "username": admin["username"], + "start_time": "2020-01-01 00:00:00", + "end_time": "2020-01-02 00:00:00", + }, + headers=admin["headers"], + ) + assert resp.json()["data"]["total"] == 0 + + def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None: + resp = client.get( + _OPERATION_LOGS, + params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None: + """列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。""" + resp = client.get(_OPERATION_LOGS, headers=admin["headers"]) + rows = resp.json()["data"]["list"] + if rows: + assert "request_data" not in rows[0] + + def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None: + """列表 → 详情 的两段式:详情含 request_data 且已脱敏。""" + secret = "S3cret@2026!" + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": secret}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + + resp = client.get( + _OPERATION_LOGS, + params={"username": admin["username"], "action": "重置密码"}, + headers=admin["headers"], + ) + row = resp.json()["data"]["list"][0] + assert "request_data" not in row + + detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"]) + assert detail.status_code == 200 + request_data = detail.json()["data"]["request_data"] or "" + assert secret not in request_data + assert "***" in request_data + + def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None: + resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None: + resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"]) + assert resp.status_code == 403 + + def test_普通用户被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403 + + +class TestLoginLogQuery: + """登录日志查询。""" + + @pytest.fixture + def seeded_logs(self): + """直插两条登录日志(一成功一失败),用后清理。""" + username = f"t_login_{uuid.uuid4().hex[:6]}" + factory = get_session_factory() + with factory() as session: + session.add( + LoginLog( + username=username, + ip="10.1.2.3", + user_agent="pytest", + success=0, + fail_reason="用户名或密码错误", + ) + ) + session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1)) + session.commit() + yield username + with factory() as session: + session.execute(delete(LoginLog).where(LoginLog.username == username)) + session.commit() + + def test_按用户名查询并返回两端结果( + self, client: TestClient, admin: dict, seeded_logs: str + ) -> None: + resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"]) + data = resp.json()["data"] + assert data["total"] == 2 + assert {row["success"] for row in data["list"]} == {0, 1} + + def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None: + resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"]) + data = resp.json()["data"] + assert data["total"] == 1 + assert data["list"][0]["fail_reason"] == "用户名或密码错误" + + def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None: + resp = client.get( + _LOGIN_LOGS, + params={"username": seeded_logs, "success": 0}, + headers=admin["headers"], + ) + data = resp.json()["data"] + assert data["total"] == 1 + assert data["list"][0]["success"] == 0 + + def test_普通用户被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403 diff --git a/backend/tests/integration/test_role_management.py b/backend/tests/integration/test_role_management.py new file mode 100644 index 0000000..64b9a21 --- /dev/null +++ b/backend/tests/integration/test_role_management.py @@ -0,0 +1,209 @@ +"""角色管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是角色管理的**保护规则** —— 它们不是界面体验,而是系统自举与授权 +安全的最低防线: + +- `admin` 角色禁删禁停(cli 建管理员与 require_admin 判定都依赖该编码) +- 有用户绑定的角色禁删(user_roles 外键是 CASCADE,直接删会静默剥离权限) +- `role_code` 创建后不可改(授权判定的锚点) + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.core.constants import ROLE_USER + +pytestmark = pytest.mark.integration + +_ROLES = "/api/v1/system/roles" + + +def _new_code() -> str: + return f"t_role_{uuid.uuid4().hex[:6]}" + + +def _admin_role_id(client: TestClient, admin: dict) -> int: + """从管理列表取内置 admin 角色的 id。""" + resp = client.get(_ROLES, params={"keyword": "admin"}, headers=admin["headers"]) + rows = resp.json()["data"]["list"] + matches = [row for row in rows if row["role_code"] == "admin"] + assert matches, "内置 admin 角色缺失" + return matches[0]["id"] + + +class TestCrudFlow: + """正向链路:建 → 查(列表 + 下拉)→ 改 → 停用 → 删。""" + + def test_新增角色并联动列表与下拉(self, client: TestClient, admin: dict) -> None: + code = _new_code() + resp = client.post( + _ROLES, + json={"role_code": code, "role_name": "对外协作岗", "sort_order": 50}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + role_id = resp.json()["data"]["id"] + assert resp.json()["data"]["user_count"] == 0 + + # 管理列表可查(keyword 同时匹配编码与名称) + resp = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"]) + assert resp.json()["data"]["total"] == 1 + + # 用户表单下拉包含(启用状态) + resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"]) + assert code in [row["role_code"] for row in resp.json()["data"]] + + assert client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]).status_code == 200 + + def test_重复编码被拒(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.post( + _ROLES, + json={"role_code": role["role_code"], "role_name": "重复"}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "已存在" in resp.json()["message"] + + def test_编码格式非法被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _ROLES, json={"role_code": "BadCode", "role_name": "大写"}, headers=admin["headers"] + ) + assert resp.status_code == 400 + + def test_编辑角色名称与排序(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}", + json={"role_name": "改名后", "description": "新的说明", "sort_order": 7}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["role_name"] == "改名后" + assert data["description"] == "新的说明" + assert data["sort_order"] == 7 + + def test_编码不可被修改(self, client: TestClient, admin: dict, make_role) -> None: + """请求体携带 role_code 会被忽略(编辑模型不接收该字段),编码保持不变。""" + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}", + json={"role_name": "改名", "role_code": "hacked"}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert resp.json()["data"]["role_code"] == role["role_code"] + + def test_停用后不在下拉但仍在管理列表(self, client: TestClient, admin: dict, make_role) -> None: + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == 0 + + resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"]) + assert role["role_code"] not in [row["role_code"] for row in resp.json()["data"]] + + resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"]) + assert resp.json()["data"]["total"] == 1 + + +class TestProtection: + """保护规则:内置角色与有绑定的角色。""" + + def test_admin角色不可删除(self, client: TestClient, admin: dict) -> None: + role_id = _admin_role_id(client, admin) + resp = client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]) + assert resp.status_code == 403 + assert "不可删除" in resp.json()["message"] + + def test_admin角色不可停用(self, client: TestClient, admin: dict) -> None: + role_id = _admin_role_id(client, admin) + resp = client.put( + f"{_ROLES}/{role_id}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 403 + assert "不可停用" in resp.json()["message"] + + def test_有绑定的角色不可删除且解绑后可删除( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + """`user_roles` 外键是 CASCADE —— 后端 400 拦截是防止静默剥离权限的 + 唯一防线,前端按钮禁用只是体验。""" + role = make_role() + bound = make_user([role["role_code"]]) + + resp = client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]) + assert resp.status_code == 400 + assert "1 个用户" in resp.json()["message"] + + # 解绑(换回内置 user 角色)后可删除 + resp = client.put( + f"/api/v1/system/users/{bound['id']}", + json={"roles": [ROLE_USER]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]).status_code == 200 + + def test_列表返回绑定用户数( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + role = make_role() + make_user([role["role_code"]]) + resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"]) + assert resp.json()["data"]["list"][0]["user_count"] == 1 + + def test_编辑不存在的角色404(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_ROLES}/999999999", json={"role_name": "不存在"}, headers=admin["headers"] + ) + assert resp.status_code == 404 + + def test_停用角色不可再分配给用户( + self, client: TestClient, admin: dict, make_role, make_user + ) -> None: + """回归:停用只挡住了表单下拉,接口层此前仍可绑定(或使用停用前 + 加载的旧选项提交)—— 与「停用后不可再分配」的语义矛盾。""" + role = make_role() + resp = client.put( + f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + + username = f"t_{uuid.uuid4().hex[:8]}" + resp = client.post( + "/api/v1/system/users", + json={"username": username, "password": "TestPwd@2026", "roles": [role["role_code"]]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "停用" in resp.json()["message"] + + # 已绑定该角色的用户(历史数据)改回启用角色后可正常保存 + bound = make_user([role["role_code"]]) + resp = client.put( + f"/api/v1/system/users/{bound['id']}", + json={"roles": [ROLE_USER]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + + +class TestAuthorization: + """权限:角色管理为管理员专属。""" + + def test_普通用户访问被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_ROLES, headers=normal["headers"]).status_code == 403 + resp = client.post( + _ROLES, + json={"role_code": _new_code(), "role_name": "越权"}, + headers=normal["headers"], + ) + assert resp.status_code == 403 diff --git a/backend/tests/integration/test_user_management.py b/backend/tests/integration/test_user_management.py new file mode 100644 index 0000000..a48a61f --- /dev/null +++ b/backend/tests/integration/test_user_management.py @@ -0,0 +1,364 @@ +"""用户管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是:用户 CRUD 的**串联正确性**与**审计留痕**。 + +审计是重点验收项:每个写操作(含失败的)都必须在 operation_logs 留下 +可读记录 —— 模块、操作、摘要、操作人、请求元信息完整,且 request_data +中**绝无明文密码**;账号被删除后审计行通过「user_id 置空 + 冗余 username」 +保留可追溯性。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_PASSWORD = "TestPwd@2026" +_USERS = "/api/v1/system/users" + + +def _new_username() -> str: + return f"t_{uuid.uuid4().hex[:8]}" + + +def _create_via_api(client: TestClient, admin: dict, username: str, **overrides: object) -> dict: + """经接口创建用户并返回响应 data(账号归 autouse 清理兜底)。""" + payload: dict = { + "username": username, + "password": _PASSWORD, + "real_name": "接口新用户", + "roles": ["user"], + } + payload.update(overrides) + resp = client.post(_USERS, json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人最近的审计行(按 id 倒序)。""" + factory = get_session_factory() + with factory() as session: + rows = list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + return rows + + +class TestCrudFlow: + """正向链路:建 → 查 → 改 → 启停 → 重置密码 → 删。""" + + def test_新增用户并可在列表查到(self, client: TestClient, admin: dict) -> None: + username = _new_username() + data = _create_via_api(client, admin, username) + assert data["username"] == username + assert data["roles"] == ["user"] + assert data["status"] == 1 + + resp = client.get(_USERS, params={"username": username}, headers=admin["headers"]) + body = resp.json()["data"] + assert body["total"] == 1 + assert body["list"][0]["id"] == data["id"] + + def test_重名用户被拒(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.post( + _USERS, + json={"username": target["username"], "password": _PASSWORD, "roles": ["user"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "已存在" in resp.json()["message"] + + def test_分页参数以驼峰命名生效(self, client: TestClient, admin: dict) -> None: + """pageNum/pageSize 是前端契约(PageQuery 的 alias)。 + + 防回归:若被改成蛇形命名,FastAPI 会忽略这两个参数、恒返回 + 第 1 页 10 条 —— 表现为「翻页没反应」,且没有任何报错。 + """ + resp = client.get(_USERS, params={"pageNum": 2, "pageSize": 5}, headers=admin["headers"]) + data = resp.json()["data"] + assert {"list", "total", "pageNum", "pageSize"} <= set(data) + assert data["pageNum"] == 2 + assert data["pageSize"] == 5 + assert len(data["list"]) <= 5 + + def test_编辑用户信息与角色(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "改后姓名", "roles": ["admin"]}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["real_name"] == "改后姓名" + assert data["roles"] == ["admin"] + + def test_编辑用户保留既有角色不触发唯一键冲突( + self, client: TestClient, admin: dict, make_user + ) -> None: + """回归:新旧角色集合有交集时(编辑时未改角色必然如此), + `replace_roles` 若在 clear 后不先落库删除,SQLAlchemy 同一 flush + 内先 INSERT 后 DELETE 会撞 uk_user_roles_user_id_role_id(1062)。 + 该缺陷最初由前端 e2e「编辑用户」步骤暴露 —— 集成测试当时只覆盖 + 了「换掉全部角色」的场景。""" + target = make_user(["user"]) + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "保留角色", "roles": ["user", "admin"]}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + assert sorted(resp.json()["data"]["roles"]) == ["admin", "user"] + + def test_禁用与重新启用(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.put( + f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == 0 + + resp = client.put( + f"{_USERS}/{target['id']}/status", json={"status": 1}, headers=admin["headers"] + ) + assert resp.json()["data"]["status"] == 1 + + def test_禁用后旧令牌立即失效( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + """禁用会提 token_version —— 不依赖 Redis 的「离职即失效」。""" + target = make_user() + old_headers = auth_headers(target) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200 + + client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401 + + def test_重置密码后旧令牌立即失效( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + target = make_user() + old_headers = auth_headers(target) + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200 + + resp = client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": "NewPwd@2026"}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401 + + def test_删除用户(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 200 + + resp = client.get(f"{_USERS}/{target['id']}", headers=admin["headers"]) + assert resp.status_code == 404 + + +class TestSelfProtection: + """自我保护:不允许对当前登录账号执行危险操作。""" + + def test_不能删除自己(self, client: TestClient, admin: dict) -> None: + resp = client.delete(f"{_USERS}/{admin['id']}", headers=admin["headers"]) + assert resp.status_code == 403 + + def test_不能禁用自己(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_USERS}/{admin['id']}/status", json={"status": 0}, headers=admin["headers"] + ) + assert resp.status_code == 403 + + def test_不能移除自己的管理员角色(self, client: TestClient, admin: dict) -> None: + resp = client.put( + f"{_USERS}/{admin['id']}", json={"roles": ["user"]}, headers=admin["headers"] + ) + assert resp.status_code == 403 + + +class TestValidation: + """参数与业务校验。""" + + def test_无效角色编码被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": ["nonexist"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + assert "角色编码无效" in resp.json()["message"] + + def test_空角色列表被拒(self, client: TestClient, admin: dict) -> None: + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": []}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_密码25个汉字被拒(self, client: TestClient, admin: dict) -> None: + """75 字节超 bcrypt 上限;按字符数校验拦不住(25 字符很短)。""" + resp = client.post( + _USERS, + json={"username": _new_username(), "password": "中" * 25, "roles": ["user"]}, + headers=admin["headers"], + ) + assert resp.status_code == 400 + + def test_查询不存在的用户返回404(self, client: TestClient, admin: dict) -> None: + resp = client.get(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + def test_编辑未修改字段不污染空值语义(self, client: TestClient, admin: dict, make_user) -> None: + """回归:编辑表单把未修改字段以空串提交,此前会把库中 NULL 写成 ''—— + 两类空值(未填 vs 空串)语义漂移且不可逆;'' 现归一为 NULL。""" + target = make_user() # 直建账号 phone/email/remark 均为 NULL + resp = client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "改名", "phone": "", "email": "", "remark": ""}, + headers=admin["headers"], + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["real_name"] == "改名" + assert data["phone"] is None + assert data["email"] is None + assert data["remark"] is None + + +class TestFiltering: + """筛选条件的口径。""" + + def test_含下划线的用户名不误配(self, client: TestClient, admin: dict, make_user) -> None: + """回归:模糊筛选未转义 LIKE 通配符时 `_` 匹配任意单字符, + 搜 t_xx 会命中 txXx 之类无关账号 —— 用户名普遍含下划线,非理论场景。""" + target = make_user() + decoy_name = target["username"].replace("_", "x", 1) + make_user(username=decoy_name) + + resp = client.get(_USERS, params={"username": target["username"]}, headers=admin["headers"]) + names = [row["username"] for row in resp.json()["data"]["list"]] + assert target["username"] in names + assert decoy_name not in names + + +class TestAuditTrail: + """审计留痕 —— 本模块的核心验收点。""" + + def test_每个写操作都留下审计记录(self, client: TestClient, admin: dict, make_user) -> None: + target = make_user() + client.put( + f"{_USERS}/{target['id']}", + json={"real_name": "审计留痕"}, + headers=admin["headers"], + ) + client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]) + client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": "AuditPwd@2026"}, + headers=admin["headers"], + ) + client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"]) + + logs = _logs_of(admin["username"]) + actions = {(log.module, log.action) for log in logs} + assert ("用户管理", "修改") in actions + assert ("用户管理", "禁用") in actions + assert ("用户管理", "重置密码") in actions + assert ("用户管理", "删除") in actions + + # 摘要含目标用户名(供人阅读的追溯锚点) + assert any(target["username"] in log.summary for log in logs) + + # 请求元信息与结果完整 + success_logs = [log for log in logs if log.success == 1] + assert success_logs + for log in success_logs: + assert log.method and log.path + assert log.duration_ms is not None and log.duration_ms >= 0 + + def test_失败操作记为失败且带中文原因(self, client: TestClient, admin: dict) -> None: + resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"]) + assert resp.status_code == 404 + + latest = _logs_of(admin["username"])[0] + assert latest.success == 0 + assert latest.action == "删除" + assert latest.error_message == "用户不存在" + + def test_审计请求参数绝不包含明文密码(self, client: TestClient, admin: dict, make_user) -> None: + """密码是审计脱敏的红线:全表扫描该操作人的 request_data 不得命中。""" + secret = "S3cret@2026!" + _create_via_api(client, admin, _new_username(), password=secret) + target = make_user() + client.put( + f"{_USERS}/{target['id']}/password", + json={"new_password": secret}, + headers=admin["headers"], + ) + + factory = get_session_factory() + with factory() as session: + rows = list( + session.execute( + select(OperationLog.request_data).where( + OperationLog.username == admin["username"] + ) + ).scalars() + ) + joined = "\n".join(row for row in rows if row) + assert secret not in joined + assert "***" in joined # 键名保留、值被抹除 + + def test_账号删除后审计行保留用户名( + self, client: TestClient, admin: dict, make_user, auth_headers + ) -> None: + """user_id 外键 SET NULL + username 冗余 —— 删除账号不得让审计断链。""" + victim = make_user(["admin"]) + victim_headers = auth_headers(victim) + # victim 作为操作人产生一条审计(失败操作最省事,不留业务数据) + client.delete(f"{_USERS}/999999999", headers=victim_headers) + + assert ( + client.delete(f"{_USERS}/{victim['id']}", headers=admin["headers"]).status_code == 200 + ) + + factory = get_session_factory() + with factory() as session: + logs = list( + session.execute( + select(OperationLog).where(OperationLog.username == victim["username"]) + ).scalars() + ) + assert logs, "审计行不应随账号删除而消失" + assert all(log.user_id is None for log in logs) + assert all(log.username == victim["username"] for log in logs) + + +class TestAuthorization: + """权限:系统管理为管理员专属。""" + + def test_普通用户访问用户管理被拒(self, client: TestClient, normal: dict) -> None: + assert client.get(_USERS, headers=normal["headers"]).status_code == 403 + resp = client.post( + _USERS, + json={"username": _new_username(), "password": _PASSWORD, "roles": ["user"]}, + headers=normal["headers"], + ) + assert resp.status_code == 403 diff --git a/backend/tests/unit/test_audit_serialization.py b/backend/tests/unit/test_audit_serialization.py new file mode 100644 index 0000000..9f10873 --- /dev/null +++ b/backend/tests/unit/test_audit_serialization.py @@ -0,0 +1,128 @@ +"""操作日志入库前处理单元测试。 + +守的是入库前的**截断与脱敏**: + +- 截断:各列超长在 MySQL 严格模式下抛 1406,而「审计失败不影响业务」的约定 + 会把该异常吞掉 —— 整条日志静默消失;request_data 尤甚,中文一个字符 + 占 3 字节,必须按 **UTF-8 字节边界**切,否则会切出乱码残字 +- 脱敏:密码是最容易从日志泄漏的凭据,request_data 入库前必经 scrub +""" + +import json +from datetime import datetime + +from app.models.operation_log import ( + ERROR_MESSAGE_MAX_LEN, + REQUEST_DATA_MAX_BYTES, + SUMMARY_MAX_LEN, +) +from app.repositories.operation_log_repository import _clip, _serialize_request_data + + +class TestClip: + """按字符数截断(长度常量与数据库列宽均按字符定义)。""" + + def test_超长截断至上限(self) -> None: + result = _clip("x" * (SUMMARY_MAX_LEN + 50), SUMMARY_MAX_LEN) + assert result is not None + assert len(result) == SUMMARY_MAX_LEN + + def test_未超长原样返回(self) -> None: + assert _clip("正常长度", SUMMARY_MAX_LEN) == "正常长度" + + def test_空值返回None(self) -> None: + assert _clip(None, ERROR_MESSAGE_MAX_LEN) is None + assert _clip("", ERROR_MESSAGE_MAX_LEN) is None + + def test_中文按字符数截断不切字(self) -> None: + """str 切片天然按字符,不会切出半个汉字。""" + result = _clip("中" * 100, 10) + assert result == "中" * 10 + + +class TestSerializeRequestData: + """序列化:JSON 化 → 脱敏 → 字节边界截断。""" + + def test_常规参数原样序列化(self) -> None: + text = _serialize_request_data({"status": 0, "roles": ["user"]}) + assert text is not None + assert json.loads(text) == {"status": 0, "roles": ["user"]} + + def test_空值返回None(self) -> None: + assert _serialize_request_data(None) is None + assert _serialize_request_data({}) is None + + def test_密码值被抹除而键名保留(self) -> None: + """键名保留便于审计阅读"提交过密码字段",值必须抹掉。""" + for payload in ({"password": "S3cret@2026"}, {"new_password": "S3cret@2026"}): + text = _serialize_request_data(payload) or "" + assert "S3cret@2026" not in text + assert "***" in text + + def test_手机号被脱敏(self) -> None: + text = _serialize_request_data({"phone": "13812345678"}) or "" + assert "13812345678" not in text + assert "138****5678" in text + + def test_超长中文按字节边界截断且无残字(self) -> None: + """75000 字节(25000 个汉字)必然超限: + 若按字符截断则字节数三倍超标;若按字节硬切可能切出半个字符。 + 正确结果应是原始 JSON 串的**干净前缀**。""" + payload = {"remark": "中" * 25000} + text = _serialize_request_data(payload) + assert text is not None + assert len(text.encode("utf-8")) <= REQUEST_DATA_MAX_BYTES + assert "�" not in text # 无替换字符 + raw = json.dumps(payload, ensure_ascii=False) + assert raw.startswith(text) + + def test_未超长的中文完整保留(self) -> None: + payload = {"remark": "中" * 100} + text = _serialize_request_data(payload) + assert text is not None + assert json.loads(text) == payload + + def test_不可JSON序列化的值降级为字符串(self) -> None: + text = _serialize_request_data({"at": datetime(2026, 10, 10, 12, 0, 0)}) + assert text is not None + assert "2026-10-10" in text + + +class TestStructuredSanitization: + """结构化抹除 —— 按键名整值替换,不依赖文本正则。 + + 回归背景:此前用文本正则脱敏,有三种逃逸形态 —— 值以空白开头时 + 整体匹配失败(**完全不脱敏**);值含空格/逗号时只抹首段;值含引号时 + 既残留明文又破坏 JSON 结构。密码校验只查字节数、前后端都不 trim, + 这些形态都是正常可用密码。 + """ + + def test_前导空白密码被完全抹除(self) -> None: + """回归:此前 `" abc12345"` 原样明文入库。""" + text = _serialize_request_data({"new_password": " abc12345"}) or "" + assert "abc12345" not in text + assert json.loads(text) == {"new_password": "***"} + + def test_含空格逗号引号均整体抹除且JSON完整(self) -> None: + for password in ("abc 12345", "abc,def", 'abc"def', "abc&def"): + text = _serialize_request_data({"password": password}) or "" + assert password not in text + assert json.loads(text) == {"password": "***"} + + def test_嵌套结构与键名大小写(self) -> None: + text = ( + _serialize_request_data({"user": {"New_Password": "s3"}, "list": [{"TOKEN": "t"}]}) + or "" + ) + assert "s3" not in text + assert json.loads(text) == {"user": {"New_Password": "***"}, "list": [{"TOKEN": "***"}]} + + def test_非敏感键不受影响(self) -> None: + text = _serialize_request_data({"keyword": "a_b", "remark": "正常备注"}) or "" + assert json.loads(text) == {"keyword": "a_b", "remark": "正常备注"} + + def test_孤立代理字符不再抛错(self) -> None: + """回归:直接 encode('utf-8') 遇 \\ud800 抛 UnicodeEncodeError, + 该请求的审计记录会被兜底逻辑吞掉、静默丢失。""" + text = _serialize_request_data({"username": "a\ud800b"}) + assert text is not None diff --git a/backend/tests/unit/test_audit_service.py b/backend/tests/unit/test_audit_service.py new file mode 100644 index 0000000..8765df3 --- /dev/null +++ b/backend/tests/unit/test_audit_service.py @@ -0,0 +1,268 @@ +"""操作日志审计服务单元测试。 + +守的是 `audit()` 上下文管理器的语义: + +- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播** +- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等 + 数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError, + 最该留下的失败记录反而被静默丢掉 +- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM + 对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError + 令失败审计静默丢失 +- 审计自身的任何失败都不得外抛("审计可丢,业务不可断") +""" + +import logging +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from app.core.exceptions import BadRequestError +from app.models import OperationLog +from app.services.audit_service import AuditRecord, RequestMeta, audit + + +class _StubSession: + """最小会话桩:记录调用顺序与写入的日志对象,可注入失败。""" + + def __init__(self) -> None: + self.events: list[str] = [] + self.logs: list[OperationLog] = [] + self.fail_add = False + self.fail_commit = False + # audit 入口会巡检未提交变更(嵌套调用防护) + self.new: list = [] + self.dirty: list = [] + self.deleted: list = [] + + def add(self, obj: object) -> None: + if self.fail_add: + raise RuntimeError("模拟写入失败") + self.events.append("add") + self.logs.append(obj) # type: ignore[arg-type] + + def flush(self) -> None: + self.events.append("flush") + + def commit(self) -> None: + if self.fail_commit: + raise RuntimeError("模拟提交失败") + self.events.append("commit") + + def rollback(self) -> None: + self.events.append("rollback") + + +class _FrozenOperator: + """只允许读取固定轮数属性的操作人桩。 + + 模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性, + 真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError), + 此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。 + """ + + def __init__(self, max_reads: int = 2) -> None: + self._max_reads = max_reads + self._reads = 0 + + def _touch(self) -> None: + self._reads += 1 + if self._reads > self._max_reads: + raise RuntimeError("进入审计后不应再访问操作人 ORM 属性") + + @property + def id(self) -> int: + self._touch() + return 7 + + @property + def username(self) -> str: + self._touch() + return "admin" + + +def _operator() -> SimpleNamespace: + """模拟已认证用户(audit 只读取 id 与 username 两个属性)。""" + return SimpleNamespace(id=7, username="admin") + + +def _record(**overrides: object) -> AuditRecord: + base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"} + base.update(overrides) + return AuditRecord(**base) + + +def _audit( + stub: _StubSession, + *, + request_data: dict | None = None, + meta: RequestMeta | None = None, +): + return audit( + stub, # type: ignore[arg-type] + operator=_operator(), + record=_record(request_data=request_data), + meta=meta, + ) + + +class TestSuccessPath: + """正常退出:业务完成后记成功并独立提交。""" + + def test_成功路径写入一条成功日志(self) -> None: + stub = _StubSession() + with _audit(stub): + pass + assert len(stub.logs) == 1 + log = stub.logs[0] + assert log.success == 1 + assert log.error_message is None + assert log.module == "用户管理" + assert log.action == "新增" + assert log.username == "admin" + assert log.user_id == 7 + + def test_日志提交发生在业务逻辑之后(self) -> None: + """审计是业务的"后置动作":业务先 commit,日志再独立 commit。""" + stub = _StubSession() + with _audit(stub): + stub.events.append("business") + assert stub.events == ["business", "add", "flush", "commit"] + + def test_请求元信息与耗时被写入(self) -> None: + stub = _StubSession() + meta = RequestMeta( + ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users" + ) + with _audit(stub, meta=meta): + pass + log = stub.logs[0] + assert log.method == "POST" + assert log.path == "/api/v1/system/users" + assert log.ip == "10.0.0.8" + assert log.user_agent == "pytest-agent" + assert log.duration_ms is not None + assert log.duration_ms >= 0 + + def test_请求参数经脱敏后写入(self) -> None: + stub = _StubSession() + with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}): + pass + request_data = stub.logs[0].request_data or "" + assert "Secret@2026" not in request_data + assert "zhangsan" in request_data + + +class TestFailurePath: + """业务异常:先回滚、记失败、异常原样重抛。""" + + def test_失败时原异常原样重抛(self) -> None: + stub = _StubSession() + with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub): + raise BadRequestError("角色编码无效") + + def test_失败记录在回滚之后写入(self) -> None: + """顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败 + 记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。""" + stub = _StubSession() + with pytest.raises(BadRequestError), _audit(stub): + stub.events.append("business") + raise BadRequestError("用户名重复") + assert stub.events == ["business", "rollback", "add", "flush", "commit"] + log = stub.logs[0] + assert log.success == 0 + assert log.error_message == "用户名重复" + + def test_未预期异常不泄漏内部信息(self) -> None: + """未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。""" + stub = _StubSession() + with pytest.raises(ValueError, match="内部实现细节"), _audit(stub): + raise ValueError("内部实现细节") + assert stub.logs[0].error_message == "服务端异常,请稍后重试" + + +class TestOperatorFrozen: + """操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。""" + + def test_成功路径只读取一轮操作人属性(self) -> None: + stub = _StubSession() + with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type] + pass + assert stub.logs[0].username == "admin" + + def test_失败路径不再触碰操作人对象(self) -> None: + """回归:此前失败路径在 rollback 之后读 operator.id/username —— + 真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError + 还会被兜底吞掉,最该留痕的失败审计静默丢失。""" + stub = _StubSession() + with ( + pytest.raises(BadRequestError, match="用户不存在"), + audit( + stub, # type: ignore[arg-type] + operator=_FrozenOperator(), + record=_record(action="删除", summary="删除用户 x"), + ), + ): + raise BadRequestError("用户不存在") + assert stub.logs[0].user_id == 7 + assert stub.logs[0].username == "admin" + + +class TestDirtySessionGuard: + """进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。""" + + def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None: + stub = _StubSession() + stub.dirty.append(object()) + with caplog.at_level(logging.WARNING), _audit(stub): + pass + assert any("未提交的变更" in message for message in caplog.messages) + + def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None: + stub = _StubSession() + with caplog.at_level(logging.WARNING), _audit(stub): + pass + assert not caplog.messages + + +class TestResilience: + """审计失败不得影响业务。""" + + def test_写入失败不外抛且业务继续(self) -> None: + stub = _StubSession() + stub.fail_add = True + reached = False + with _audit(stub): + reached = True + assert reached is True # 审计写入失败不打断业务流 + + def test_提交失败不外抛(self) -> None: + stub = _StubSession() + stub.fail_commit = True + with _audit(stub): + pass # 正常退出,不抛异常 + + def test_业务失败时审计失败不以审计异常替换原异常(self) -> None: + """业务异常是用户看到的原因,不能因审计自身失败而被替换。""" + stub = _StubSession() + stub.fail_add = True + with pytest.raises(BadRequestError, match="业务错误"), _audit(stub): + raise BadRequestError("业务错误") + + +class TestSerializationEdge: + """审计参数的序列化边界(细节覆盖见 test_audit_serialization)。""" + + def test_请求参数可为None(self) -> None: + stub = _StubSession() + with _audit(stub, request_data=None): + pass + assert stub.logs[0].request_data is None + + def test_不可JSON序列化的值被降级为字符串(self) -> None: + """如 datetime 等对象经 default=str 降级,序列化不应抛异常。""" + stub = _StubSession() + with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}): + pass + assert "2026-10-10" in (stub.logs[0].request_data or "") diff --git a/backend/tests/unit/test_log_schemas.py b/backend/tests/unit/test_log_schemas.py new file mode 100644 index 0000000..89ade25 --- /dev/null +++ b/backend/tests/unit/test_log_schemas.py @@ -0,0 +1,92 @@ +"""日志查询模型单元测试。 + +守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去 +的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了"; +带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较 +抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。 +""" + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from app.schemas.log import LoginLogQuery, OperationLogQuery + + +class TestTimeRange: + """start_time ≤ end_time 校验(两类日志共用基类)。""" + + def test_合法范围通过(self) -> None: + query = OperationLogQuery( + start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0) + ) + assert query.start_time is not None + + def test_起始晚于结束被拒(self) -> None: + with pytest.raises(ValidationError): + OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1)) + + def test_登录日志同样校验(self) -> None: + with pytest.raises(ValidationError): + LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1)) + + def test_边界相等通过(self) -> None: + moment = datetime(2026, 10, 1, 12, 0, 0) + assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment + + def test_仅传一端不校验(self) -> None: + assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None + assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None + + +class TestQueryFields: + """筛选字段边界。""" + + def test_成功标记只能为0或1(self) -> None: + assert OperationLogQuery(success=0).success == 0 + with pytest.raises(ValidationError): + OperationLogQuery(success=2) + + def test_分页别名与偏移(self) -> None: + query = OperationLogQuery(pageNum=2, pageSize=50) + assert query.offset == 50 + + def test_操作日志筛选字段(self) -> None: + query = OperationLogQuery(module="用户管理", action="删除", username="adm") + assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm") + + def test_登录日志按IP精确筛选(self) -> None: + assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1" + + +class TestTimezoneNormalization: + """时区归一。 + + 回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传 + 曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。 + """ + + def test_utc后缀被转换为本地时间(self) -> None: + """Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。""" + query = OperationLogQuery(start_time="2026-10-01T00:00:00Z") + assert query.start_time == datetime(2026, 10, 1, 8, 0, 0) + + def test_混合aware与naive不再抛比较错误(self) -> None: + query = OperationLogQuery( + start_time=datetime(2026, 10, 1, 0, 0, 0), + end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC), + ) + assert query.end_time is not None + assert query.end_time.tzinfo is None # 已摘除 + + def test_归一后范围校验仍按本地口径生效(self) -> None: + with pytest.raises(ValidationError): + OperationLogQuery( + start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00 + end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始 + ) + + def test_naive时间视为本地原样保留(self) -> None: + moment = datetime(2026, 10, 1, 12, 0, 0) + assert OperationLogQuery(start_time=moment).start_time == moment diff --git a/backend/tests/unit/test_role_schemas.py b/backend/tests/unit/test_role_schemas.py new file mode 100644 index 0000000..0345558 --- /dev/null +++ b/backend/tests/unit/test_role_schemas.py @@ -0,0 +1,98 @@ +"""角色请求模型单元测试。 + +守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码 +比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 —— +放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn + + +def _create(**overrides: object) -> RoleCreateIn: + base: dict[str, object] = {"role_code": "finance", "role_name": "财务"} + base.update(overrides) + return RoleCreateIn(**base) + + +class TestRoleCodePattern: + """编码规则:^[a-z][a-z0-9_]{1,31}$""" + + def test_合法编码通过(self) -> None: + assert _create().role_code == "finance" + + def test_下划线与数字通过(self) -> None: + assert _create(role_code="sales_01").role_code == "sales_01" + + def test_大写字母被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="Finance") + + def test_中文被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="财务") + + def test_数字开头被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="1abc") + + def test_下划线开头被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="_abc") + + def test_单字符被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="a") + + def test_超长被拒(self) -> None: + with pytest.raises(ValidationError): + _create(role_code="a" * 33) + + def test_首尾空白被自动去除(self) -> None: + assert _create(role_code=" finance ").role_code == "finance" + + +class TestRoleUpdate: + """编辑模型:不含 role_code。""" + + def test_编辑不接受role_code(self) -> None: + """编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错 + (前端表单可能整份回填提交)。""" + payload = RoleUpdateIn(role_name="财务二组", role_code="hacked") + assert not hasattr(payload, "role_code") + + def test_名称为空被拒(self) -> None: + with pytest.raises(ValidationError): + RoleUpdateIn(role_name="") + + def test_排序负数被拒(self) -> None: + with pytest.raises(ValidationError): + RoleUpdateIn(role_name="财务", sort_order=-1) + + +class TestRoleStatusAndQuery: + """状态与查询模型。""" + + def test_状态只能为0或1(self) -> None: + assert RoleStatusIn(status=0).status == 0 + assert RoleStatusIn(status=1).status == 1 + with pytest.raises(ValidationError): + RoleStatusIn(status=2) + + def test_查询默认分页(self) -> None: + query = RoleQuery() + assert query.page_num == 1 + assert query.page_size == 10 + assert query.keyword is None + assert query.status is None + + def test_分页别名照常工作(self) -> None: + """pageNum/pageSize 是前端契约(PageQuery 的 alias)。""" + query = RoleQuery(pageNum=3, pageSize=5) + assert query.offset == 10 + + def test_关键词超长被拒(self) -> None: + with pytest.raises(ValidationError): + RoleQuery(keyword="a" * 65) -- 2.27.0 From 8355e0cc7dd6db05319db9ff4b016f1fd2e89afb Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 16:28:53 +0800 Subject: [PATCH 004/100] =?UTF-8?q?feat(system):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=EF=BC=88?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=A1=86=E6=9E=B6=E3=80=81=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E3=80=81e2e=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生; 路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回) - 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、 时间范围配 default-time、日志详情按需懒加载并缓存 - 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐), 「停用」文案全站统一为「禁用」 - 品牌色提升为全局 token,Element Plus 主色指向赭金 - e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言), 管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过 Co-Authored-By: Claude Code --- frontend/README.md | 34 +- frontend/e2e/common.py | 153 ++++++ frontend/e2e/login_flow.py | 102 ++-- frontend/e2e/system_management.py | 401 +++++++++++++++ frontend/src/api/logs.ts | 78 +++ frontend/src/api/roles.ts | 54 ++ frontend/src/api/types.ts | 25 + frontend/src/api/users.ts | 86 ++++ frontend/src/assets/styles/index.css | 41 +- frontend/src/components/layout/AppMenu.vue | 49 ++ .../src/components/layout/BasicLayout.vue | 219 ++++++++ .../src/components/table/AppPagination.vue | 36 ++ frontend/src/components/table/StatusTag.vue | 29 ++ frontend/src/composables/useAppMenu.ts | 65 +++ frontend/src/composables/useConfirmAction.ts | 52 ++ frontend/src/composables/usePagedList.ts | 86 ++++ frontend/src/router/index.ts | 65 ++- frontend/src/utils/constants.ts | 23 + frontend/src/utils/error.ts | 13 + frontend/src/utils/format.ts | 12 + frontend/src/utils/validate.ts | 38 ++ frontend/src/views/dashboard/index.vue | 117 ++--- frontend/src/views/login/index.vue | 22 +- frontend/src/views/system/logs/index.vue | 393 +++++++++++++++ frontend/src/views/system/roles/index.vue | 305 ++++++++++++ frontend/src/views/system/users/index.vue | 467 ++++++++++++++++++ 26 files changed, 2785 insertions(+), 180 deletions(-) create mode 100644 frontend/e2e/common.py create mode 100644 frontend/e2e/system_management.py create mode 100644 frontend/src/api/logs.ts create mode 100644 frontend/src/api/roles.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/layout/AppMenu.vue create mode 100644 frontend/src/components/layout/BasicLayout.vue create mode 100644 frontend/src/components/table/AppPagination.vue create mode 100644 frontend/src/components/table/StatusTag.vue create mode 100644 frontend/src/composables/useAppMenu.ts create mode 100644 frontend/src/composables/useConfirmAction.ts create mode 100644 frontend/src/composables/usePagedList.ts create mode 100644 frontend/src/utils/constants.ts create mode 100644 frontend/src/utils/error.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/validate.ts create mode 100644 frontend/src/views/system/logs/index.vue create mode 100644 frontend/src/views/system/roles/index.vue create mode 100644 frontend/src/views/system/users/index.vue diff --git a/frontend/README.md b/frontend/README.md index c86e097..17ad2d4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ # 前端 -> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。 +> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。当前完成登录链路与系统管理页面(用户/角色/日志查询),业务模块页面待接入。 ## 职责 @@ -26,18 +26,26 @@ src/ ├── api/ │ ├── request.ts # axios 实例、拦截器、统一信封解包 -│ └── auth.ts # 认证接口封装与类型 -├── assets/styles/ # 全局样式 -├── components/ # 通用组件(待补充) -├── composables/ # 组合式函数(待补充) -├── router/index.ts # 路由表与全局守卫 +│ ├── auth.ts # 认证接口封装与类型 +│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装 +│ └── types.ts # 分页等公共类型 +├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*) +├── components/ +│ ├── layout/ # BasicLayout(侧边栏/顶栏/面包屑)、AppMenu +│ └── table/ # StatusTag(0/1 状态标签)、AppPagination +├── composables/ +│ ├── useAppMenu.ts # 菜单从路由表派生(meta.title/group/order/requiresAdmin) +│ ├── useConfirmAction.ts # 确认框→执行→提示→刷新 动作流(启停/删除共用) +│ └── usePagedList.ts # 分页列表状态(请求序号防竞态、删除后页码回退) +├── router/index.ts # 路由表与全局守卫(四层,含 requiresAdmin) ├── store/ │ ├── index.ts # Pinia 实例 │ └── user.ts # 会话状态:token、用户信息、登录/登出 -├── utils/storage.ts # localStorage 读写 +├── utils/ # storage/format/validate/constants/error(handledError) └── views/ ├── login/ # 登录页 - └── dashboard/ # 首页(登录链路验证页) + ├── dashboard/ # 首页(登录链路验证页) + └── system/ # 系统管理:users(用户)/ roles(角色)/ logs(日志查询) ``` ## 启动 @@ -85,6 +93,8 @@ pnpm build # 类型检查 + 生产构建 **字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。 **界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。 +**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页与布局、系统管理页面共用;全局还把 `--el-color-primary` 指向赭金,Element Plus 组件默认走品牌色。登录页 scoped 里的同名局部变量只是全局 token 的别名。 + **签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。 **构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 @@ -104,14 +114,20 @@ pnpm build # 类型检查 + 生产构建 ## 端到端测试 -`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。 +`e2e/` 下两个脚本用 Playwright 驱动真实浏览器,公共工具抽在 `e2e/common.py`(登录动作、从 Redis 取验证码答案、提示等待、失败断言与控制台错误收集): + +- `login_flow.py`(8 项):路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效 +- `system_management.py`(13 项):菜单导航、用户增删改查(含重名负例)、重置密码、启用/禁用、角色保护与 CRUD、操作日志留痕与详情懒加载脱敏、登录日志、普通用户菜单隐藏与路由守卫 ```bash # 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行 python e2e/login_flow.py +# 管理员密码经环境变量传入(不写死在源码里) +E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py ``` 需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 +脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。 ## 常见问题 diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py new file mode 100644 index 0000000..cb88b58 --- /dev/null +++ b/frontend/e2e/common.py @@ -0,0 +1,153 @@ +"""端到端测试公共工具。 + +抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码 +答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。 + +约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()` +统一截图并返回退出码 1。 +""" + +import os +import subprocess +import sys +import time +from urllib.parse import urlparse + +from playwright.sync_api import Page + +BASE_URL = "http://127.0.0.1:5173" +REDIS_CONTAINER = "ys-redis-dev" + +# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。 +# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + +class E2EFailure(Exception): + """步骤断言失败。""" + + +def redis_password() -> str: + """从 docker/.env 读取 Redis 密码。 + + 测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。 + """ + if value := os.environ.get("REDIS_PASSWORD"): + return value + env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env") + with open(env_file, encoding="utf-8") as handle: + for line in handle: + if line.startswith("REDIS_PASSWORD="): + return line.split("=", 1)[1].strip() + raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD") + + +def redis_get(key: str) -> str | None: + """读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。""" + result = subprocess.run( + ["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", redis_password(), + "--no-auth-warning", "get", key], + capture_output=True, text=True, encoding="utf-8", check=False, + ) + return result.stdout.strip() or None + + +def fail(step: str, detail: str) -> None: + """断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。""" + raise E2EFailure(f"{step}:{detail}") + + +def current_path(page: Page) -> str: + """当前 URL 的路径部分。 + + 比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把 + `/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。 + """ + return urlparse(page.url).path + + +def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]: + """注册控制台错误收集(失败诊断时打印最近几条)。 + + :param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取) + """ + errors = sink if sink is not None else [] + page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) + page.on("pageerror", lambda exc: errors.append(str(exc))) + return errors + + +def toast_text(page: Page) -> str: + """当前显示的全局提示文本(el-message),无提示时为空串。""" + locator = page.locator(".el-message") + return locator.first.text_content().strip() if locator.count() else "" + + +def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str: + """等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。 + + 直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里 + 读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。 + """ + deadline = time.monotonic() + timeout_ms / 1000 + text = "" + while time.monotonic() < deadline: + text = toast_text(page) + if expected in text: + return text + page.wait_for_timeout(200) + return text + + +def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None: + """断言出现了包含指定文本的全局提示。""" + text = wait_toast(page, expected, timeout_ms) + if expected not in text: + fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}") + + +def login(page: Page, username: str, password: str) -> None: + """完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。 + + 验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。 + """ + captcha_ids: list[str] = [] + + def on_response(response) -> None: + if "/api/v1/auth/captcha" not in response.url or response.status != 200: + return + try: + captcha_ids.append(response.json()["data"]["captcha_id"]) + except Exception: + # 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声 + pass + + page.on("response", on_response) + try: + page.goto(f"{BASE_URL}/login", wait_until="networkidle") + for _ in range(50): + if captcha_ids: + break + page.wait_for_timeout(100) + if not captcha_ids: + fail("登录", "未捕获到验证码请求") + + captcha_id = captcha_ids[-1] + answer = redis_get(f"ys:captcha:{captcha_id}") + if not answer: + fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}") + + page.fill('input[autocomplete="username"]', username) + page.fill('input[autocomplete="current-password"]', password) + page.fill('input[autocomplete="off"]', answer) + page.click("button:has-text('登 录')") + page.wait_for_timeout(2000) + if current_path(page) != "/dashboard": + detail = f"未跳转到首页,当前 {page.url}" + if message := toast_text(page): + detail += f",提示:{message}" + fail("登录", detail) + page.wait_for_load_state("networkidle") + finally: + page.remove_listener("response", on_response) diff --git a/frontend/e2e/login_flow.py b/frontend/e2e/login_flow.py index 8fbfa89..e1f60f3 100644 --- a/frontend/e2e/login_flow.py +++ b/frontend/e2e/login_flow.py @@ -1,4 +1,4 @@ -"""浏览器端端到端验证:登录 + 图形验证码。 +"""浏览器端到端验证:登录 + 图形验证码。 验证的是**只有真实浏览器才能暴露**的问题: @@ -20,53 +20,28 @@ cd frontend && python e2e/login_flow.py 依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 +公共工具见 `e2e/common.py`。 """ -import os -import subprocess -import sys - from playwright.sync_api import Page, sync_playwright -BASE_URL = "http://127.0.0.1:5173" +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + fail, + redis_get, + toast_text, +) + USERNAME = "admin" PASSWORD = "Admin@2026" -REDIS_CONTAINER = "ys-redis-dev" - - -def _redis_password() -> str: - """从 docker/.env 读取 Redis 密码。 - - 测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。 - """ - if value := os.environ.get("REDIS_PASSWORD"): - return value - env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env") - with open(env_file, encoding="utf-8") as handle: - for line in handle: - if line.startswith("REDIS_PASSWORD="): - return line.split("=", 1)[1].strip() - raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD") - - -def redis_get(key: str) -> str | None: - """读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。""" - result = subprocess.run( - ["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(), - "--no-auth-warning", "get", key], - capture_output=True, text=True, encoding="utf-8", check=False, - ) - return result.stdout.strip() or None - - -def _fail(step: str, detail: str) -> None: - print(f" ✗ {step}:{detail}") - sys.exit(1) def run(page: Page) -> None: captured_captcha_ids: list[str] = [] - console_errors: list[str] = [] + console_errors = collect_console_errors(page) def on_response(response) -> None: if "/api/v1/auth/captcha" not in response.url or response.status != 200: @@ -79,27 +54,11 @@ def run(page: Page) -> None: pass page.on("response", on_response) - page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) - page.on("pageerror", lambda exc: console_errors.append(str(exc))) - - def at_dashboard() -> bool: - """判断是否真的在首页。 - - 不能用 `**/dashboard` 这类 glob —— 它会把 - `/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。 - """ - from urllib.parse import urlparse - - return urlparse(page.url).path == "/dashboard" - - def toast_text() -> str: - locator = page.locator(".el-message") - return locator.first.text_content().strip() if locator.count() else "" # ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ---- page.goto(BASE_URL, wait_until="networkidle") if "/login" not in page.url: - _fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") + fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}") print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}") # ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)---- @@ -107,9 +66,9 @@ def run(page: Page) -> None: image.wait_for(state="visible", timeout=10_000) src = image.get_attribute("src") or "" if not src.startswith("data:image/png;base64,"): - _fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") + fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}") if len(src) < 1000: - _fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") + fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染") print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)") # ---- 3. 输错验证码 → 应提示且验证码自动刷新 ---- @@ -121,38 +80,38 @@ def run(page: Page) -> None: page.wait_for_timeout(1500) if "/login" not in page.url: - _fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") + fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}") second_id = captured_captcha_ids[-1] if second_id == first_id: - _fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") + fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」") print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)") # ---- 4. 正确登录 ---- answer = redis_get(f"ys:captcha:{second_id}") if not answer: - _fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") + fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}") page.fill('input[autocomplete="off"]', answer) page.click("button:has-text('登 录')") page.wait_for_timeout(2000) - if not at_dashboard(): + if current_path(page) != "/dashboard": detail = f"仍停留在 {page.url}" - if message := toast_text(): + if message := toast_text(page): detail += f",提示:{message}" if console_errors: detail += f",控制台错误:{console_errors[:3]}" - _fail("登录", detail) + fail("登录", detail) page.wait_for_load_state("networkidle") print(f"✓ 4. 登录成功 跳转到 {page.url}") if USERNAME not in page.content(): - _fail("用户信息", "首页未显示当前用户名") + fail("用户信息", "首页未显示当前用户名") print("✓ 5. 用户信息 首页已展示当前账号") # ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ---- page.reload(wait_until="networkidle") - if "/dashboard" not in page.url: - _fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") + if current_path(page) != "/dashboard": + fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}") print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)") # ---- 7. 登出 → 令牌立即失效 ---- @@ -162,8 +121,8 @@ def run(page: Page) -> None: page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle") page.wait_for_timeout(1000) - if at_dashboard(): - _fail("登出失效", "登出后仍能访问受保护页面") + if current_path(page) == "/dashboard": + fail("登出失效", "登出后仍能访问受保护页面") print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页") @@ -174,9 +133,14 @@ def main() -> int: page.set_default_timeout(10_000) try: run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 page.screenshot(path="e2e/failure.png", full_page=True) - print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}") + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") print(" 已保存截图 e2e/failure.png") return 1 finally: diff --git a/frontend/e2e/system_management.py b/frontend/e2e/system_management.py new file mode 100644 index 0000000..30fcb83 --- /dev/null +++ b/frontend/e2e/system_management.py @@ -0,0 +1,401 @@ +"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 侧边菜单导航与「系统管理」分组 +- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除 +- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程 +- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数 +- 登录日志:能查到当前会话的成功登录 +- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页 + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库, +可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。 +""" + +import os +import time + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + toast_text, + wait_toast, +) + +ADMIN = "admin" +E2E_PASSWORD = "E2ePwd@2026" +RESET_PASSWORD = "E2eReset@2026" + +SUFFIX = str(int(time.time())) +NEW_USERNAME = f"e2e_user_{SUFFIX}" +VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}" +NEW_ROLE_CODE = f"e2e_role_{SUFFIX}" + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + + +# ==================== 页面操作辅助 ==================== + + +def open_menu(page: Page, title: str) -> None: + """点击侧边菜单项,必要时先展开「系统管理」分组。 + + 分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。 + """ + menu = page.locator(".app-menu") + item = menu.get_by_text(title, exact=True) + if not item.is_visible(): + menu.get_by_text("系统管理", exact=True).click() + page.wait_for_timeout(400) + item.click() + page.wait_for_timeout(800) + + +def fill_filter(page: Page, label: str, value: str) -> None: + """在**当前可见**的筛选表单中按标签填写输入框。 + + 选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab + 用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。 + """ + form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first + form_item.locator("input").first.fill(value) + + +def select_filter_option(page: Page, label: str, option: str) -> None: + """展开可见筛选表单中指定标签的下拉并选择选项。 + + 标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。 + """ + item = page.locator( + f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ) + item.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click() + page.wait_for_timeout(200) + + +def click_query(page: Page) -> None: + """点击可见筛选表单中的查询按钮。""" + page.locator(".filter-form:visible button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + +def table_rows(page: Page): + """当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。""" + return page.locator(".el-table__body .el-table__row:visible") + + +def confirm_message_box(page: Page, button_text: str) -> None: + """点击确认框(ElMessageBox)中的指定按钮。""" + page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click() + page.wait_for_timeout(800) + + +def visible_dialog(page: Page): + """当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。""" + return page.locator(".el-dialog:visible").last + + +def create_user_via_ui( + page: Page, username: str, real_name: str, expect_success: bool = True +) -> None: + """经界面新增用户(角色固定选择「普通用户」)。""" + page.click("button:has-text('新增用户')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username) + dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD) + dialog.locator('input[placeholder="真实姓名"]').fill(real_name) + + # 角色多选:展开下拉后选择「普通用户」 + dialog.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click() + page.wait_for_timeout(200) + dialog.locator(".el-dialog__header").click() # 收起下拉 + page.wait_for_timeout(200) + + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + if expect_success: + expect_toast(page, "用户已创建", "新增用户") + page.wait_for_timeout(600) + + +def find_user_row(page: Page, username: str): + """在用户列表中按用户名筛选并返回目标行。""" + fill_filter(page, "用户名", username) + click_query(page) + return table_rows(page).filter(has_text=username).first + + +# ==================== 流程步骤 ==================== + + +def step_user_create(page: Page, admin_password: str) -> None: + """登录、菜单导航、新增用户与重名负例。""" + login(page, ADMIN, admin_password) + open_menu(page, "用户管理") + if current_path(page) != "/system/users": + fail("菜单导航", f"未进入用户管理页,当前 {page.url}") + print("✓ 1. 菜单导航 经侧边菜单进入用户管理") + + create_user_via_ui(page, NEW_USERNAME, "E2E测试") + row = find_user_row(page, NEW_USERNAME) + if row.count() == 0: + fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}") + print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到") + + create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False) + # 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时, + # 直读会命中它造成偶发误判 + if "已存在" not in wait_toast(page, "已存在"): + fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}") + visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 3. 重名负例 重复用户名被拒并给出中文提示") + + +def step_user_edit_and_status(page: Page) -> None: + """编辑用户、重置密码、启用/禁用。""" + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('编辑')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑用户") + row = find_user_row(page, NEW_USERNAME) + if "E2E已改名" not in row.inner_text(): + fail("编辑用户", "列表未显示修改后的姓名") + print("✓ 4. 编辑用户 姓名修改生效") + + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('重置密码')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD) + dialog.locator(".el-dialog__footer button:has-text('确认重置')").click() + expect_toast(page, "密码已重置", "重置密码") + page.wait_for_timeout(600) + print("✓ 5. 重置密码 提示该用户需重新登录") + + row = find_user_row(page, NEW_USERNAME) + row.locator("button:has-text('禁用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "禁用") + expect_toast(page, "已禁用", "禁用用户") + + row = find_user_row(page, NEW_USERNAME) + if "禁用" not in row.inner_text(): + fail("禁用用户", "列表中状态未更新为禁用") + row.locator("button:has-text('启用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "启用") + expect_toast(page, "已启用", "启用用户") + print("✓ 6. 启用/禁用 状态切换与后端联动正常") + + +def step_role_management(page: Page) -> None: + """角色保护与角色 CRUD。""" + open_menu(page, "角色管理") + if current_path(page) != "/system/roles": + fail("菜单导航", f"未进入角色管理页,当前 {page.url}") + + admin_row = table_rows(page).filter(has_text="系统管理员").first + if not admin_row.locator("button:has-text('禁用')").is_disabled(): + fail("角色保护", "内置 admin 角色的禁用按钮未禁用") + if not admin_row.locator("button:has-text('删除')").is_disabled(): + fail("角色保护", "内置 admin 角色的删除按钮未禁用") + print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用") + + page.click("button:has-text('新增角色')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE) + dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "角色已创建", "新增角色") + + fill_filter(page, "关键词", NEW_ROLE_CODE) + click_query(page) + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + if role_row.count() == 0: + fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}") + + role_row.locator("button:has-text('编辑')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑角色") + + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + role_row.locator("button:has-text('禁用')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "禁用") + expect_toast(page, "已禁用", "禁用角色") + print("✓ 8. 角色管理 新增/编辑/禁用全流程正常") + + +def step_logs_query(page: Page) -> None: + """操作日志(含详情懒加载与脱敏断言)与登录日志。""" + open_menu(page, "日志查询") + if current_path(page) != "/system/logs": + fail("菜单导航", f"未进入日志查询页,当前 {page.url}") + + fill_filter(page, "操作人", ADMIN) + select_filter_option(page, "操作", "新增") + click_query(page) + + log_row = table_rows(page).filter(has_text=NEW_USERNAME).first + if log_row.count() == 0: + fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录") + log_row.locator(".el-table__expand-icon").click() + + # 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记) + detail = "" + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + detail = page.locator(".log-detail__data:visible").first.text_content() or "" + if "***" in detail: + break + page.wait_for_timeout(200) + if "***" not in detail: + fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}") + if E2E_PASSWORD in detail: + fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效") + print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏") + + page.locator(".el-tabs__item:has-text('登录日志')").click() + page.wait_for_timeout(800) + fill_filter(page, "用户名", ADMIN) + click_query(page) + if table_rows(page).filter(has_text="成功").count() == 0: + fail("登录日志", "未查到当前会话的成功登录记录") + print("✓ 10. 登录日志 当前会话登录记录可查") + + +def step_permission_check(page: Page, admin_password: str) -> None: + """普通用户菜单隐藏与路由守卫。""" + open_menu(page, "用户管理") + create_user_via_ui(page, VIEWER_USERNAME, "E2E访客") + page.click("text=退出登录") + page.wait_for_url("**/login**", timeout=10_000) + + login(page, VIEWER_USERNAME, E2E_PASSWORD) + if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0: + fail("菜单权限", "普通用户看到了「系统管理」菜单") + print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组") + + page.goto(f"{BASE_URL}/system/users", wait_until="networkidle") + page.wait_for_timeout(800) + if current_path(page) != "/dashboard": + fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}") + if "需要管理员权限" not in toast_text(page): + fail("路由守卫", "直连管理页未给出权限提示") + print("✓ 12. 路由守卫 直连 /system/users 被弹回首页") + + page.click("text=退出登录") + page.wait_for_url("**/login**", timeout=10_000) + login(page, ADMIN, admin_password) + + +def step_cleanup(page: Page) -> None: + """删除测试用户与角色(用 expect_toast 校验,失败不假绿)。""" + open_menu(page, "用户管理") + for username in (NEW_USERNAME, VIEWER_USERNAME): + row = find_user_row(page, username) + if row.count() == 0: + continue + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "已删除", "数据清理") + + open_menu(page, "角色管理") + fill_filter(page, "关键词", NEW_ROLE_CODE) + click_query(page) + role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first + if role_row.count() > 0: + role_row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "已删除", "数据清理") + print("✓ 13. 数据清理 e2e 测试用户与角色已删除") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py", + ) + + step_user_create(page, admin_password) + step_user_edit_and_status(page) + step_role_management(page) + step_logs_query(page) + step_permission_check(page, admin_password) + step_cleanup(page) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 0000000..dbb9284 --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,78 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 操作日志列表项(对应后端 `OperationLogOut`;不含 request_data 大字段)。 */ +export interface OperationLogItem { + id: number + user_id: number | null + username: string + module: string + action: string + summary: string + method: string | null + path: string | null + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + error_message: string | null + duration_ms: number | null + create_time: string | null +} + +/** 操作日志详情(对应后端 `OperationLogDetailOut`;request_data 已脱敏) */ +export interface OperationLogDetail extends OperationLogItem { + request_data: string | null +} + +export interface OperationLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + module?: string + action?: string + success?: number + /** 格式 YYYY-MM-DD HH:mm:ss(el-date-picker 需配 value-format) */ + start_time?: string + end_time?: string +} + +/** 登录日志项(对应后端 `LoginLogOut`)。 */ +export interface LoginLogItem { + id: number + user_id: number | null + username: string + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + fail_reason: string | null + create_time: string | null +} + +export interface LoginLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + ip?: string + success?: number + start_time?: string + end_time?: string +} + +/** 分页查询操作日志(列表不含 request_data,详情走 getOperationLog) */ +export function listOperationLogs( + params: OperationLogQueryParams, +): Promise> { + return request({ url: '/v1/system/operation-logs', method: 'get', params }) +} + +/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */ +export function getOperationLog(logId: number): Promise { + return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' }) +} + +/** 分页查询登录日志 */ +export function listLoginLogs(params: LoginLogQueryParams): Promise> { + return request({ url: '/v1/system/login-logs', method: 'get', params }) +} diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts new file mode 100644 index 0000000..b459a27 --- /dev/null +++ b/frontend/src/api/roles.ts @@ -0,0 +1,54 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 角色管理列表项(对应后端 `RoleManageOut`,比下拉多绑定用户数)。 */ +export interface RoleItem extends RoleOption { + /** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */ + user_count: number +} + +export interface RoleQueryParams { + pageNum?: number + pageSize?: number + keyword?: string + status?: number +} + +export interface RoleCreateParams { + role_code: string + role_name: string + description?: string + sort_order?: number +} + +/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */ +export interface RoleUpdateParams { + role_name: string + description?: string + sort_order?: number +} + +/** 分页查询角色(含停用角色) */ +export function listRoles(params: RoleQueryParams): Promise> { + return request({ url: '/v1/system/roles', method: 'get', params }) +} + +/** 新增角色 */ +export function createRole(data: RoleCreateParams): Promise { + return request({ url: '/v1/system/roles', method: 'post', data }) +} + +/** 编辑角色 */ +export function updateRole(roleId: number, data: RoleUpdateParams): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data }) +} + +/** 启用 / 禁用角色(内置 admin 不可停用) */ +export function setRoleStatus(roleId: number, status: number): Promise { + return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } }) +} + +/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */ +export function deleteRole(roleId: number): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' }) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..561f225 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,25 @@ +/** 请求/响应公共类型。 */ + +/** + * 分页响应 —— 与后端 `PageResult` 的序列化字段一致。 + * + * 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选 + * 参数是蛇形(username、real_name…,与后端 Query 模型一致), + * 这是既有契约,不要"顺手统一"。 + */ +export interface PageData { + list: T[] + total: number + pageNum: number + pageSize: number +} + +/** 已启用角色的下拉选项 */ +export interface RoleOption { + id: number + role_code: string + role_name: string + description: string | null + sort_order: number + status: number +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..7594508 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,86 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 用户列表项(对应后端 `UserOut`)。 */ +export interface UserItem { + id: number + username: string + real_name: string | null + phone: string | null + email: string | null + /** 1启用 0禁用 */ + status: number + /** 角色编码列表 */ + roles: string[] + last_login_time: string | null + remark: string | null + create_time: string | null +} + +/** 列表查询:分页参数为驼峰,业务筛选为蛇形(与后端契约一致,勿"统一") */ +export interface UserQueryParams { + pageNum?: number + pageSize?: number + username?: string + real_name?: string + status?: number + role_code?: string +} + +export interface UserCreateParams { + username: string + password: string + real_name?: string + phone?: string + email?: string + roles: string[] + remark?: string +} + +/** 编辑:不含用户名与密码(后者走专门的重置接口) */ +export interface UserUpdateParams { + real_name?: string + phone?: string + email?: string + roles?: string[] + remark?: string +} + +/** 分页查询用户 */ +export function listUsers(params: UserQueryParams): Promise> { + return request({ url: '/v1/system/users', method: 'get', params }) +} + +/** 新增用户 */ +export function createUser(data: UserCreateParams): Promise { + return request({ url: '/v1/system/users', method: 'post', data }) +} + +/** 编辑用户 */ +export function updateUser(userId: number, data: UserUpdateParams): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'put', data }) +} + +/** 删除用户(物理删除,建议优先用禁用) */ +export function deleteUser(userId: number): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'delete' }) +} + +/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */ +export function resetPassword(userId: number, newPassword: string): Promise { + return request({ + url: `/v1/system/users/${userId}/password`, + method: 'put', + data: { new_password: newPassword }, + }) +} + +/** 启用 / 禁用 */ +export function setUserStatus(userId: number, status: number): Promise { + return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } }) +} + +/** 角色下拉选项(仅启用角色) */ +export function listRoleOptions(): Promise { + return request({ url: '/v1/system/users/meta/roles', method: 'get' }) +} diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index d3510fa..6bb84be 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -1,4 +1,34 @@ -/* 全局样式:重置 + 基础版式 */ +/* 全局样式:重置 + 品牌 token + 基础版式 */ + +:root { + /* + * 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架 + * 与系统管理页面共用后提升为全局 token)。 + * 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。 + */ + --ys-ink-900: #061a22; + --ys-ink-800: #0a2833; + --ys-ink-700: #0f3646; + --ys-ink-600: #1a4f63; + --ys-gold-bright: #c9a227; + --ys-gold-deep: #8f6520; + --ys-paper: #fafaf7; + --ys-text: #16262d; + --ys-muted: #6b7b84; + + /* + * Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。 + * 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 —— + * 不用 color-mix(),避免依赖较新的浏览器特性。 + */ + --el-color-primary: #8f6520; + --el-color-primary-light-3: #b19363; + --el-color-primary-light-5: #c7b290; + --el-color-primary-light-7: #ddd1bc; + --el-color-primary-light-8: #e9e0d2; + --el-color-primary-light-9: #f4f0e9; + --el-color-primary-dark-2: #72511a; +} * { box-sizing: border-box; @@ -20,3 +50,12 @@ body { background-color: #f5f7fa; -webkit-font-smoothing: antialiased; } + +/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */ +.filter-form { + margin-bottom: 4px; +} + +.toolbar { + margin-bottom: 12px; +} diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue new file mode 100644 index 0000000..ac0f945 --- /dev/null +++ b/frontend/src/components/layout/AppMenu.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue new file mode 100644 index 0000000..9476f51 --- /dev/null +++ b/frontend/src/components/layout/BasicLayout.vue @@ -0,0 +1,219 @@ + + + + + + + diff --git a/frontend/src/components/table/AppPagination.vue b/frontend/src/components/table/AppPagination.vue new file mode 100644 index 0000000..f39d135 --- /dev/null +++ b/frontend/src/components/table/AppPagination.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/table/StatusTag.vue b/frontend/src/components/table/StatusTag.vue new file mode 100644 index 0000000..d7994c3 --- /dev/null +++ b/frontend/src/components/table/StatusTag.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts new file mode 100644 index 0000000..857e743 --- /dev/null +++ b/frontend/src/composables/useAppMenu.ts @@ -0,0 +1,65 @@ +import { computed } from 'vue' +import { useRouter } from 'vue-router' + +import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */ +export interface MenuNode { + path: string + title: string + order: number + group?: string +} + +export interface MenuGroup { + name: string + items: MenuNode[] +} + +/** + * 侧边菜单数据。 + * + * 规则: + * - 取路由表中带 `meta.title` 且未被 `meta.menu === false` 排除的记录 + * - `meta.requiresAdmin` 的项仅管理员可见(与路由守卫双保险,隐藏入口在前) + * - `meta.group` 相同的项聚合为一个子菜单;无 group 的为顶层菜单项 + * - 按 `meta.order` 排序 + */ +export function useAppMenu() { + const router = useRouter() + const userStore = useUserStore() + + const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN)) + + const menuNodes = computed(() => + router + .getRoutes() + .filter((record) => Boolean(record.meta.title) && record.meta.menu !== false) + .filter((record) => !record.meta.requiresAdmin || isAdmin.value) + .map((record) => ({ + path: record.path, + title: record.meta.title ?? '', + order: record.meta.order ?? 99, + group: record.meta.group, + })) + .sort((a, b) => a.order - b.order), + ) + + /** 无分组的顶层菜单项 */ + const topLevelMenus = computed(() => menuNodes.value.filter((node) => !node.group)) + + /** 分组菜单(按 group 名聚合,组内保持 order 顺序) */ + const groupedMenus = computed(() => { + const groups = new Map() + for (const node of menuNodes.value) { + if (!node.group) continue + const items = groups.get(node.group) ?? [] + items.push(node) + groups.set(node.group, items) + } + return [...groups.entries()].map(([name, items]) => ({ name, items })) + }) + + return { topLevelMenus, groupedMenus } +} diff --git a/frontend/src/composables/useConfirmAction.ts b/frontend/src/composables/useConfirmAction.ts new file mode 100644 index 0000000..00943e5 --- /dev/null +++ b/frontend/src/composables/useConfirmAction.ts @@ -0,0 +1,52 @@ +import { ElMessage, ElMessageBox } from 'element-plus' + +import { handledError } from '@/utils/error' + +/** + * 「确认框 → 执行 → 成功提示 → 收尾刷新」动作流。 + * + * 用户/角色两页的启用禁用、删除共四处结构逐行相同,收敛到这里: + * 取消确认静默返回 false;接口失败由请求拦截器统一提示,此处仅兜底 + * 阻止 Promise 继续传播(handledError 显式标注,不写空 catch)。 + */ +export function useConfirmAction() { + /** + * 弹出确认框并执行;返回是否真正执行成功(用户取消或执行失败均为 false)。 + */ + async function confirmAndRun(options: { + /** 确认框正文 */ + confirmText: string + /** 确认框标题 */ + title?: string + /** 确认按钮文案 */ + confirmButtonText?: string + /** 执行成功后的全局提示 */ + successText: string + /** 真正的业务动作 */ + run: () => Promise + /** 成功后的收尾(如刷新列表) */ + after?: () => Promise | void + }): Promise { + try { + await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', { + type: 'warning', + confirmButtonText: options.confirmButtonText ?? '确定', + cancelButtonText: '取消', + }) + } catch { + return false // 用户取消,静默返回 + } + + try { + await options.run() + ElMessage.success(options.successText) + await options.after?.() + return true + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + return false + } + } + + return { confirmAndRun } +} diff --git a/frontend/src/composables/usePagedList.ts b/frontend/src/composables/usePagedList.ts new file mode 100644 index 0000000..936701d --- /dev/null +++ b/frontend/src/composables/usePagedList.ts @@ -0,0 +1,86 @@ +import { ref, type Ref } from 'vue' + +import type { PageData } from '@/api/types' +import { handledError } from '@/utils/error' + +/** + * 分页列表状态管理。 + * + * 四个列表页(用户/角色/操作日志/登录日志)共用同一套分页交互,要点: + * + * - 「删除当前页最后一条后页码回退」是高频踩坑点:不回退就会停在空页 + * - **请求序号防竞态**:快速翻页/改条件时并发请求乱序返回,只有最新 + * 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位 + * - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位 + */ +export function usePagedList( + fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise>, + getFilters: () => F, +) { + const records = ref([]) as Ref + const total = ref(0) + const pageNum = ref(1) + const pageSize = ref(10) + const loading = ref(false) + + /** 自增请求序号:仅最新请求的响应可写入状态 */ + let requestSeq = 0 + + /** 按当前页码与筛选条件加载 */ + async function load(): Promise { + const seq = ++requestSeq + loading.value = true + try { + const data = await fetcher({ + ...getFilters(), + pageNum: pageNum.value, + pageSize: pageSize.value, + }) + if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应 + records.value = data.list + total.value = data.total + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + } finally { + if (seq === requestSeq) loading.value = false + } + } + + /** 条件查询:回到第一页再加载(停留旧页码会查到空页) */ + function search(): void { + pageNum.value = 1 + void load() + } + + function onPageChange(page: number): void { + pageNum.value = page + void load() + } + + function onSizeChange(size: number): void { + pageSize.value = size + pageNum.value = 1 + void load() + } + + /** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */ + async function reloadAfterDelete(removedCount = 1): Promise { + if (records.value.length - removedCount <= 0 && pageNum.value > 1) { + pageNum.value -= 1 + } + await load() + } + + return { + records, + total, + pageNum, + pageSize, + loading, + load, + search, + onPageChange, + onSizeChange, + reloadAfterDelete, + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 856d6dc..51c84ef 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,20 +1,64 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +declare module 'vue-router' { + interface RouteMeta { + /** 免登录页面(登录页) */ + public?: boolean + /** 页面标题:浏览器标题 / 面包屑 / 菜单共用,新页面必须写 */ + title?: string + /** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */ + requiresAdmin?: boolean + /** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */ + menu?: boolean + /** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */ + group?: string + /** 菜单排序,越小越靠前 */ + order?: number + } +} const routes: RouteRecordRaw[] = [ - { path: '/', redirect: '/dashboard' }, { path: '/login', name: 'login', component: () => import('@/views/login/index.vue'), - meta: { public: true, title: '登录' }, + meta: { public: true, title: '登录', menu: false }, }, { - path: '/dashboard', - name: 'dashboard', - component: () => import('@/views/dashboard/index.vue'), - meta: { title: '首页' }, + // 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染 + path: '/', + component: () => import('@/components/layout/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '首页', order: 1 }, + }, + { + path: '/system/users', + name: 'system-users', + component: () => import('@/views/system/users/index.vue'), + meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 }, + }, + { + path: '/system/roles', + name: 'system-roles', + component: () => import('@/views/system/roles/index.vue'), + meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 }, + }, + { + path: '/system/logs', + name: 'system-logs', + component: () => import('@/views/system/logs/index.vue'), + meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 }, + }, + ], }, // 未匹配路由回首页;若未登录会被守卫拦到登录页 { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, @@ -28,11 +72,13 @@ const router = createRouter({ /** * 全局前置守卫。 * - * 三层判断: + * 四层判断: * 1. `meta.public` 标记的页面(登录页)直接放行 * 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳 * 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话; * 失败则视为令牌已失效,清状态并跳登录页 + * 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪, + * 这是"隐藏菜单"之外的第二道防线(防手输 URL) */ router.beforeEach(async (to) => { const userStore = useUserStore() @@ -54,6 +100,11 @@ router.beforeEach(async (to) => { } } + if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) { + ElMessage.error('需要管理员权限') + return { path: '/dashboard' } + } + return true }) diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts new file mode 100644 index 0000000..8109b6d --- /dev/null +++ b/frontend/src/utils/constants.ts @@ -0,0 +1,23 @@ +/** 跨页面共享常量。 */ + +/** 系统管理员:可见「系统管理」菜单,全部管理接口可用(与后端 core/constants.py 一致) */ +export const ROLE_ADMIN = 'admin' + +/** + * 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的 + * `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。 + */ +export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const +export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const + +/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */ +export const STATUS_OPTIONS = [ + { label: '启用', value: 1 }, + { label: '禁用', value: 0 }, +] as const + +/** 成功/失败筛选选项 */ +export const RESULT_OPTIONS = [ + { label: '成功', value: 1 }, + { label: '失败', value: 0 }, +] as const diff --git a/frontend/src/utils/error.ts b/frontend/src/utils/error.ts new file mode 100644 index 0000000..037cddf --- /dev/null +++ b/frontend/src/utils/error.ts @@ -0,0 +1,13 @@ +/** 异常处理工具。 */ + +/** + * 显式声明「异常已按约定处理」。 + * + * 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止 + * Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError), + * 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。 + * 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。 + */ +export function handledError(error: unknown): void { + console.debug('[handled]', error) +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..d05363e --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,12 @@ +/** 显示格式化工具。 */ + +/** + * 时间展示:把后端的时间串规范为 `YYYY-MM-DD HH:mm:ss`。 + * + * Pydantic 序列化 datetime 为 ISO 8601(含 `T`),直接展示不友好; + * 值缺失(空串/null/undefined)时返回占位符而非空白。 + */ +export function formatDateTime(value?: string | null, placeholder = '—'): string { + if (!value) return placeholder + return value.replace('T', ' ').slice(0, 19) +} diff --git a/frontend/src/utils/validate.ts b/frontend/src/utils/validate.ts new file mode 100644 index 0000000..0075843 --- /dev/null +++ b/frontend/src/utils/validate.ts @@ -0,0 +1,38 @@ +/** 表单校验工具。 */ + +/** + * 密码校验:按 **UTF-8 字节数** 而非字符数。 + * + * 后端 bcrypt 对超过 72 字节的密码直接拒绝;中文一个字符占 3 字节, + * 24 个汉字即到顶 —— 按字符数校验(如 maxlength=20)根本拦不住, + * 会出现"前端放行、后端 400"的错位体验。与后端 `PASSWORD_*_BYTES` 对齐。 + */ +export const PASSWORD_MIN_BYTES = 8 +export const PASSWORD_MAX_BYTES = 72 + +const BYTE_HINT = '(中文一个字符约 3 字节)' + +/** 计算 UTF-8 字节数 */ +export function passwordByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Element Plus 表单校验器(用于 el-form 的 rules.validator) */ +export function passwordRule( + _rule: unknown, + value: string, + callback: (error?: Error) => void, +): void { + if (!value) { + callback(new Error('请输入密码')) + return + } + const length = passwordByteLength(value) + if (length < PASSWORD_MIN_BYTES) { + callback(new Error(`密码不足 ${PASSWORD_MIN_BYTES} 字节${BYTE_HINT}`)) + } else if (length > PASSWORD_MAX_BYTES) { + callback(new Error(`密码超过 ${PASSWORD_MAX_BYTES} 字节上限${BYTE_HINT}`)) + } else { + callback() + } +} diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 101396d..835dbbd 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,98 +1,49 @@ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 1da5400..31e6e7b 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -182,19 +182,19 @@ onMounted(refreshCaptcha) diff --git a/frontend/src/views/system/roles/index.vue b/frontend/src/views/system/roles/index.vue new file mode 100644 index 0000000..06859fa --- /dev/null +++ b/frontend/src/views/system/roles/index.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/frontend/src/views/system/users/index.vue b/frontend/src/views/system/users/index.vue new file mode 100644 index 0000000..9ceaeb9 --- /dev/null +++ b/frontend/src/views/system/users/index.vue @@ -0,0 +1,467 @@ + + + + + -- 2.27.0 From c7212cd93f58aa583002006200e02d4413b25a17 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 16:31:45 +0800 Subject: [PATCH 005/100] =?UTF-8?q?chore:=20=E4=BF=AE=E6=AD=A3=20gitignore?= =?UTF-8?q?=20=E5=B9=B6=E5=90=8C=E6=AD=A5=E9=A1=B9=E7=9B=AE=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore:logs/ 收窄为 /backend/logs/ —— 原规则(无前导斜杠)匹配任意 层级同名目录,曾把 frontend/src/views/system/logs 整个吞出版本库 - 同步根/frontend README(进度、e2e 运行方式与环境变量) - 收录业务确认问题清单(完整版) Co-Authored-By: Claude Code --- .gitignore | 6 +++++- README.md | 5 +++-- docs/业务确认问题清单_完整版.xlsx | Bin 0 -> 7389 bytes 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 docs/业务确认问题清单_完整版.xlsx diff --git a/.gitignore b/.gitignore index 2ffbab2..aef6667 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,10 @@ out/ # 日志 *.log -logs/ +# 后端运行日志目录(core/logging.py 创建)。 +# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把 +# frontend/src/views/system/logs 整个吞掉(文件进不了版本库) +/backend/logs/ # IDE .idea/ @@ -55,3 +58,4 @@ frontend/e2e/*.png # 备份 backup/ .claude +CLAUDE.md diff --git a/README.md b/README.md index dd5d4df..fbbbbbf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。 -> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 +> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。 ## 快速开始 @@ -81,7 +81,8 @@ pnpm type-check # 类型检查 pnpm build # 类型检查 + 生产构建 # 浏览器端到端(需容器、后端、前端都在运行) -python e2e/login_flow.py +python e2e/login_flow.py # 登录链路 8 项 +python e2e/system_management.py # 系统管理 13 项 ``` ## 目录结构 diff --git a/docs/业务确认问题清单_完整版.xlsx b/docs/业务确认问题清单_完整版.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a992057c04bf8c1125c64b4e5d4f12f56403b452 GIT binary patch literal 7389 zcma)B1yEeewjJDkaGyXJbZ`rUyF+kycZUEWxCR0QcbDMqK?1=Yf(DlaCuopQ?ydLl zOX~irKU3#)otoZjy4TrKy|%I(96S&J03ZX3d}Q^Js*o3EpPm?C0069~(aR3v>F8o> z;^=70;$?3aGY(aRW5Y&RE7zK?TU`kTlwTC(=58ZeK*w}ypp3C_od8-|B;h>8(U5CC zL=e?f5ore<)Ni^9z10WBWh8` zuA(dj9eNsL8e7sDb72mLW+Hd|Zt&tsa7s|Jy?&M11WV8OD)siGoH)61_wMk6?c?p~ z<@Li4^o@ySA_R=^8NU3rxkfbGA7vfoktl^B0tGxz0 zcdHHUedaWEj1&V>o*>uJ6bKSh$DhIwz9E)^&6oL56f#qIo42!Ix08eoCCc z!po#*U2SXnwgim{ji|_l6=-ru0uqVXD*ei2xiw$Y*#+2Jg}J`E+8xa{Y!d2Rd3f-r zMAa6tHV0D_>k)y7+5n=v%9*)%Zo?_Ts}OF35D8OxEkj-^iZPN?>iV$W+Y_lv z6%@{1Cq&EQ@%FwV(lW0*CI)uotjL(jxBI3BT)I;Y_yek8^^aFq+ z_clxj-qr-{FKI-~8GP1uf7*~#5Ri#nNV?kx0-QP}riDRsPUQo861`;k%w?&1;f%!* zA(2HB@m2oPm5Jt*d)8Ely3|%NJUO>|FuqK&QE9k|@9HoZj?_&JGKuQhN0v1)Wt1> znktj_m61nbksS{33d)U9k`uNLXBUEM2kt0bA`5f0Gq3N*-}!S0N!`9`RX?)`9?4c~ zTHy9FM-o`UFz%D#{H==FYz;Ot&(Gf}&p*0Fr_?KnexQt>N38Pp`P?kd!zZ)`y?u zFxe37S7}A~@&c35aEz5EVQSBG?o5(|?qVRw(+1Qi-I&wOU!eIN0FpO)Ovq?&sD*^5 z9V;)mBf}$%Xq{+<6pYAmy0qBi3cqttY3V$S91zh2hO;^CCx2zh7yk$rtxw7iR2bm$ zEr($&vGJmMwU&BGC2U+hfd1x{v~{QahL>7%Z^Y8)lf>6`B)t71XNMy*pe>aYwxF;|Q-PQhkh zw@)0qH4yM?hm?)VBAPH{jnufoG+v|Cm*La$(iFT>C*;C?v9Ld2EZ^?8!M;_dq_*GZ zN7P?IZ{cc5@uk6&|D53UZ10_%b2}BUfwAb8xMV}&7c;aTk{$|H^J>y_e)XXSg>EsU zfI+<}5mx-!wC`L>(2NB8hr00ZMLfDM0$edAJiDEM*;d3`OxtClxSq9`<5?4vlNjz_ z=)T2yy3}HSy{nE}E5X*M$LdxK+NNJ0v1!}Jp#ig?Y0&MxzhI)pw0P-h=I3Fn1ZEpI zicV|qPmC|M_}+SgE}>E2^xGKT1cKOVrW7b!O6thk@3h_c=o?{cikVieZEMY68}C8q=){Ednmu%!jx(HJR2YB z-5VPXhX7lZAatvs_EMn(Cd#EB zESClQ;})|d7VA-;6C_~7c3maeySNIG=(N#z-2&d49ZS7u?4BPRKx%YPRheDhdQM^) zXyPFyzJlhg@zpVY@bDn0uJm|Qv;%2d4DFqBuO_nNmL39Gk!Cp)`I^zgh?>-^--N`f@0g~<6ZY%Z}B+g&x0;R3I+M4 z$Fof=$nm{k*U9TBrMriK!|mm|-KZ;r+XE%oH-uMYZ$BX^-MQ99C7eU<#tp}yuQS0~nDV-@YR*s_qk^?0mAiI{bLV9<6rI30#{KknyIymGlV z|LNo1^5cZ&vO1$$`h2~&e!6RH?$OkYeru~OnR`>w(cJ9HChzg&3{-P0HvedLro*)4 zMI}gB^zz)!h=77%=XDb^Hg$N?@C)U+O{u&)fZ4;)f_+rMI83%5Tea}&-DSRN_=b-D z5!pr}INM`utnyG)da=NQpaOqORH76(D=)FK)HA9MRJdpZm5Do*v4nl{=RtpaFS#;} zaep|@{$o1Zd~a81cmnb|Dfj22hZrWTBAldmq{^xvsigPD+zeM!K^-|+#hM#3;p#=( zf}C-T=h^CD>Y(+0dtd@{hQoTG7yXB;wmiMTwQ1n0cgT?3y~FiUVKw(mxvYpHy!F?K z#mcj?;vIx-qit2Kru*UNbe=USA5(G1jxXD^tJ%Bx=y;0{4Y>;!l|$fH>2rKVtjg_T1H2 zs`4*8>G_F#c*LB4p1j&Sd3C)M`c=$c-_V6`dfgJaJQu^=QK}TUCEL%5j1EnF6%;$J ze2QIcbMHVP)dTL#oxyS{DmW^3E`nE}pmZ(q`f?m8=pX5eeAR}t?ThB9?o7kP6{UI( zm8i|I%~Q^h_CbnoE26fzcBQA;V_K)Xk)d$%?-DnGaJ@p*dCGo|-PpL^WimQbc{Ty;Jd%#X5tf6L0- zuJLi5i;`{?NVW=rM-s!*bd?t9%f8&!LjLqMx9+_^PZX}ykhf@Q6Btlnq4pSxZ!3A8 zENT*`$P-=UdmGf+w5gl`E2}ZnL5?ewUvm-?*SAli&X*H)girlnDHK)B` zrNc`mh1b+cL_TgwxiQSigaiPfVFEvGX$zvlLQof4?%eT?Q}Nmvzsl*vZlR&x`3w@vJGI72 z2Rf^@?Mpra%!zMo-}<*@Lv8eeCC0Z#(BHk4?#OYe4(zk%(;TRYpW>A;!O$`3HuVwk zlGhC!h{pFCNx>=^08(%mB4dS8WPa|~mPf+px-6`NOAH@EYG5phlgHd&3!N*hhPi;I zE-PQX_VSd=GQMW7CwV7(p(>rT!>*XMsY4zSwie#Kb5drhhb3g$$swrNoZ;C?MF;!zi%(+%3aO9j*nlG=C;fTyP|3IL#B3zhsfT+|+_OAaQY1beJb#fU%`Dh@hMeFD_n*d$EH_24&T?}yPFL4 z-pljAR{cbN+I*SPRPkKiNcR%HFJsttPwOC)tzv43YCp$Rz#6*c8f~1;o$(}>^n^h- z6AEk{E?GG6lxWvbO<2PGth$e_I6D!BZZ)|b&i!Erp&dT~4S!K8%~(sC_{WTmEb-^q z9#l&d#*7lyv*J0i+meBST0krG5bhkV(HPiq1Apk9&udJgG{v zlnG-Z(lQz2{q8}Aw@mAjNEIDiX`%7Cgq)&5CNTDr(I-JreNF^@s>(cB4I2!J?4zGn z>4~0z6(eAFaPbG||(zAuDwjSgDlRg%F<>-mmSPe3=0f1zDOIUB9S~Q4ABvFA8)}Qs z1znLZQa9!LSZ7FAXiHN+nFNJ>qix=A#kRq0!JFT`j{(hzT~5pHLD!4IH-`og6|WLS z;ZIf-1_#dyt1rpN(0KKKKD@ZP3MPz_9!;6#ao@#|6)z~Fa`)jpP6-O7(liP#^yc$c$wrwWp z!cATi&RQoPCu8lRcE;hVt*z#N)$ zH^CdfxEEn1O%aN#aZv8|!e>VlL3?p&5+zuM7Y@4bi4ZYFb`eFocHsHFpEa0jS`Ah1 z$%G}A?|h61w&vipo*X6dZjf(##%20LuBSzOVz9Qg4r$ijAjb~PfRR3=c6mXa0imo% zp6!SYXR|SsNRA02oeJvD*fo!cl|+x)kLjOWq&{vjZL`gPrdZT+ScyR^UYdNhTti>w z1IFo2RRTW{*u=n-(o&#pp|Wk{bNI)pjo_hB%YB4q*LsQzVYuL6-I&S*Av@1VC4?qv zTMbQk6J!u7v!PXli;$tUb%V9z5hIv<_fpen(sr;YNe`}CMTkgiYgq~XT>2JVWt+5Gs~{PEuZK$ zARfWRkX+Zd9hgWjJF=jcbZwPb4J=o_UcX}j6v6=cY-YE8@cWa+6>sW?6kcR1s=~)9 zV0#btA*Pp*zpZm13dA!^N@KGh>#~nWz|dj`627;|T*tJUqbmc*MA z@W$hSUaJI(W^6%C&O7faG&+gqOM z;~cv5Om!axVaJzamFO-Qa44ozr9l+2!my4JN(l5XX;SdYm`>kG8_{pHduVZg87A*e z)qL=B3bdeb)3D{K6@qq!%sw|z*UWdR66L|yMB=FZ>JZZue96?8(S7N>(?=^uR)xZA z$Q8>iww>*1Z^MGDNi@xj%a3yzEX+O`Attt-(~uNIV66IrK09uh%IS5icg_%dX_&4J zYH|b9^)vI@5u{M_^)NB+uY7(W^pKEiMt5xR1}~$`DWMPl`yy z0PaLbiaOyZn5~Fc3K86XAo}I{JkR>Ahqs>aa1+Zfq{h5gAnv&6XWLC zfc~&F&tj@wh95g9s98_7ZwdU4A4{@{zZrghMYJ4LXui+D#3eLQ?%HpTaEW-zAT27t z){m#3HOR+3`R$W*D%JzovPzk`vXsWeMd?MwbH1P7#>mAx7Xuh4%Y|i@i!}>~-&`*> z)v%5nIhs1Z_v?)Lq+WZ=O`sWZy`*)=s^vj8?{Ce&`*dD=9DIy^KvsWU%Y01vNC)8p}V}^kL{#Y&4(#VJLHZir>NG@j}ReQZ{5U>xL4+*JF1paivjMQ{aq` z^)b-*?2G;53rggXag402YfH*o!nHy3CTLnV)*jYO`~$}~%16rooQeT|N!+IW=uq&J zy#4D!dpZ>xx|rL!LRfy?kK4Kggq{R0Di3oPS8GQHD2R=P6+~t3VCrc0bdHCD+}$jg zc|k&g0;=YA#%@nDT&=8~T&bQ+99*FwD>pYMeh9?X^hqx>c4cvNGIx0T#lq3W-q`Kw z(Zv$tWNd0{Y-tW*XJzGvJpJQ87@*%Vsnook%>O@#jusZyrsm>~rtbFU4sQPf4EcQq zh)T`a#nRjj3WEHh?SrUf%%C7uc5@zXK0Z@+UUm)>PE$T(5EVpF0P@>@1^+|n-#vq| zd;lWO6PagEsyWHuGQ;1T{&(l#FMb>16%>0}u_dcayp;}kE7S9eR;rD4eP9MC=v40U z7FY@_O}pPU!{$*XQW4m-rhej?nod_bf4FU2Q5ws$47Wz^>^2u+gRKgr@SR_C<@V%S zJ#Iv*X2TCUOJ zkwc!{g%L#)0|nsSJ=V@CjpENfiT3P)O_5r<&1%1Dv++eu<~y8-5Or$kY0MCHw>YqY zc<_dxyjr(<`BmCuA7mHj(siOVtcTv`G}X&?MCW9?kojJ%- zxhk{Ij#oY~m7@JQF=%a1pEQ16p1nX(!rr45&Rsi6a|qr{*x>XXHTNuez0YxTGbORp z)Z5jF4UfeaWj48{ZgW@oPa z4|Ml7w0O&BuA%JS!DYJbvUD8OaVR@dc$TDa*mY0^$DeF4hy5e|-wx{2Ko7 z`#+8UYykaj3IG(mfqi;6zofnYZ3q2n`lojOU#9SXnf|Gy{|WG?koXS(H`2e$ihm;f zsrmhbF#0#bzbb(Lp!|D|{0C)|;J1wVkKw=Pf`1G-pA^U^^8Sy^@F&inq3{omAMw8i XhO!(2;;%`_PhYsFMAY{y69N7QL`5>h literal 0 HcmV?d00001 -- 2.27.0 From bc019cadf5d34a126eddf369f327ae3bf864a391 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 16:13:13 +0800 Subject: [PATCH 006/100] =?UTF-8?q?feat(system):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E8=A7=92=E8=89=B2=E4=B8=8E=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=9B=9B=E8=A1=A8=E5=8F=8A=20V3=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - business_roles/param_definitions/param_change_batches/param_values 四表 + 种子(3 内置岗位/16 参数定义/19 版本条目/初始批次 P20260101-00) - 双轨迁移:Alembic bfbda9c3191d + db/migration/V3 导出 + init-dev.sql 同步(schema_sync 校验通过) - 参数值以 Fraction 可解析字符串存储(4/360 禁 0.011111);企业户 2‰→3‰ 双种子支持按到账日回溯 Co-Authored-By: Claude Code --- ...da9c3191d_add_business_roles_and_params.py | 810 ++++++++++++++++++ backend/app/models/__init__.py | 6 + backend/app/models/business_role.py | 46 + backend/app/models/param.py | 159 ++++ backend/tests/unit/test_models.py | 4 + db/init/init-dev.sql | 140 ++- .../V3__add_business_roles_and_params.sql | 153 ++++ db/queries/计提参数.sql | 58 ++ 8 files changed, 1375 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py create mode 100644 backend/app/models/business_role.py create mode 100644 backend/app/models/param.py create mode 100644 db/migration/V3__add_business_roles_and_params.sql create mode 100644 db/queries/计提参数.sql diff --git a/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py new file mode 100644 index 0000000..a9bccbe --- /dev/null +++ b/backend/alembic/versions/bfbda9c3191d_add_business_roles_and_params.py @@ -0,0 +1,810 @@ +"""新增岗位角色与计提参数四表 + +Revision ID: bfbda9c3191d +Revises: a45a8087831b +Create Date: 2026-10-12 16:01:48.760620 + +""" + +from collections.abc import Sequence +from datetime import date + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "bfbda9c3191d" +down_revision: str | Sequence[str] | None = "a45a8087831b" +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( + "business_roles", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "role_code", + sa.String(length=32), + nullable=False, + comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改", + ), + sa.Column("role_name", sa.String(length=64), nullable=False, comment="岗位名称"), + sa.Column( + "calc_template", + sa.String(length=32), + nullable=False, + comment="计提公式模板编码(以代码内模板注册表为准)", + ), + sa.Column("description", sa.String(length=255), nullable=True, comment="岗位说明"), + sa.Column( + "sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序" + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0禁用", + ), + sa.Column( + "is_builtin", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否内置岗位:1内置(禁删禁停) 0自定义", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_roles")), + sa.UniqueConstraint("role_code", name=op.f("uk_business_roles_role_code")), + comment="业务岗位角色表(与登录角色roles区分,决定计提公式与参数)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_definitions", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "param_code", + sa.String(length=64), + nullable=False, + comment="参数编码,如 salesperson.rate.slope", + ), + sa.Column("param_name", sa.String(length=64), nullable=False, comment="参数名称"), + sa.Column( + "param_group", + sa.String(length=32), + nullable=False, + comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径", + ), + sa.Column( + "value_type", + sa.String(length=16), + nullable=False, + comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT", + ), + sa.Column( + "unit", sa.String(length=16), nullable=True, comment="展示单位:‰/%/元/天,无单位为空" + ), + sa.Column( + "dimension_type", + sa.String(length=32), + server_default="NONE", + nullable=False, + comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE", + ), + sa.Column( + "options_text", + sa.String(length=255), + nullable=True, + comment="枚举选项(竖线分隔),仅 ENUM 使用", + ), + sa.Column( + "default_value", + sa.String(length=64), + nullable=True, + comment="系统默认值(Fraction 可解析字符串)", + ), + sa.Column( + "value_min", sa.String(length=64), nullable=True, comment="取值下限(含),校验用" + ), + sa.Column( + "value_max", sa.String(length=64), nullable=True, comment="取值上限(含),校验用" + ), + sa.Column( + "description", sa.String(length=255), nullable=True, comment="口径说明(配置页展示)" + ), + sa.Column( + "sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序" + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0停用", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_definitions")), + sa.UniqueConstraint("param_code", name=op.f("uk_param_definitions_param_code")), + comment="计提参数定义表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_change_batches", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "version_no", sa.String(length=32), nullable=False, comment="版本号,如 P20261012-01" + ), + sa.Column("effective_date", sa.Date(), nullable=False, comment="生效日期(业务日期口径)"), + sa.Column( + "change_type", + mysql.TINYINT(), + nullable=False, + comment="生效方式:1立即生效 2指定日期生效", + ), + sa.Column("remark", 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( + "item_count", sa.Integer(), server_default="0", 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_param_change_batches_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_change_batches")), + sa.UniqueConstraint("version_no", name=op.f("uk_param_change_batches_version_no")), + comment="参数变更批次表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "param_values", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "param_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="参数定义ID(param_definitions.id)", + ), + sa.Column( + "dimension_value", + sa.String(length=32), + server_default=sa.text("''"), + nullable=False, + comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值", + ), + sa.Column( + "effective_date", sa.Date(), nullable=False, comment="生效日期(按业务日期时点化取值)" + ), + sa.Column( + "value_text", + sa.String(length=64), + nullable=False, + comment="参数值(Fraction 可解析字符串/枚举码/开关)", + ), + sa.Column( + "old_value_text", sa.String(length=64), nullable=True, comment="变更前值(留痕)" + ), + sa.Column( + "batch_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="变更批次ID(param_change_batches.id)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["batch_id"], + ["param_change_batches.id"], + name=op.f("fk_param_values_batch_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["param_id"], + ["param_definitions.id"], + name=op.f("fk_param_values_param_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_param_values")), + comment="参数版本条目表(append-only,按生效日期时点化取值)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_param_values_batch_id", "param_values", ["batch_id"], unique=False) + op.create_index( + "idx_param_values_effective_date", "param_values", ["effective_date"], unique=False + ) + op.create_index( + "idx_param_values_param_id_dimension_value_effective_date", + "param_values", + ["param_id", "dimension_value", "effective_date"], + unique=False, + ) + # ### end Alembic commands ### + + # ---- 种子数据(生产与开发同源;口径依据 2026-10-12 业务裁决对照表)---- + # 显式指定 id:四张表均为本迁移新建、应用时必为空表,id 稳定可控; + # param_values 按 param_id 引用定义,无法依赖自增(bulk_insert 取不回自增值)。 + op.bulk_insert( + sa.table( + "business_roles", + sa.column("id", sa.BigInteger), + sa.column("role_code", sa.String), + sa.column("role_name", sa.String), + sa.column("calc_template", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("is_builtin", sa.Integer), + ), + [ + { + "id": 1, + "role_code": "salesperson", + "role_name": "营销员", + "calc_template": "TPL_SALESPERSON_DECAY", + "description": "业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)", + "sort_order": 1, + "is_builtin": 1, + }, + { + "id": 2, + "role_code": "sales_manager", + "role_name": "营销经理", + "calc_template": "TPL_MANAGER_SHARE", + "description": "经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%", + "sort_order": 2, + "is_builtin": 1, + }, + { + "id": 3, + "role_code": "info_officer", + "role_name": "信息员", + "calc_template": "TPL_INFO_SALES_FIXED", + "description": "信息员绩效:本人销售额×1‰", + "sort_order": 3, + "is_builtin": 1, + }, + ], + ) + + op.bulk_insert( + sa.table( + "param_change_batches", + sa.column("id", sa.BigInteger), + sa.column("version_no", sa.String), + sa.column("effective_date", sa.Date), + sa.column("change_type", sa.Integer), + sa.column("remark", sa.String), + sa.column("operator_name", sa.String), + sa.column("item_count", sa.Integer), + ), + [ + { + "id": 1, + "version_no": "P20260101-00", + "effective_date": date(2026, 1, 1), + "change_type": 1, + "remark": "初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)", + "operator_name": "system", + "item_count": 19, + } + ], + ) + + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + ), + [ + { + "id": 1, + "param_code": "salesperson.rate.start", + "param_name": "业务员起始计提比例", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "10", + "value_min": "0", + "value_max": None, + "description": "线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", + "sort_order": 1, + }, + { + "id": 2, + "param_code": "salesperson.rate.slope", + "param_name": "业务员日递减斜率", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰/天", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "4/360", + "value_min": "0", + "value_max": None, + "description": "精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", + "sort_order": 2, + }, + { + "id": 3, + "param_code": "salesperson.rate.floor", + "param_name": "业务员计提下限", + "param_group": "业务员个人户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "4", + "value_min": "0", + "value_max": None, + "description": "回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", + "sort_order": 3, + }, + { + "id": 4, + "param_code": "salesperson.days.boundary", + "param_name": "线性递减分界天数", + "param_group": "业务员个人户", + "value_type": "INT", + "unit": "天", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "360", + "value_min": "0", + "value_max": None, + "description": "≤分界天数按公式线性递减(360 天=6‰);超过则取下限", + "sort_order": 4, + }, + { + "id": 5, + "param_code": "manager.share.team", + "param_name": "经理本团队分成比例", + "param_group": "经理分成", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "12.5", + "value_min": "0", + "value_max": "100", + "description": "∑本人带领业务员绩效 × 此比例(限本团队)", + "sort_order": 5, + }, + { + "id": 6, + "param_code": "manager.share.company", + "param_name": "经理全公司分成比例", + "param_group": "经理分成", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "5", + "value_min": "0", + "value_max": "100", + "description": "∑全部业务员绩效 × 此比例(全公司)", + "sort_order": 6, + }, + { + "id": 7, + "param_code": "enterprise.rate", + "param_name": "企业户计提比例", + "param_group": "企业户", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "PRODUCT_CATEGORY", + "options_text": None, + "default_value": "3", + "value_min": "0", + "value_max": None, + "description": "所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", + "sort_order": 7, + }, + { + "id": 8, + "param_code": "info.rate", + "param_name": "信息员计提比例", + "param_group": "信息员", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "BUSINESS_ROLE", + "options_text": None, + "default_value": "1", + "value_min": "0", + "value_max": None, + "description": "信息员绩效 = 本人销售额 × 此比例", + "sort_order": 8, + }, + { + "id": 9, + "param_code": "prepay.rate", + "param_name": "预付款计提比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "‰", + "dimension_type": "NONE", + "options_text": None, + "default_value": "10", + "value_min": "0", + "value_max": None, + "description": "预付款统一按此比例计提", + "sort_order": 9, + }, + { + "id": 10, + "param_code": "barter.discount", + "param_name": "以物抵顶折算比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "NONE", + "options_text": None, + "default_value": "80", + "value_min": "0", + "value_max": "100", + "description": "以物抵顶回款绩效按应发绩效的此比例折算", + "sort_order": 10, + }, + { + "id": 11, + "param_code": "payment.start.ratio", + "param_name": "支付起付比例", + "param_group": "支付环节", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "NONE", + "options_text": None, + "default_value": "60", + "value_min": "0", + "value_max": "100", + "description": "单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", + "sort_order": 11, + }, + { + "id": 12, + "param_code": "payment.weight.mode", + "param_name": "支付权重应用方式", + "param_group": "支付环节", + "value_type": "ENUM", + "unit": None, + "dimension_type": "NONE", + "options_text": "FULL|BALANCE", + "default_value": "FULL", + "value_min": None, + "value_max": None, + "description": "FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", + "sort_order": 12, + }, + { + "id": 13, + "param_code": "product.threshold", + "param_name": "折算毛利润额阈值", + "param_group": "产品阈值", + "value_type": "DECIMAL", + "unit": "%", + "dimension_type": "PRODUCT_CATEGORY", + "options_text": None, + "default_value": "30", + "value_min": "0", + "value_max": "100", + "description": "折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", + "sort_order": 13, + }, + { + "id": 14, + "param_code": "salary.basic.mode", + "param_name": "月工资基本工资口径", + "param_group": "月工资口径", + "value_type": "ENUM", + "unit": None, + "dimension_type": "BUSINESS_ROLE", + "options_text": "INCLUDE|DEDUCT|EXCLUDE", + "default_value": "INCLUDE", + "value_min": None, + "value_max": None, + "description": "INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", + "sort_order": 14, + }, + { + "id": 15, + "param_code": "probation.annual.threshold", + "param_name": "试用期年累计结算阈值", + "param_group": "月工资口径", + "value_type": "DECIMAL", + "unit": "元", + "dimension_type": "NONE", + "options_text": None, + "default_value": "18000", + "value_min": "0", + "value_max": None, + "description": "试用期年累计绩效超过此值部分年底一次性结算", + "sort_order": 15, + }, + { + "id": 16, + "param_code": "probation.salary.basic", + "param_name": "试用期基本工资标准", + "param_group": "月工资口径", + "value_type": "DECIMAL", + "unit": "元", + "dimension_type": "NONE", + "options_text": None, + "default_value": "3000", + "value_min": "0", + "value_max": None, + "description": "试用期仅发基本工资、不发放绩效(归属待 M2 复核)", + "sort_order": 16, + }, + ], + ) + + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + # 业务员个人户(2026-02-17 折算公式执行日起) + { + "id": 1, + "param_id": 1, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "10", + "batch_id": 1, + }, + { + "id": 2, + "param_id": 2, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "4/360", + "batch_id": 1, + }, + { + "id": 3, + "param_id": 3, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "4", + "batch_id": 1, + }, + { + "id": 4, + "param_id": 4, + "dimension_value": "salesperson", + "effective_date": date(2026, 2, 17), + "value_text": "360", + "batch_id": 1, + }, + # 经理与信息员(2026-08-13 执行日起) + { + "id": 5, + "param_id": 5, + "dimension_value": "sales_manager", + "effective_date": date(2026, 8, 13), + "value_text": "12.5", + "batch_id": 1, + }, + { + "id": 6, + "param_id": 6, + "dimension_value": "sales_manager", + "effective_date": date(2026, 8, 13), + "value_text": "5", + "batch_id": 1, + }, + { + "id": 7, + "param_id": 7, + "dimension_value": "", + "effective_date": date(2026, 2, 17), + "value_text": "2", + "batch_id": 1, + }, + { + "id": 8, + "param_id": 7, + "dimension_value": "", + "effective_date": date(2026, 8, 13), + "value_text": "3", + "batch_id": 1, + }, + { + "id": 9, + "param_id": 8, + "dimension_value": "info_officer", + "effective_date": date(2026, 8, 13), + "value_text": "1", + "batch_id": 1, + }, + # 支付环节与通用口径(需规默认值,无历史冲突) + { + "id": 10, + "param_id": 9, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "10", + "batch_id": 1, + }, + { + "id": 11, + "param_id": 10, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "80", + "batch_id": 1, + }, + { + "id": 12, + "param_id": 11, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "60", + "batch_id": 1, + }, + { + "id": 13, + "param_id": 12, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "FULL", + "batch_id": 1, + }, + # 产品阈值(按产品类别维度) + { + "id": 14, + "param_id": 13, + "dimension_value": "municipal_water", + "effective_date": date(2026, 2, 17), + "value_text": "30", + "batch_id": 1, + }, + { + "id": 15, + "param_id": 13, + "dimension_value": "rail_transit", + "effective_date": date(2026, 2, 17), + "value_text": "20", + "batch_id": 1, + }, + # 月工资口径(按岗位维度) + { + "id": 16, + "param_id": 14, + "dimension_value": "salesperson", + "effective_date": date(2026, 1, 1), + "value_text": "INCLUDE", + "batch_id": 1, + }, + { + "id": 17, + "param_id": 14, + "dimension_value": "sales_manager", + "effective_date": date(2026, 1, 1), + "value_text": "DEDUCT", + "batch_id": 1, + }, + { + "id": 18, + "param_id": 15, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "18000", + "batch_id": 1, + }, + { + "id": 19, + "param_id": 16, + "dimension_value": "", + "effective_date": date(2026, 1, 1), + "value_text": "3000", + "batch_id": 1, + }, + ], + ) + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。 + # 原 autogenerate 输出的逐个 drop_index 会失败:组合索引最左列 param_id 与 + # idx_param_values_batch_id 分别是两个外键的唯一支撑索引(MySQL errno 1553)。 + # 删除顺序:先删引用方 param_values,再删被引用方。 + op.drop_table("param_values") + op.drop_table("param_change_batches") + op.drop_table("param_definitions") + op.drop_table("business_roles") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c99f584..826689d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,15 +10,21 @@ """ from app.models.base import Base, TimestampMixin +from app.models.business_role import BusinessRole 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.role import Permission, Role, RolePermission from app.models.user import User, UserRole __all__ = [ "Base", + "BusinessRole", "LoginLog", "OperationLog", + "ParamChangeBatch", + "ParamDefinition", + "ParamValue", "Permission", "Role", "RolePermission", diff --git a/backend/app/models/business_role.py b/backend/app/models/business_role.py new file mode 100644 index 0000000..3076010 --- /dev/null +++ b/backend/app/models/business_role.py @@ -0,0 +1,46 @@ +"""业务岗位角色模型。 + +与系统登录角色(`roles`,权限维度)严格区分:本表是**业务岗位**维度, +决定人员适用的计提公式模板与参数。需求 #28 要求岗位角色可增删、 +新增岗位时在系统设置中绑定公式与参数(无需改代码),故模板编码 +存于本表、公式实现留在代码内注册表(一期不引入规则引擎)。 +""" + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class BusinessRole(Base, TimestampMixin): + """业务岗位角色表。内置三个岗位(营销员/营销经理/信息员)随迁移初始化。""" + + __tablename__ = "business_roles" + __table_args__ = mysql_table_args("业务岗位角色表(与登录角色roles区分,决定计提公式与参数)") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + role_code: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改", + ) + role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称") + calc_template: Mapped[str] = mapped_column( + String(32), nullable=False, comment="计提公式模板编码(以代码内模板注册表为准)" + ) + description: Mapped[str | None] = mapped_column(String(255), comment="岗位说明") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用" + ) + is_builtin: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="是否内置岗位:1内置(禁删禁停) 0自定义", + ) diff --git a/backend/app/models/param.py b/backend/app/models/param.py new file mode 100644 index 0000000..2404742 --- /dev/null +++ b/backend/app/models/param.py @@ -0,0 +1,159 @@ +"""计提参数模型:定义、变更批次与版本条目。 + +存储设计(M1 评审定稿): +- 参数值按**生效日期时点化**存储(逐项版本条目,append-only),核算按业务 + 日期回溯取值(对照表 #11:按回款到账日匹配参数版本),禁读「当前值」算历史 +- 「版本」由变更批次承载:一次保存 = 一个批次 = 一条变更记录;条目逐项留痕 + (旧值/新值),满足 #28 版本管理与留痕(需规 2.3.1 四) +- 值一律为 Fraction 可解析字符串(如 "4/360",禁 0.011111),解析不经 float +- **不设 (param_id, dimension_value, effective_date) 唯一键**:同日多次修正 + 是合法留痕,解析取 (effective_date, id) 最新一条;唯一键会把同日二次变更 + 逼成覆盖写、丢失中间值 +- 批次与条目均为 append-only(仅 create_time、无 update_time,同 login_logs + 先例):带 ON UPDATE 的列会让「未改也被刷新」造成留痕假象 +""" + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class ParamDefinition(Base, TimestampMixin): + """计提参数定义表。目录元数据(类型/单位/维度/取值域),随迁移种子初始化。""" + + __tablename__ = "param_definitions" + __table_args__ = mysql_table_args("计提参数定义表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_code: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True, comment="参数编码,如 salesperson.rate.slope" + ) + param_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称") + param_group: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径", + ) + value_type: Mapped[str] = mapped_column( + String(16), nullable=False, comment="值类型:DECIMAL/INT/BOOL/ENUM/TEXT" + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="展示单位:‰/%/元/天,无单位为空") + dimension_type: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default="NONE", + comment="维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE", + ) + options_text: Mapped[str | None] = mapped_column( + String(255), comment="枚举选项(竖线分隔),仅 ENUM 使用" + ) + default_value: Mapped[str | None] = mapped_column( + String(64), comment="系统默认值(Fraction 可解析字符串)" + ) + value_min: Mapped[str | None] = mapped_column(String(64), comment="取值下限(含),校验用") + value_max: Mapped[str | None] = mapped_column(String(64), comment="取值上限(含),校验用") + description: Mapped[str | None] = mapped_column(String(255), comment="口径说明(配置页展示)") + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="显示排序" + ) + status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用" + ) + + +class ParamChangeBatch(Base): + """参数变更批次表(append-only)。 + + 一次提交 = 一个批次;版本号(如 P20261012-01)供对比与追溯引用。 + 未生效批次可整批撤回(删批次与条目);已生效条目禁改禁删。 + """ + + __tablename__ = "param_change_batches" + __table_args__ = mysql_table_args("参数变更批次表") + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + version_no: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="版本号,如 P20261012-01" + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(业务日期口径)" + ) + change_type: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="生效方式:1立即生效 2指定日期生效" + ) + remark: 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="操作人用户名(冗余固化)" + ) + item_count: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="变更参数项数" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) + + +class ParamValue(Base): + """参数版本条目表(append-only)。 + + 解析规则:按 (param_id, dimension_value) 取 effective_date <= 业务日期 的 + ORDER BY effective_date DESC, id DESC 首条(同日多次变更取最新)。 + dimension_value 用空串而非 NULL:MySQL 组合索引对 NULL 不去重,空串语义更稳。 + """ + + __tablename__ = "param_values" + __table_args__ = mysql_table_args( + "参数版本条目表(append-only,按生效日期时点化取值)", + Index( + "idx_param_values_param_id_dimension_value_effective_date", + "param_id", + "dimension_value", + "effective_date", + ), + Index("idx_param_values_effective_date", "effective_date"), + Index("idx_param_values_batch_id", "batch_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + param_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_definitions.id", ondelete="RESTRICT"), + nullable=False, + comment="参数定义ID(param_definitions.id)", + ) + dimension_value: Mapped[str] = mapped_column( + String(32), + nullable=False, + server_default=text("''"), + comment="维度取值:NONE维度为空串;产品类别/岗位编码为具体值", + ) + effective_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="生效日期(按业务日期时点化取值)" + ) + value_text: Mapped[str] = mapped_column( + String(64), nullable=False, comment="参数值(Fraction 可解析字符串/枚举码/开关)" + ) + old_value_text: Mapped[str | None] = mapped_column(String(64), comment="变更前值(留痕)") + batch_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("param_change_batches.id", ondelete="RESTRICT"), + nullable=False, + comment="变更批次ID(param_change_batches.id)", + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 528ce19..d3b8069 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -19,6 +19,10 @@ EXPECTED_TABLES = { "role_permissions", "login_logs", "operation_logs", + "business_roles", + "param_definitions", + "param_change_batches", + "param_values", } diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 4a6e6fc..b713020 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -16,6 +16,10 @@ SET NAMES utf8mb4; -- 删除顺序遵循外键依赖:先删子表 +DROP TABLE IF EXISTS `param_values`; +DROP TABLE IF EXISTS `param_change_batches`; +DROP TABLE IF EXISTS `param_definitions`; +DROP TABLE IF EXISTS `business_roles`; DROP TABLE IF EXISTS `operation_logs`; DROP TABLE IF EXISTS `role_permissions`; DROP TABLE IF EXISTS `user_roles`; @@ -167,6 +171,89 @@ CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id); CREATE INDEX idx_operation_logs_username ON operation_logs (username); +-- --------------------------------------------------------------------------- +-- 业务岗位角色表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 计提参数定义表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数变更批次表(V3 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 参数版本条目表(V3 迁移,append-only) +-- --------------------------------------------------------------------------- +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +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); + -- --------------------------------------------------------------------------- -- 字典数据:角色(与 V1 迁移中的初始化数据一致) -- --------------------------------------------------------------------------- @@ -174,6 +261,57 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2); +-- --------------------------------------------------------------------------- +-- 字典数据:业务岗位与计提参数(与 V3 迁移种子一致;口径依据 2026-10-12 业务裁决) +-- 显式指定 id:param_values 按 param_id 引用定义,与迁移种子保持同源一致 +-- --------------------------------------------------------------------------- +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES +(1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1), +(2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1), +(3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES +(1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES +(1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1), +(2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2), +(3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3), +(4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4), +(5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5), +(6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6), +(7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7), +(8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8), +(9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9), +(10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10), +(11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11), +(12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12), +(13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13), +(14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14), +(15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15), +(16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES +(1, 1, 'salesperson', '2026-02-17', '10', 1), +(2, 2, 'salesperson', '2026-02-17', '4/360', 1), +(3, 3, 'salesperson', '2026-02-17', '4', 1), +(4, 4, 'salesperson', '2026-02-17', '360', 1), +(5, 5, 'sales_manager', '2026-08-13', '12.5', 1), +(6, 6, 'sales_manager', '2026-08-13', '5', 1), +(7, 7, '', '2026-02-17', '2', 1), +(8, 7, '', '2026-08-13', '3', 1), +(9, 8, 'info_officer', '2026-08-13', '1', 1), +(10, 9, '', '2026-01-01', '10', 1), +(11, 10, '', '2026-01-01', '80', 1), +(12, 11, '', '2026-01-01', '60', 1), +(13, 12, '', '2026-01-01', 'FULL', 1), +(14, 13, 'municipal_water', '2026-02-17', '30', 1), +(15, 13, 'rail_transit', '2026-02-17', '20', 1), +(16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1), +(17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1), +(18, 15, '', '2026-01-01', '18000', 1), +(19, 16, '', '2026-01-01', '3000', 1); + -- --------------------------------------------------------------------------- -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- @@ -182,4 +320,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 ('a45a8087831b'); +INSERT INTO alembic_version (version_num) VALUES ('bfbda9c3191d'); diff --git a/db/migration/V3__add_business_roles_and_params.sql b/db/migration/V3__add_business_roles_and_params.sql new file mode 100644 index 0000000..15ad0b5 --- /dev/null +++ b/db/migration/V3__add_business_roles_and_params.sql @@ -0,0 +1,153 @@ +-- Running upgrade a45a8087831b -> bfbda9c3191d + +CREATE TABLE business_roles ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT '岗位编码:salesperson营销员 sales_manager营销经理 info_officer信息员,创建后不可修改', + role_name VARCHAR(64) NOT NULL COMMENT '岗位名称', + calc_template VARCHAR(32) NOT NULL COMMENT '计提公式模板编码(以代码内模板注册表为准)', + description VARCHAR(255) COMMENT '岗位说明', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1', + is_builtin TINYINT NOT NULL COMMENT '是否内置岗位:1内置(禁删禁停) 0自定义' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_roles PRIMARY KEY (id), + CONSTRAINT uk_business_roles_role_code UNIQUE (role_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务岗位角色表(与登录角色roles区分,决定计提公式与参数)' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_definitions ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_code VARCHAR(64) NOT NULL COMMENT '参数编码,如 salesperson.rate.slope', + param_name VARCHAR(64) NOT NULL COMMENT '参数名称', + param_group VARCHAR(32) NOT NULL COMMENT '参数分组:业务员个人户/经理分成/企业户/信息员/支付环节/产品阈值/月工资口径', + value_type VARCHAR(16) NOT NULL COMMENT '值类型:DECIMAL/INT/BOOL/ENUM/TEXT', + unit VARCHAR(16) COMMENT '展示单位:‰/%/元/天,无单位为空', + dimension_type VARCHAR(32) NOT NULL COMMENT '维度类型:NONE/PRODUCT_CATEGORY/BUSINESS_ROLE' DEFAULT 'NONE', + options_text VARCHAR(255) COMMENT '枚举选项(竖线分隔),仅 ENUM 使用', + default_value VARCHAR(64) COMMENT '系统默认值(Fraction 可解析字符串)', + value_min VARCHAR(64) COMMENT '取值下限(含),校验用', + value_max VARCHAR(64) COMMENT '取值上限(含),校验用', + description VARCHAR(255) COMMENT '口径说明(配置页展示)', + sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0', + status TINYINT NOT NULL COMMENT '状态:1启用 0停用' DEFAULT '1', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_param_definitions PRIMARY KEY (id), + CONSTRAINT uk_param_definitions_param_code UNIQUE (param_code) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='计提参数定义表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_change_batches ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + version_no VARCHAR(32) NOT NULL COMMENT '版本号,如 P20261012-01', + effective_date DATE NOT NULL COMMENT '生效日期(业务日期口径)', + change_type TINYINT NOT NULL COMMENT '生效方式:1立即生效 2指定日期生效', + remark VARCHAR(255) COMMENT '变更说明', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + item_count INTEGER NOT NULL COMMENT '变更参数项数' DEFAULT '0', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_change_batches PRIMARY KEY (id), + CONSTRAINT fk_param_change_batches_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_param_change_batches_version_no UNIQUE (version_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数变更批次表' COLLATE utf8mb4_general_ci; + +CREATE TABLE param_values ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + param_id BIGINT UNSIGNED NOT NULL COMMENT '参数定义ID(param_definitions.id)', + dimension_value VARCHAR(32) NOT NULL COMMENT '维度取值:NONE维度为空串;产品类别/岗位编码为具体值' DEFAULT '', + effective_date DATE NOT NULL COMMENT '生效日期(按业务日期时点化取值)', + value_text VARCHAR(64) NOT NULL COMMENT '参数值(Fraction 可解析字符串/枚举码/开关)', + old_value_text VARCHAR(64) COMMENT '变更前值(留痕)', + batch_id BIGINT UNSIGNED NOT NULL COMMENT '变更批次ID(param_change_batches.id)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_param_values PRIMARY KEY (id), + CONSTRAINT fk_param_values_batch_id FOREIGN KEY(batch_id) REFERENCES param_change_batches (id) ON DELETE RESTRICT, + CONSTRAINT fk_param_values_param_id FOREIGN KEY(param_id) REFERENCES param_definitions (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='参数版本条目表(append-only,按生效日期时点化取值)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_param_values_batch_id ON param_values (batch_id); + +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); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (1, 'salesperson', '营销员', 'TPL_SALESPERSON_DECAY', '业务员个人户:回款天数线性递减(10‰ − 天数×4/360‰,361 天起固定 4‰)', 1, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (2, 'sales_manager', '营销经理', 'TPL_MANAGER_SHARE', '经理绩效:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%', 2, 1); + +INSERT INTO business_roles (id, role_code, role_name, calc_template, description, sort_order, is_builtin) VALUES (3, 'info_officer', '信息员', 'TPL_INFO_SALES_FIXED', '信息员绩效:本人销售额×1‰', 3, 1); + +INSERT INTO param_change_batches (id, version_no, effective_date, change_type, remark, operator_name, item_count) VALUES (1, 'P20260101-00', '2026-01-01', 1, '初始版本(系统初始化种子,口径依据 2026-10-12 业务裁决)', 'system', 19); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (1, 'salesperson.rate.start', '业务员起始计提比例', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '10', '0', NULL, '线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)', 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (2, 'salesperson.rate.slope', '业务员日递减斜率', '业务员个人户', 'DECIMAL', '‰/天', 'BUSINESS_ROLE', NULL, '4/360', '0', NULL, '精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储', 2); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (3, 'salesperson.rate.floor', '业务员计提下限', '业务员个人户', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '4', '0', NULL, '回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)', 3); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (4, 'salesperson.days.boundary', '线性递减分界天数', '业务员个人户', 'INT', '天', 'BUSINESS_ROLE', NULL, '360', '0', NULL, '≤分界天数按公式线性递减(360 天=6‰);超过则取下限', 4); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (5, 'manager.share.team', '经理本团队分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '12.5', '0', '100', '∑本人带领业务员绩效 × 此比例(限本团队)', 5); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (6, 'manager.share.company', '经理全公司分成比例', '经理分成', 'DECIMAL', '%', 'BUSINESS_ROLE', NULL, '5', '0', '100', '∑全部业务员绩效 × 此比例(全公司)', 6); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (7, 'enterprise.rate', '企业户计提比例', '企业户', 'DECIMAL', '‰', 'PRODUCT_CATEGORY', NULL, '3', '0', NULL, '所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰', 7); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (8, 'info.rate', '信息员计提比例', '信息员', 'DECIMAL', '‰', 'BUSINESS_ROLE', NULL, '1', '0', NULL, '信息员绩效 = 本人销售额 × 此比例', 8); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (9, 'prepay.rate', '预付款计提比例', '支付环节', 'DECIMAL', '‰', 'NONE', NULL, '10', '0', NULL, '预付款统一按此比例计提', 9); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (10, 'barter.discount', '以物抵顶折算比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '80', '0', '100', '以物抵顶回款绩效按应发绩效的此比例折算', 10); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (11, 'payment.start.ratio', '支付起付比例', '支付环节', 'DECIMAL', '%', 'NONE', NULL, '60', '0', '100', '单项业务回款额达销售额(不含运费)此比例后起付;达标前累计', 11); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (12, 'payment.weight.mode', '支付权重应用方式', '支付环节', 'ENUM', NULL, 'NONE', 'FULL|BALANCE', 'FULL', NULL, NULL, 'FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)', 12); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (13, 'product.threshold', '折算毛利润额阈值', '产品阈值', 'DECIMAL', '%', 'PRODUCT_CATEGORY', NULL, '30', '0', '100', '折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%', 13); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (14, 'salary.basic.mode', '月工资基本工资口径', '月工资口径', 'ENUM', NULL, 'BUSINESS_ROLE', 'INCLUDE|DEDUCT|EXCLUDE', 'INCLUDE', NULL, NULL, 'INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计', 14); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (15, 'probation.annual.threshold', '试用期年累计结算阈值', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '18000', '0', NULL, '试用期年累计绩效超过此值部分年底一次性结算', 15); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order) VALUES (16, 'probation.salary.basic', '试用期基本工资标准', '月工资口径', 'DECIMAL', '元', 'NONE', NULL, '3000', '0', NULL, '试用期仅发基本工资、不发放绩效(归属待 M2 复核)', 16); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (2, 2, 'salesperson', '2026-02-17', '4/360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (3, 3, 'salesperson', '2026-02-17', '4', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (4, 4, 'salesperson', '2026-02-17', '360', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (5, 5, 'sales_manager', '2026-08-13', '12.5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (6, 6, 'sales_manager', '2026-08-13', '5', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (7, 7, '', '2026-02-17', '2', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (8, 7, '', '2026-08-13', '3', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (9, 8, 'info_officer', '2026-08-13', '1', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (10, 9, '', '2026-01-01', '10', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (11, 10, '', '2026-01-01', '80', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (12, 11, '', '2026-01-01', '60', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (13, 12, '', '2026-01-01', 'FULL', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (14, 13, 'municipal_water', '2026-02-17', '30', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (15, 13, 'rail_transit', '2026-02-17', '20', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (16, 14, 'salesperson', '2026-01-01', 'INCLUDE', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (17, 14, 'sales_manager', '2026-01-01', 'DEDUCT', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (18, 15, '', '2026-01-01', '18000', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (19, 16, '', '2026-01-01', '3000', 1); + +UPDATE alembic_version SET version_num='bfbda9c3191d' WHERE alembic_version.version_num = 'a45a8087831b'; + diff --git a/db/queries/计提参数.sql b/db/queries/计提参数.sql new file mode 100644 index 0000000..a4df868 --- /dev/null +++ b/db/queries/计提参数.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- 计提参数查询留档(M1 参数体系) +-- +-- 时点化取值核心规则:按 (param_id, dimension_value) 分组,取 +-- effective_date <= :biz_date 的 ORDER BY effective_date DESC, id DESC 首条 +-- (同日多次变更取最新一条,不设唯一键以保证完整留痕)。 +-- 参数总量约百行级,全量快照一次取回后在应用层按覆盖链 +-- (角色维度 > 产品类别维度 > 默认行)选择,无需在 SQL 层做维度回退。 +-- ============================================================================ + +-- 1. 全量快照:某业务日期下每个(参数, 维度值)的最新生效值 +-- 窗口函数版(MySQL 8.0+),供 param_repository.resolve_values 使用 +SELECT t.param_code, t.dimension_value, t.value_text, t.effective_date +FROM ( + SELECT pd.param_code, + pv.dimension_value, + pv.value_text, + pv.effective_date, + ROW_NUMBER() OVER ( + PARTITION BY pv.param_id, pv.dimension_value + ORDER BY pv.effective_date DESC, pv.id DESC + ) AS rn + FROM param_values pv + JOIN param_definitions pd ON pd.id = pv.param_id + WHERE pv.effective_date <= :biz_date + AND pd.status = 1 +) t +WHERE t.rn = 1; + +-- 2. 单参数版本历史(变更留痕,供参数历史接口与导出) +SELECT pv.effective_date, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + b.version_no, + b.operator_name, + b.create_time +FROM param_values pv +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.param_id = :param_id +ORDER BY pv.effective_date DESC, pv.id DESC; + +-- 3. 区间变更条目(对比视图辅助:应用层对两日期各执行查询 1 后逐项 diff, +-- 本查询列出区间内实际发生过的变更,供变更记录分页与导出) +SELECT pd.param_code, + pd.param_name, + pv.dimension_value, + pv.old_value_text, + pv.value_text, + pv.effective_date, + b.version_no, + b.operator_name +FROM param_values pv +JOIN param_definitions pd ON pd.id = pv.param_id +JOIN param_change_batches b ON b.id = pv.batch_id +WHERE pv.effective_date > :from_date + AND pv.effective_date <= :to_date +ORDER BY pv.effective_date, pv.id; -- 2.27.0 From 5ed5665a54f67d952d57671182f5a7be68a0cd6d Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 16:22:32 +0800 Subject: [PATCH 007/100] =?UTF-8?q?feat(system):=20=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E6=A8=A1=E6=9D=BF=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E7=B2=BE=E7=A1=AE=E5=88=86=E6=95=B0=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formula.py 八个纯函数:回款天数(首日计1)、业务员分段递减(360=6‰/361=4‰)、折算毛利润额化简式(防除零)、乘法方向绩效、以物抵顶/R×R/起付 - param_registry.py:16 项参数元数据(与迁移种子同源)+ Fraction 精确解析(4/360 禁截断)+ 类型/取值域/枚举校验 - calc_templates.py:5 个公式模板注册表(岗位绑定的受控集合) - 新增 43 项单元用例:精确分数断言(899/90000、27 天、9/25),畸形值拒绝 Co-Authored-By: Claude Code --- backend/app/core/constants.py | 9 + backend/app/services/calc_templates.py | 72 +++++ backend/app/services/formula.py | 88 +++++++ backend/app/services/param_registry.py | 304 ++++++++++++++++++++++ backend/tests/unit/test_formula.py | 131 ++++++++++ backend/tests/unit/test_param_registry.py | 154 +++++++++++ 6 files changed, 758 insertions(+) create mode 100644 backend/app/services/calc_templates.py create mode 100644 backend/app/services/formula.py create mode 100644 backend/app/services/param_registry.py create mode 100644 backend/tests/unit/test_formula.py create mode 100644 backend/tests/unit/test_param_registry.py diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index fc44fd9..ed67f0a 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -37,6 +37,15 @@ PASSWORD_MAX_BYTES = 72 CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" CAPTCHA_LENGTH = 4 +# ===== 业务岗位编码(与 db/migration 种子一致,business_roles.role_code)===== +BUSINESS_ROLE_SALESPERSON = "salesperson" +BUSINESS_ROLE_SALES_MANAGER = "sales_manager" +BUSINESS_ROLE_INFO_OFFICER = "info_officer" + +# ===== 产品类别码(与 param_values.dimension_value 口径一致)===== +PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water" +PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" + # ===== 操作日志词表(operation_logs 的 module / action 取值)===== # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 MODULE_USER = "用户管理" diff --git a/backend/app/services/calc_templates.py b/backend/app/services/calc_templates.py new file mode 100644 index 0000000..9f22044 --- /dev/null +++ b/backend/app/services/calc_templates.py @@ -0,0 +1,72 @@ +"""计提公式模板注册表(代码内有限集合)。 + +一期不引入规则引擎的落地方式:公式骨架固定(本表枚举),变化的是参数值 +(系统设置维护)。岗位角色(business_roles.calc_template)绑定模板编码, +新增岗位 = 选模板 + 配参数,无需改代码(需求 #28)。 +新增公式形态(如未来「按产品数量计提」)需要新增模板 —— 一期接受, +岗位表单中模板为带说明的受控下拉,避免误以为可自由写公式。 +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class TemplateSpec: + """公式模板元数据。""" + + code: str + name: str + params: tuple[str, ...] + description: str + + +TEMPLATES: dict[str, TemplateSpec] = { + "TPL_SALESPERSON_DECAY": TemplateSpec( + code="TPL_SALESPERSON_DECAY", + name="业务员个人户·天数线性递减", + params=( + "salesperson.rate.start", + "salesperson.rate.slope", + "salesperson.rate.floor", + "salesperson.days.boundary", + ), + description="分段:≤分界天数 = 起始 − 天数×斜率;>分界天数 = 下限", + ), + "TPL_MANAGER_SHARE": TemplateSpec( + code="TPL_MANAGER_SHARE", + name="营销经理·团队与全公司分成", + params=("manager.share.team", "manager.share.company"), + description="聚合:∑本团队业务员绩效×12.5% + ∑全公司业务员绩效×5%", + ), + "TPL_ENTERPRISE_FIXED": TemplateSpec( + code="TPL_ENTERPRISE_FIXED", + name="企业户·固定计提", + params=("enterprise.rate",), + description="乘法:折算毛利润额 × 计提比例", + ), + "TPL_INFO_SALES_FIXED": TemplateSpec( + code="TPL_INFO_SALES_FIXED", + name="信息员·销售额固定比例", + params=("info.rate",), + description="乘法:本人销售额 × 计提比例", + ), + "TPL_PREPAY_FIXED": TemplateSpec( + code="TPL_PREPAY_FIXED", + name="预付款·固定计提", + params=("prepay.rate",), + description="乘法:回款额 × 计提比例", + ), +} + + +def get_template(code: str) -> TemplateSpec: + """取模板元数据。未注册编码属代码缺陷(500),不是用户错误。""" + tpl = TEMPLATES.get(code) + if tpl is None: + raise ValueError(f"公式模板编码未注册:{code}") + return tpl + + +def required_params(code: str) -> tuple[str, ...]: + """模板声明的参数编码列表(岗位表单展示与校验用)。""" + return get_template(code).params diff --git a/backend/app/services/formula.py b/backend/app/services/formula.py new file mode 100644 index 0000000..fd2c999 --- /dev/null +++ b/backend/app/services/formula.py @@ -0,0 +1,88 @@ +"""计提公式纯函数(无 IO、无会话)。 + +契约(与 M3 核算、计算预览共用,口径见 2026-10-12 业务裁决): +- 参数值按**存储口径**传入(含单位:比例参数为 ‰ 数如 10、百分比参数为 % 数如 60) +- 计算出的中间比率(如毛利润率)为**纯比例**(Fraction,如 9/20) +- 金额用 Decimal、比率用 Fraction,全程禁 float;Fraction→Decimal 转换在 + 分母仅含 2/5 因子时精确,否则按 Decimal 上下文精度(28 位) +- 绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3,禁按"互为倒数"实现) +""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction + + +def _to_decimal(value: Fraction) -> Decimal: + """Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。""" + return Decimal(value.numerator) / Decimal(value.denominator) + + +def payment_days(ship_date: date, receipt_date: date) -> int: + """回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。""" + return (receipt_date - ship_date).days + 1 + + +def salesperson_rate( + days: int, + *, + start: Fraction, + slope: Fraction, + floor: Fraction, + boundary_days: int, +) -> Fraction: + """业务员计提比例(纯比例)。 + + start/floor 为 ‰ 数、slope 为 ‰/天(存储口径,slope 为精确分数 4/360)。 + ≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限 + (对照表 #4:360 天=6‰,361 天起固定 4‰)。 + """ + if days > boundary_days: + return floor / 1000 + return (start - days * slope) / 1000 + + +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction: + """毛利润率(纯比例):毛利润额 / 销售额(**不含运费**口径,对照表 #2)。 + + 销售额为 0 时返回 0(防除零;折算额随之为 0)。 + """ + if revenue == 0: + return Fraction(0) + return Fraction(profit) / Fraction(revenue) + + +def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> Decimal: + """折算毛利润额(化简式):销售额 × (毛利润率 − 阈值)。 + + rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。 + 化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免 + 毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 —— + 业务确认负值正常计算、不归零。 + """ + return _to_decimal(Fraction(revenue) * (rate - threshold / 100)) + + +def performance_amount(base: Decimal, rate: Fraction) -> Decimal: + """绩效金额 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。""" + return _to_decimal(Fraction(base) * rate) + + +def apply_barter(amount: Decimal, discount: Fraction) -> Decimal: + """以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。""" + return _to_decimal(Fraction(amount) * discount / 100) + + +def payment_weight(r: int) -> Fraction: + """支付权重 R×R(纯比例):R 为回款占销售额百分比向下取整(对照表 #22)。 + + 60 → 36/100;100 → 1(全额)。 + """ + return Fraction(r, 100) * Fraction(r, 100) + + +def start_payment_ok(received: Decimal, revenue: Decimal, ratio: Fraction) -> bool: + """起付判定:单项业务回款额达到销售额(不含运费)的 ratio%(对照表 #21)。""" + if revenue == 0: + return False + return Fraction(received) / Fraction(revenue) >= ratio / 100 diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py new file mode 100644 index 0000000..8063a46 --- /dev/null +++ b/backend/app/services/param_registry.py @@ -0,0 +1,304 @@ +"""计提参数目录注册表。 + +注册表是**迁移种子与运行期校验的唯一来源**:编码、默认值、取值域在此定义 +一次;新增参数 = 改这里 + 生成迁移种子(两者一致性由测试锁定)。 +值一律以 Fraction 可解析字符串表示(如 "4/360",禁 0.011111 —— 见算法红线)。 +""" + +from dataclasses import dataclass +from fractions import Fraction + +from app.core.exceptions import BadRequestError + + +@dataclass(frozen=True, slots=True) +class ParamSpec: + """单个参数的元数据(与 param_definitions 表字段一一对应)。""" + + code: str + name: str + group: str + value_type: str + unit: str | None + dimension_type: str + options: tuple[str, ...] | None + default_value: str | None + value_min: str | None + value_max: str | None + description: str | None + sort_order: int + + +PARAM_REGISTRY: dict[str, ParamSpec] = { + spec.code: spec + for spec in [ + ParamSpec( + code="salesperson.rate.start", + name="业务员起始计提比例", + group="业务员个人户", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="10", + value_min="0", + value_max=None, + description="线性递减段起始值:比例 = 起始 − 回款天数 × 斜率(≤分界天数)", + sort_order=1, + ), + ParamSpec( + code="salesperson.rate.slope", + name="业务员日递减斜率", + group="业务员个人户", + value_type="DECIMAL", + unit="‰/天", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="4/360", + value_min="0", + value_max=None, + description="精确分数 4/360‰/天(=1/90),禁止按 0.011111 截断存储", + sort_order=2, + ), + ParamSpec( + code="salesperson.rate.floor", + name="业务员计提下限", + group="业务员个人户", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="4", + value_min="0", + value_max=None, + description="回款天数超过分界天数后固定取此比例(≥361 天 = 4‰)", + sort_order=3, + ), + ParamSpec( + code="salesperson.days.boundary", + name="线性递减分界天数", + group="业务员个人户", + value_type="INT", + unit="天", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="360", + value_min="0", + value_max=None, + description="≤分界天数按公式线性递减(360 天=6‰);超过则取下限", + sort_order=4, + ), + ParamSpec( + code="manager.share.team", + name="经理本团队分成比例", + group="经理分成", + value_type="DECIMAL", + unit="%", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="12.5", + value_min="0", + value_max="100", + description="∑本人带领业务员绩效 × 此比例(限本团队)", + sort_order=5, + ), + ParamSpec( + code="manager.share.company", + name="经理全公司分成比例", + group="经理分成", + value_type="DECIMAL", + unit="%", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="5", + value_min="0", + value_max="100", + description="∑全部业务员绩效 × 此比例(全公司)", + sort_order=6, + ), + ParamSpec( + code="enterprise.rate", + name="企业户计提比例", + group="企业户", + value_type="DECIMAL", + unit="‰", + dimension_type="PRODUCT_CATEGORY", + options=None, + default_value="3", + value_min="0", + value_max=None, + description="所有企业户适用;2026-08-13 起由 2‰ 提高至 3‰", + sort_order=7, + ), + ParamSpec( + code="info.rate", + name="信息员计提比例", + group="信息员", + value_type="DECIMAL", + unit="‰", + dimension_type="BUSINESS_ROLE", + options=None, + default_value="1", + value_min="0", + value_max=None, + description="信息员绩效 = 本人销售额 × 此比例", + sort_order=8, + ), + ParamSpec( + code="prepay.rate", + name="预付款计提比例", + group="支付环节", + value_type="DECIMAL", + unit="‰", + dimension_type="NONE", + options=None, + default_value="10", + value_min="0", + value_max=None, + description="预付款统一按此比例计提", + sort_order=9, + ), + ParamSpec( + code="barter.discount", + name="以物抵顶折算比例", + group="支付环节", + value_type="DECIMAL", + unit="%", + dimension_type="NONE", + options=None, + default_value="80", + value_min="0", + value_max="100", + description="以物抵顶回款绩效按应发绩效的此比例折算", + sort_order=10, + ), + ParamSpec( + code="payment.start.ratio", + name="支付起付比例", + group="支付环节", + value_type="DECIMAL", + unit="%", + dimension_type="NONE", + options=None, + default_value="60", + value_min="0", + value_max="100", + description="单项业务回款额达销售额(不含运费)此比例后起付;达标前累计", + sort_order=11, + ), + ParamSpec( + code="payment.weight.mode", + name="支付权重应用方式", + group="支付环节", + value_type="ENUM", + unit=None, + dimension_type="NONE", + options=("FULL", "BALANCE"), + default_value="FULL", + value_min=None, + value_max=None, + description="FULL=按 R×R 乘当月全额应发绩效;BALANCE=乘余额(语义待 M3 前确认)", + sort_order=12, + ), + ParamSpec( + code="product.threshold", + name="折算毛利润额阈值", + group="产品阈值", + value_type="DECIMAL", + unit="%", + dimension_type="PRODUCT_CATEGORY", + options=None, + default_value="30", + value_min="0", + value_max="100", + description="折算毛利润额 = 销售额 ×(毛利润率 − 阈值);市政水利 30%、轨交 20%", + sort_order=13, + ), + ParamSpec( + code="salary.basic.mode", + name="月工资基本工资口径", + group="月工资口径", + value_type="ENUM", + unit=None, + dimension_type="BUSINESS_ROLE", + options=("INCLUDE", "DEDUCT", "EXCLUDE"), + default_value="INCLUDE", + value_min=None, + value_max=None, + description="INCLUDE含基本工资 / DEDUCT扣除基本工资 / EXCLUDE不计", + sort_order=14, + ), + ParamSpec( + code="probation.annual.threshold", + name="试用期年累计结算阈值", + group="月工资口径", + value_type="DECIMAL", + unit="元", + dimension_type="NONE", + options=None, + default_value="18000", + value_min="0", + value_max=None, + description="试用期年累计绩效超过此值部分年底一次性结算", + sort_order=15, + ), + ParamSpec( + code="probation.salary.basic", + name="试用期基本工资标准", + group="月工资口径", + value_type="DECIMAL", + unit="元", + dimension_type="NONE", + options=None, + default_value="3000", + value_min="0", + value_max=None, + description="试用期仅发基本工资、不发放绩效(归属待 M2 复核)", + sort_order=16, + ), + ] +} + + +def get_spec(code: str) -> ParamSpec: + """取参数元数据。未注册编码属代码缺陷(500),不是用户错误。""" + spec = PARAM_REGISTRY.get(code) + if spec is None: + raise ValueError(f"参数编码未注册:{code}") + return spec + + +def parse_fraction(text: str) -> Fraction: + """Fraction 精确解析("4/360" → 1/90,不经 float)。非法输入抛 400。""" + try: + return Fraction(text.strip()) + except (ValueError, ZeroDivisionError) as exc: + raise BadRequestError(f"参数值无法解析为数值:{text}") from exc + + +def validate_and_normalize(spec: ParamSpec, raw: str) -> str: + """按元数据校验并规范化参数值,返回可入库字符串。 + + DECIMAL:Fraction 解析 + 取值域校验,分数式原样保留("4/360" 不截断); + INT:必须整数;BOOL:仅 0/1;ENUM:必须命中 options;TEXT:原样返回。 + """ + value = raw.strip() + if spec.value_type in {"DECIMAL", "INT"}: + fraction = parse_fraction(value) + if spec.value_type == "INT" and fraction.denominator != 1: + raise BadRequestError(f"参数 {spec.code} 必须为整数:{raw}") + if spec.value_min is not None and fraction < parse_fraction(spec.value_min): + raise BadRequestError(f"参数 {spec.code} 低于取值下限 {spec.value_min}:{raw}") + if spec.value_max is not None and fraction > parse_fraction(spec.value_max): + raise BadRequestError(f"参数 {spec.code} 高于取值上限 {spec.value_max}:{raw}") + return value + if spec.value_type == "BOOL": + if value not in {"0", "1"}: + raise BadRequestError(f"参数 {spec.code} 仅接受 0/1:{raw}") + return value + if spec.value_type == "ENUM": + if not spec.options or value not in spec.options: + allowed = "/".join(spec.options or ()) + raise BadRequestError(f"参数 {spec.code} 取值须为 {allowed}:{raw}") + return value + return value diff --git a/backend/tests/unit/test_formula.py b/backend/tests/unit/test_formula.py new file mode 100644 index 0000000..f252d17 --- /dev/null +++ b/backend/tests/unit/test_formula.py @@ -0,0 +1,131 @@ +"""公式纯函数测试。 + +守的是:算钱公式的精确性与边界 —— 一旦按 float 或字面量截断计算, +360/361 天分界、4/360 斜率、27 天回款示例会全部错位,直接影响工资金额。 +断言一律用 Fraction/Decimal 精确值,禁近似比较。 +""" + +from datetime import date +from decimal import Decimal +from fractions import Fraction + +from app.services.formula import ( + apply_barter, + converted_profit, + payment_days, + payment_weight, + performance_amount, + profit_rate, + salesperson_rate, + start_payment_ok, +) + +# 与迁移种子一致的业务员参数(存储口径:start/floor 为 ‰ 数,slope 为 ‰/天) +SP_START = Fraction(10) +SP_SLOPE = Fraction(4, 360) +SP_FLOOR = Fraction(4) +SP_BOUNDARY = 360 + + +def _sp_rate(days: int) -> Fraction: + return salesperson_rate( + days, start=SP_START, slope=SP_SLOPE, floor=SP_FLOOR, boundary_days=SP_BOUNDARY + ) + + +class TestPaymentDays: + """回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。""" + + def test_发货日当天回款为第1天(self) -> None: + d = date(2026, 1, 1) + assert payment_days(d, d) == 1 + + def test_12月15日发货1月10日回款为27天(self) -> None: + assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27 + + def test_跨年按自然日连续累计(self) -> None: + assert payment_days(date(2025, 12, 31), date(2026, 1, 1)) == 2 + + +class TestSalespersonRate: + """业务员计提比例:1~360 天线性递减、≥361 天固定下限(对照表 #4)。""" + + def test_第360天恰为千分之6_线性段终点(self) -> None: + assert _sp_rate(360) == Fraction(6, 1000) + + def test_第361天固定下限千分之4_分界线(self) -> None: + # 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」 + assert _sp_rate(361) == Fraction(4, 1000) + + def test_第1天按公式为899比90000_非取整为10(self) -> None: + # 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000 + assert _sp_rate(1) == Fraction(899, 90000) + + def test_第180天线性段中点千分之8(self) -> None: + assert _sp_rate(180) == Fraction(8, 1000) + + def test_返回值为纯比例_非千分数(self) -> None: + # 绩效 = 折算额 × 比例,比例必须是 0.006 而非 6 + assert _sp_rate(360) == Fraction(6, 1000) + assert _sp_rate(360) != Fraction(6) + + +class TestProfitRate: + """毛利润率:不含运费口径(对照表 #2),毛利润额 / 销售额。""" + + def test_毛利润率_不含运费(self) -> None: + assert profit_rate(Decimal("45"), Decimal("100")) == Fraction(9, 20) + + def test_销售额为零_返回零_防除零(self) -> None: + assert profit_rate(Decimal("0"), Decimal("0")) == Fraction(0) + + +class TestConvertedProfit: + """折算毛利润额(化简式):销售额 ×(毛利润率 − 阈值)(对照表 #12/#13)。""" + + def test_化简式结果_市政水利30点阈值(self) -> None: + # 100 × (9/20 − 30/100) = 15 + assert converted_profit(Decimal("100"), Fraction(9, 20), Fraction(30)) == Decimal("15") + + def test_化简式与定义式等价(self) -> None: + # 定义式:毛利润额 / 毛利润率 × (毛利润率 − 阈值) = 45 / (9/20) × (9/20 − 3/10) = 15 + revenue, profit = Decimal("100"), Decimal("45") + rate = profit_rate(profit, revenue) + assert converted_profit(revenue, rate, Fraction(30)) == Decimal("15") + + def test_毛利润额为零_折算为零_不除零(self) -> None: + assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0") + + def test_毛利润率低于阈值_折算为负_不归零(self) -> None: + # 100 × (1/10 − 3/10) = -20(业务确认负值正常计算) + assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20") + + +class TestPerformanceAmount: + """绩效 = 折算毛利润额 × 计提比例(乘法方向,对照表 #3)。""" + + def test_乘法方向_折算额乘比例(self) -> None: + # 15000 × 97/10000 = 145.5 + assert performance_amount(Decimal("15000"), Fraction(97, 10000)) == Decimal("145.5") + + def test_负折算额_绩效为负不归零(self) -> None: + assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12") + + +class TestModifiers: + """修饰器:以物抵顶、支付权重 R×R、起付判定(对照表 #21/#22/#24)。""" + + def test_以物抵顶_按80点折算(self) -> None: + assert apply_barter(Decimal("145.5"), Fraction(80)) == Decimal("116.4") + + def test_支付权重_R为60时0点36(self) -> None: + assert payment_weight(60) == Fraction(36, 100) + + def test_支付权重_R为100时全额(self) -> None: + assert payment_weight(100) == Fraction(1) + + def test_起付判定_达到60点(self) -> None: + assert start_payment_ok(Decimal("60000"), Decimal("100000"), Fraction(60)) is True + + def test_起付判定_未达到60点(self) -> None: + assert start_payment_ok(Decimal("59999"), Decimal("100000"), Fraction(60)) is False diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py new file mode 100644 index 0000000..6c7c596 --- /dev/null +++ b/backend/tests/unit/test_param_registry.py @@ -0,0 +1,154 @@ +"""参数注册表与公式模板注册表测试。 + +守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值 +与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守, +畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。 +""" + +from fractions import Fraction + +import pytest + +from app.core.exceptions import BadRequestError +from app.services.calc_templates import TEMPLATES, get_template, required_params +from app.services.param_registry import ( + PARAM_REGISTRY, + get_spec, + parse_fraction, + validate_and_normalize, +) + +# 与 db/migration/V3 迁移种子保持一致的 16 项编码 +EXPECTED_CODES = { + "salesperson.rate.start", + "salesperson.rate.slope", + "salesperson.rate.floor", + "salesperson.days.boundary", + "manager.share.team", + "manager.share.company", + "enterprise.rate", + "info.rate", + "prepay.rate", + "barter.discount", + "payment.start.ratio", + "payment.weight.mode", + "product.threshold", + "salary.basic.mode", + "probation.annual.threshold", + "probation.salary.basic", +} + + +class TestRegistry: + """注册表元数据完整性(与迁移种子同源)。""" + + def test_注册表共16项(self) -> None: + assert len(PARAM_REGISTRY) == 16 + + def test_编码集合与迁移种子一致(self) -> None: + assert set(PARAM_REGISTRY) == EXPECTED_CODES + + def test_每项必填字段非空(self) -> None: + for spec in PARAM_REGISTRY.values(): + assert spec.code, "code 为空" + assert spec.name, f"{spec.code} 缺中文名称" + assert spec.group, f"{spec.code} 缺分组" + assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"} + + def test_默认值可按类型解析(self) -> None: + for spec in PARAM_REGISTRY.values(): + if spec.default_value is None: + continue + if spec.value_type in {"DECIMAL", "INT"}: + parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError + elif spec.value_type == "ENUM": + assert spec.options, f"{spec.code} 为 ENUM 但无选项" + assert spec.default_value in spec.options + + def test_取值域上下限可解析(self) -> None: + for spec in PARAM_REGISTRY.values(): + if spec.value_min is not None: + parse_fraction(spec.value_min) + if spec.value_max is not None: + parse_fraction(spec.value_max) + + def test_取未注册编码抛ValueError_属代码缺陷(self) -> None: + with pytest.raises(ValueError): + get_spec("not.exist.code") + + +class TestParseFraction: + """Fraction 精确解析:4/360 不许经 float 中转。""" + + def test_分数式精确解析_4除以360(self) -> None: + assert parse_fraction("4/360") == Fraction(1, 90) + + def test_整数解析(self) -> None: + assert parse_fraction("10") == Fraction(10) + + def test_小数解析(self) -> None: + assert parse_fraction("12.5") == Fraction(25, 2) + + def test_除零分数拒绝(self) -> None: + with pytest.raises(BadRequestError): + parse_fraction("1/0") + + def test_非数字拒绝(self) -> None: + with pytest.raises(BadRequestError): + parse_fraction("abc") + + +class TestValidateAndNormalize: + """参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。""" + + def test_去除首尾空白后返回规范值(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] + assert validate_and_normalize(spec, " 80 ") == "80" + + def test_超取值域上限拒绝(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] # value_max=100 + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "120") + + def test_低于取值域下限拒绝(self) -> None: + spec = PARAM_REGISTRY["barter.discount"] # value_min=0 + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "-1") + + def test_枚举越界拒绝(self) -> None: + spec = PARAM_REGISTRY["payment.weight.mode"] + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "PARTIAL") + + def test_枚举合法值通过(self) -> None: + spec = PARAM_REGISTRY["payment.weight.mode"] + assert validate_and_normalize(spec, "BALANCE") == "BALANCE" + + def test_整数类型拒绝小数(self) -> None: + spec = PARAM_REGISTRY["salesperson.days.boundary"] + with pytest.raises(BadRequestError): + validate_and_normalize(spec, "360.5") + + def test_分数式斜率原样保留不截断(self) -> None: + spec = PARAM_REGISTRY["salesperson.rate.slope"] + assert validate_and_normalize(spec, "4/360") == "4/360" + + +class TestTemplates: + """公式模板注册表:有限集合,声明参数必须真实存在。""" + + def test_共五个模板(self) -> None: + assert len(TEMPLATES) == 5 + + def test_模板声明参数均在注册表内(self) -> None: + for tpl in TEMPLATES.values(): + for code in tpl.params: + assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}" + + def test_取模板声明参数(self) -> None: + params = required_params("TPL_SALESPERSON_DECAY") + assert "salesperson.rate.slope" in params + + def test_未知模板抛ValueError_属代码缺陷(self) -> None: + with pytest.raises(ValueError): + get_template("TPL_NOT_EXIST") -- 2.27.0 From 1b3509b20fb68e34f6642403b3caac96716db4e7 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 16:27:56 +0800 Subject: [PATCH 008/100] =?UTF-8?q?feat(system):=20=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=8C=89=E4=B8=9A=E5=8A=A1=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E6=97=B6=E7=82=B9=E5=8C=96=E5=8F=96=E5=80=BC=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - param_repository.resolve_values:窗口函数一条 SQL 取全量快照(同日多次变更取 effective_date DESC, id DESC 首条) - param_service.ParamSnapshot:冻结快照 + 覆盖链(角色 > 产品类别 > 默认行)+ 注册表默认值兜底;fraction/integer/boolean/text 类型化取值 - 12 项单元用例:覆盖链优先级、兜底、同日取最新、冻结不可变 Co-Authored-By: Claude Code --- backend/app/repositories/param_repository.py | 46 ++++++++++ backend/app/services/param_service.py | 87 ++++++++++++++++++ backend/tests/unit/test_param_snapshot.py | 92 ++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 backend/app/repositories/param_repository.py create mode 100644 backend/app/services/param_service.py create mode 100644 backend/tests/unit/test_param_snapshot.py diff --git a/backend/app/repositories/param_repository.py b/backend/app/repositories/param_repository.py new file mode 100644 index 0000000..8c461d6 --- /dev/null +++ b/backend/app/repositories/param_repository.py @@ -0,0 +1,46 @@ +"""计提参数数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +时点化解析查询(窗口函数版)留存 db/queries/计提参数.sql。 +""" + +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import ParamDefinition, ParamValue + +# ==================== 读取 ==================== + + +def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str]]: + """时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。 + + 排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条 + (不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。 + 返回 (param_code, dimension_value, value_text),供 ParamSnapshot 构造。 + """ + row_number = ( + func.row_number() + .over( + partition_by=(ParamValue.param_id, ParamValue.dimension_value), + order_by=(ParamValue.effective_date.desc(), ParamValue.id.desc()), + ) + .label("rn") + ) + subq = ( + select( + ParamDefinition.param_code.label("param_code"), + ParamValue.dimension_value.label("dimension_value"), + ParamValue.value_text.label("value_text"), + row_number, + ) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1) + .subquery() + ) + stmt = select(subq.c.param_code, subq.c.dimension_value, subq.c.value_text).where( + subq.c.rn == 1 + ) + return [(row.param_code, row.dimension_value, row.value_text) for row in session.execute(stmt)] diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py new file mode 100644 index 0000000..a0b6bd8 --- /dev/null +++ b/backend/app/services/param_service.py @@ -0,0 +1,87 @@ +"""计提参数服务:时点化取值(解析部分)。 + +ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日) +冻结当时口径,禁读「当前值」计算历史数据(算法红线)。 +变更提交/历史/对比/预览/导出在后续任务追加。 +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date +from fractions import Fraction + +from sqlalchemy.orm import Session + +from app.core.exceptions import BadRequestError +from app.repositories.param_repository import resolve_values +from app.services.param_registry import get_spec, parse_fraction + + +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + """某业务日期下的参数快照(冻结不可变,核算期间保持一致)。 + + 覆盖链:角色维度 > 产品类别维度 > 默认行('');均无匹配时回退注册表默认值。 + """ + + biz_date: date + _values: dict[str, dict[str, str]] + + @classmethod + def from_rows(cls, biz_date: date, rows: Iterable[tuple[str, str, str]]) -> "ParamSnapshot": + """由 (param_code, dimension_value, value_text) 行构造。 + + rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。 + """ + values: dict[str, dict[str, str]] = {} + for code, dimension, value_text in rows: + values.setdefault(code, {}).setdefault(dimension, value_text) + return cls(biz_date=biz_date, _values=values) + + def get( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> str: + """取参数原始值(覆盖链解析)。无值且无注册表默认值时抛 400。""" + candidates = self._values.get(code) + if candidates: + for dimension in (role_code, product_category, ""): + if dimension is not None and dimension in candidates: + return candidates[dimension] + spec = get_spec(code) # 未注册编码 → ValueError(代码缺陷) + if spec.default_value is None: + raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护") + return spec.default_value + + def fraction( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> Fraction: + """取参数值并精确解析为 Fraction("4/360" → 1/90,不经 float)。""" + return parse_fraction( + self.get(code, role_code=role_code, product_category=product_category) + ) + + def integer( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> int: + """取参数值为整数。配置成非整数属数据缺陷(写入经校验,不应发生)。""" + value = self.fraction(code, role_code=role_code, product_category=product_category) + if value.denominator != 1: + raise ValueError(f"参数 {code} 配置为非整数:{value}") + return int(value) + + def boolean( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> bool: + """取布尔参数(0/1)。""" + return self.get(code, role_code=role_code, product_category=product_category) == "1" + + def text( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> str: + """取参数原文(枚举码/文本)。""" + return self.get(code, role_code=role_code, product_category=product_category) + + +def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot: + """按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。""" + return ParamSnapshot.from_rows(biz_date, resolve_values(session, biz_date)) diff --git a/backend/tests/unit/test_param_snapshot.py b/backend/tests/unit/test_param_snapshot.py new file mode 100644 index 0000000..8207964 --- /dev/null +++ b/backend/tests/unit/test_param_snapshot.py @@ -0,0 +1,92 @@ +"""参数快照(时点化取值)测试。 + +守的是:覆盖链与兜底 —— 一旦取错维度(如企业户取到市政阈值行) +或漏兜底,核算会用错口径或直接崩溃;同日多次变更必须取最新留痕。 +仓储 SQL 的排序语义(同日取 id 最大)在集成测试中覆盖。 +""" + +from dataclasses import FrozenInstanceError +from datetime import date +from fractions import Fraction + +import pytest + +from app.services.param_service import ParamSnapshot + + +def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot: + """构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。""" + return ParamSnapshot.from_rows(date(2026, 10, 1), rows) + + +class TestCoverChain: + """覆盖链:角色维度 > 产品类别维度 > 默认行('')。""" + + def test_产品类别维度优先于默认行(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")]) + assert snap.get("enterprise.rate", product_category="municipal_water") == "2" + + def test_无匹配维度时落默认行(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "municipal_water", "2")]) + assert snap.get("enterprise.rate", product_category="rail_transit") == "3" + + def test_角色维度优先于产品类别维度(self) -> None: + snap = _snap([("some.param", "municipal_water", "A"), ("some.param", "salesperson", "B")]) + got = snap.get("some.param", role_code="salesperson", product_category="municipal_water") + assert got == "B" + + def test_无任何维度匹配且无默认行_回退注册表默认值(self) -> None: + snap = _snap([("enterprise.rate", "municipal_water", "2")]) + # 轨交无行、无默认行 → 回退注册表 default_value(3) + assert snap.fraction("enterprise.rate", product_category="rail_transit") == Fraction(3) + + +class TestFallback: + """注册表默认值兜底(种子缺失时的安全网)。""" + + def test_未定义参数按注册表默认值兜底(self) -> None: + snap = _snap([]) + assert snap.fraction("prepay.rate") == Fraction(10) + + def test_未注册编码抛ValueError_属代码缺陷(self) -> None: + snap = _snap([]) + with pytest.raises(ValueError): + snap.fraction("not.registered.code") + + +class TestSameDayChanges: + """同日多次变更:仓储 SQL 已按 (effective_date, id) 倒序,快照取先出现者。""" + + def test_同日多次变更取最新一条(self) -> None: + snap = _snap([("enterprise.rate", "", "3"), ("enterprise.rate", "", "2")]) + assert snap.fraction("enterprise.rate") == Fraction(3) + + +class TestTypedGetters: + """类型化取值:Fraction 精确解析、整数、布尔、文本。""" + + def test_fraction精确解析分数式(self) -> None: + snap = _snap([("salesperson.rate.slope", "salesperson", "4/360")]) + assert snap.fraction("salesperson.rate.slope", role_code="salesperson") == Fraction(1, 90) + + def test_integer(self) -> None: + snap = _snap([("salesperson.days.boundary", "salesperson", "360")]) + assert snap.integer("salesperson.days.boundary", role_code="salesperson") == 360 + + def test_text返回枚举原文(self) -> None: + snap = _snap([("payment.weight.mode", "", "FULL")]) + assert snap.text("payment.weight.mode") == "FULL" + + def test_boolean(self) -> None: + snap = _snap([("some.flag", "", "1")]) + assert snap.boolean("some.flag") is True + assert snap.boolean("some.flag") is not False + + +class TestImmutability: + """快照冻结:解析结果不得被调用方改写(核算期间一致性)。""" + + def test_字段不可赋值(self) -> None: + snap = _snap([]) + with pytest.raises(FrozenInstanceError): + snap.biz_date = date(2026, 1, 1) # type: ignore[misc] -- 2.27.0 From 7a0f3f4b9892a9ddeb251bbe872ffddc41e8ce88 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 16:39:08 +0800 Subject: [PATCH 009/100] =?UTF-8?q?feat(system):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E8=A7=92=E8=89=B2=E7=AE=A1=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E4=BF=9D=E6=8A=A4=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 四层实现:仓储(FOR UPDATE 锁行 + 引用计数钩子预留)/ schemas / service / api 共 7 个端点 - 保护规则:内置岗位禁删禁停、编码创建后不可改、模板须在注册表内(未注册 400) - 写操作全走审计上下文;冒烟验证 7 项(含成功/失败双路径落日志) - 端点:列表/新增/详情/编辑/删除/启停 + meta/templates 模板下拉 Co-Authored-By: Claude Code --- backend/app/api/router.py | 2 + backend/app/api/v1/system/business_roles.py | 122 ++++++++ backend/app/core/constants.py | 1 + .../repositories/business_role_repository.py | 88 ++++++ backend/app/schemas/business_role.py | 90 ++++++ backend/app/services/business_role_service.py | 287 ++++++++++++++++++ 6 files changed, 590 insertions(+) create mode 100644 backend/app/api/v1/system/business_roles.py create mode 100644 backend/app/repositories/business_role_repository.py create mode 100644 backend/app/schemas/business_role.py create mode 100644 backend/app/services/business_role_service.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5a89cf7..9999577 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import logs as system_logs from app.api.v1.system import roles as system_roles from app.api.v1.system import users as system_users @@ -25,6 +26,7 @@ v1_router.include_router(auth.router) system_router = APIRouter(prefix="/system") system_router.include_router(system_users.router) system_router.include_router(system_roles.router) +system_router.include_router(system_business_roles.router) system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.login_router) v1_router.include_router(system_router) diff --git a/backend/app/api/v1/system/business_roles.py b/backend/app/api/v1/system/business_roles.py new file mode 100644 index 0000000..62e9b96 --- /dev/null +++ b/backend/app/api/v1/system/business_roles.py @@ -0,0 +1,122 @@ +"""业务岗位角色管理接口(需求 #28:岗位可扩展 + 绑定公式模板)。 + +保护规则(内置岗位禁删禁停、编码不可改、模板须在注册表内)见 +`business_role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。 + +全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service +层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Path, Query + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.business_role import ( + BusinessRoleCreateIn, + BusinessRoleOut, + BusinessRoleQuery, + BusinessRoleStatusIn, + BusinessRoleUpdateIn, + TemplateOptionOut, +) +from app.services import business_role_service + +router = APIRouter(prefix="/business-roles", tags=["系统管理 · 岗位角色"]) + + +@router.get("", response_model=ApiResponse[PageResult[BusinessRoleOut]], summary="岗位列表") +def list_roles( + session: DbSession, + _: AdminUser, + query: Annotated[BusinessRoleQuery, Query()], +) -> ApiResponse[PageResult[BusinessRoleOut]]: + """分页查询业务岗位角色(含停用)。""" + return ok(business_role_service.list_roles(session, query)) + + +@router.post("", response_model=ApiResponse[BusinessRoleOut], summary="新增岗位") +def create_role( + session: DbSession, + current_user: AdminUser, + payload: BusinessRoleCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """新增岗位。编码全局唯一、创建后不可修改;模板须在注册表内;新岗位默认启用。""" + return ok( + business_role_service.create_role(session, payload, operator=current_user, meta=meta), + message="岗位已创建", + ) + + +@router.get( + "/meta/templates", + response_model=ApiResponse[list[TemplateOptionOut]], + summary="公式模板下拉", +) +def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[TemplateOptionOut]]: + """取公式模板下拉(含声明参数,供岗位表单展示)。 + + 定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。 + """ + return ok(business_role_service.list_template_options()) + + +@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情") +def get_role( + session: DbSession, + _: AdminUser, + role_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[BusinessRoleOut]: + """按主键查询岗位详情。""" + return ok(business_role_service.get_role(session, role_id)) + + +@router.put("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="编辑岗位") +def update_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: BusinessRoleUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """编辑岗位名称/模板/说明/排序。 + + **不含 role_code** —— 编码是参数维度值与模板绑定的锚点。 + """ + return ok( + business_role_service.update_role( + session, role_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.put( + "/{role_id}/status", response_model=ApiResponse[BusinessRoleOut], summary="启用/禁用岗位" +) +def set_status( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + payload: BusinessRoleStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessRoleOut]: + """启用或禁用岗位。内置岗位不可停用。""" + result = business_role_service.set_status( + session, role_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已禁用") + + +@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除岗位") +def delete_role( + session: DbSession, + current_user: AdminUser, + role_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。""" + business_role_service.delete_role(session, role_id, operator=current_user, meta=meta) + return ok(message="岗位已删除") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index ed67f0a..4d9f579 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -50,6 +50,7 @@ PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 MODULE_USER = "用户管理" MODULE_ROLE = "角色管理" +MODULE_BUSINESS_ROLE = "岗位角色" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/repositories/business_role_repository.py b/backend/app/repositories/business_role_repository.py new file mode 100644 index 0000000..f245c4f --- /dev/null +++ b/backend/app/repositories/business_role_repository.py @@ -0,0 +1,88 @@ +"""业务岗位角色数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import BusinessRole + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> BusinessRole | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 role_repository 的理由)。""" + if for_update: + stmt = select(BusinessRole).where(BusinessRole.id == role_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(BusinessRole, role_id) + + +def get_by_code(session: Session, role_code: str) -> BusinessRole | None: + """按编码查询岗位。编码是参数维度值(dimension_value)的锚点。""" + stmt = select(BusinessRole).where(BusinessRole.role_code == role_code) + return session.execute(stmt).scalar_one_or_none() + + +def list_roles( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, +) -> list[BusinessRole]: + """分页查询岗位(含停用)。排序 (sort_order, id),与系统角色口径一致。""" + stmt = _apply_filters(select(BusinessRole), keyword=keyword, status=status) + stmt = stmt.order_by(BusinessRole.sort_order, BusinessRole.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int: + """统计符合条件的岗位总数,供分页使用。""" + stmt = _apply_filters(select(func.count(BusinessRole.id)), keyword=keyword, status=status) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词走 ``contains(autoescape=True)``(转义 LIKE 通配符)——岗位编码含下划线, + 不转义时 ``_`` 会当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + BusinessRole.role_code.contains(keyword, autoescape=True), + BusinessRole.role_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(BusinessRole.status == status) + return stmt + + +def count_personnel_by_role(session: Session, role_ids: list[int]) -> dict[int, int]: + """批量统计各岗位下的人员数(删除保护的依据)。 + + ② 基础数据管理落地 personnel 表后在此实现真实计数(一次 GROUP BY, + 禁止列表循环内逐行查询);当前无人员表,恒返回空,删除校验对空字典放行。 + """ + return {} + + +# ==================== 写入 ==================== + + +def create(session: Session, role: BusinessRole) -> BusinessRole: + """新增岗位。只 flush 以取得自增 id。""" + session.add(role) + session.flush() + return role + + +def delete(session: Session, role: BusinessRole) -> None: + """删除岗位。调用方须先完成保护规则校验。""" + session.delete(role) + session.flush() diff --git a/backend/app/schemas/business_role.py b/backend/app/schemas/business_role.py new file mode 100644 index 0000000..fe6a0ab --- /dev/null +++ b/backend/app/schemas/business_role.py @@ -0,0 +1,90 @@ +"""业务岗位角色的请求/响应模型。""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 岗位编码规则与系统角色一致:小写字母开头,后接小写字母/数字/下划线,长度 2–32。 +# 编码会作为参数维度值(param_values.dimension_value)与核算模板绑定的锚点, +# 保持单一风格避免大小写/全角混用造成对照困难 +BUSINESS_ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$") + + +class BusinessRoleOut(BaseModel): + """岗位响应(列表与详情共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + role_code: str + role_name: str + calc_template: str + description: str | None = None + sort_order: int = 0 + status: int = 1 + is_builtin: int = 0 + + +class TemplateParamOut(BaseModel): + """模板声明的参数(供岗位表单展示,含当前口径说明)。""" + + code: str + name: str + + +class TemplateOptionOut(BaseModel): + """公式模板下拉项。""" + + code: str + name: str + description: str + params: list[TemplateParamOut] + + +class BusinessRoleCreateIn(BaseModel): + """新增岗位。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + role_code: str = Field(min_length=2, max_length=32, description="岗位编码,创建后不可修改") + role_name: str = Field(min_length=1, max_length=64, description="岗位名称") + calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + @field_validator("role_code") + @classmethod + def _check_code(cls, value: str) -> str: + if not BUSINESS_ROLE_CODE_PATTERN.match(value): + raise ValueError("岗位编码须以小写字母开头,仅可含小写字母、数字与下划线") + return value + + +class BusinessRoleUpdateIn(BaseModel): + """编辑岗位。 + + **不含 role_code** —— 编码是参数维度值与模板绑定的锚点(同系统角色的保护理由)。 + 前端表单全量提交,故为全量替换语义。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + role_name: str = Field(min_length=1, max_length=64, description="岗位名称") + calc_template: str = Field(min_length=1, max_length=32, description="计提公式模板编码") + description: str | None = Field(default=None, max_length=255) + sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前") + + +class BusinessRoleStatusIn(BaseModel): + """启用/禁用岗位。""" + + status: int = Field(ge=0, le=1, description="1启用 0禁用") + + +class BusinessRoleQuery(PageQuery): + """岗位列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/services/business_role_service.py b/backend/app/services/business_role_service.py new file mode 100644 index 0000000..d89037c --- /dev/null +++ b/backend/app/services/business_role_service.py @@ -0,0 +1,287 @@ +"""业务岗位角色管理业务编排。 + +需求 #28 的落点:岗位可增删、绑定公式模板(有限集合,代码内注册表), +新增岗位无需改代码。保护规则(前端会禁用相应按钮,**后端校验是唯一防线**): + +- 内置岗位(is_builtin=1:营销员/营销经理/信息员)**禁删禁停** —— + 参数种子与核算模板按编码锚定(如 salesperson 的递减公式参数行) +- `role_code` 创建后不可改 —— 它是参数维度值(param_values.dimension_value) + 与模板绑定语义的锚点 +- `calc_template` 必须是模板注册表(calc_templates.TEMPLATES)内的编码 +- 有人员引用的岗位禁删 —— ② 落地 personnel 表后由引用计数生效, + 当前钩子恒为空(见 business_role_repository.count_personnel_by_role) +""" + +import logging + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + MODULE_BUSINESS_ROLE, +) +from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.response import PageResult +from app.models import BusinessRole, User +from app.repositories import business_role_repository +from app.schemas.business_role import ( + BusinessRoleCreateIn, + BusinessRoleOut, + BusinessRoleQuery, + BusinessRoleUpdateIn, + TemplateOptionOut, + TemplateParamOut, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.calc_templates import TEMPLATES +from app.services.param_registry import PARAM_REGISTRY + +logger = logging.getLogger(__name__) + + +def _to_out(role: BusinessRole) -> BusinessRoleOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return BusinessRoleOut( + id=role.id, + role_code=role.role_code, + role_name=role.role_name, + calc_template=role.calc_template, + description=role.description, + sort_order=role.sort_order, + status=role.status, + is_builtin=role.is_builtin, + ) + + +def _ensure_template(calc_template: str) -> None: + """模板必须存在于代码内注册表(前端为受控下拉,后端仍须校验)。""" + if calc_template not in TEMPLATES: + raise BadRequestError(f"计提公式模板 {calc_template!r} 不存在") + + +def list_roles(session: Session, query: BusinessRoleQuery) -> PageResult[BusinessRoleOut]: + """分页查询岗位(含停用)。""" + roles = business_role_repository.list_roles( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + ) + total = business_role_repository.count_roles( + session, keyword=query.keyword, status=query.status + ) + return PageResult[BusinessRoleOut]( + records=[_to_out(role) for role in roles], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_role(session: Session, role_id: int) -> BusinessRoleOut: + """岗位详情。 + + :raises NotFoundError: 岗位不存在 + """ + role = business_role_repository.get_by_id(session, role_id) + if role is None: + raise NotFoundError("岗位不存在") + return _to_out(role) + + +def list_template_options() -> list[TemplateOptionOut]: + """公式模板下拉(含声明参数,供岗位表单展示与引导配置)。""" + options: list[TemplateOptionOut] = [] + for tpl in TEMPLATES.values(): + options.append( + TemplateOptionOut( + code=tpl.code, + name=tpl.name, + description=tpl.description, + params=[ + TemplateParamOut(code=code, name=PARAM_REGISTRY[code].name) + for code in tpl.params + ], + ) + ) + return options + + +def create_role( + session: Session, + payload: BusinessRoleCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """新增岗位。编码唯一、创建后不可修改,新岗位固定启用、非内置。 + + :raises BadRequestError: 编码已存在 / 模板不存在 + """ + _ensure_template(payload.calc_template) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_CREATE, + summary=f"新增岗位 {payload.role_name}({payload.role_code})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if business_role_repository.get_by_code(session, payload.role_code) is not None: + raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") + + role = BusinessRole( + role_code=payload.role_code, + role_name=payload.role_name, + calc_template=payload.calc_template, + description=payload.description, + sort_order=payload.sort_order, + status=1, + is_builtin=0, + ) + try: + business_role_repository.create(session, role) + out = _to_out(role) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一编码会撞 uk_business_roles_role_code + session.rollback() + raise BadRequestError(f"岗位编码 {payload.role_code!r} 已存在") from exc + + logger.info( + "新增岗位 | role_code=%s | template=%s", payload.role_code, payload.calc_template + ) + return out + + +def update_role( + session: Session, + role_id: int, + payload: BusinessRoleUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """编辑岗位名称/模板/说明/排序(全量替换,不含 role_code)。 + + :raises NotFoundError: 岗位不存在 + :raises BadRequestError: 模板不存在 + """ + role = business_role_repository.get_by_id(session, role_id) + _ensure_template(payload.calc_template) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_UPDATE, + summary=f"修改岗位 {role.role_name if role else f'#{role_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + role.role_name = payload.role_name + role.calc_template = payload.calc_template + role.description = payload.description + role.sort_order = payload.sort_order + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(role) + session.commit() + logger.info("修改岗位 | role_id=%s | template=%s", role_id, payload.calc_template) + return out + + +def set_status( + session: Session, + role_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessRoleOut: + """启用或禁用岗位。内置岗位不可停用(参数种子按编码锚定)。 + + :raises NotFoundError: 岗位不存在 + :raises ForbiddenError: 试图停用内置岗位 + """ + role = business_role_repository.get_by_id(session, role_id) + verb = "启用" if status == 1 else "禁用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}岗位 {role.role_name if role else f'#{role_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + if role.is_builtin == 1 and status == 0: + raise ForbiddenError("内置岗位不可停用") + + role.status = status + out = _to_out(role) + session.commit() + logger.info("变更岗位状态 | role_id=%s | status=%s", role_id, status) + return out + + +def delete_role( + session: Session, + role_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除岗位。内置岗位不可删除;有人员引用的岗位不可删除。 + + 查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用(M2 接入后), + 锁行使并发写入失败(可见)而非引用被静默悬空(不可见)。 + + :raises NotFoundError: 岗位不存在 + :raises ForbiddenError: 试图删除内置岗位 + :raises BadRequestError: 岗位下仍有人引用 + """ + role = business_role_repository.get_by_id(session, role_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS_ROLE, + action=ACTION_DELETE, + summary=f"删除岗位 {role.role_name if role else f'#{role_id}'}", + ), + meta=meta, + ): + if role is None: + raise NotFoundError("岗位不存在") + + if role.is_builtin == 1: + raise ForbiddenError("内置岗位不可删除") + + counts = business_role_repository.count_personnel_by_role(session, [role_id]) + person_count = counts.get(role_id, 0) + if person_count > 0: + raise BadRequestError(f"该岗位下仍有 {person_count} 名人员,请先调整归属") + + role_code = role.role_code # 固化:对象随删除失效 + business_role_repository.delete(session, role) + session.commit() + logger.info("删除岗位 | role_code=%s", role_code) -- 2.27.0 From 8ad3650dd381cdca2669b490864d25b2f09ce593 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 20:24:17 +0800 Subject: [PATCH 010/100] =?UTF-8?q?feat(system):=20=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E3=80=81?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E7=95=99=E7=97=95=E4=B8=8E=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9 个端点:目录快照(asOf)/单参数历史/变更批次提交与撤回/批次分页与明细/两日期对比/Excel 导出/计算预览 - submit_change 全量前置校验(注册表/维度岗位/取值域/同批去重)→ 建批次 → 逐项留痕(旧值取提交时生效值) - 撤回:FOR UPDATE 锁行 + 已生效禁撤,删除后解析自然回退;同日多次变更取最新留痕 - 预览按岗位模板分派公式(经理聚合口径明确拒绝),出参含所用参数与生效时点;导出走 openpyxl - ECharts 决策记录:前端统计图使用 echarts(用户指定) Co-Authored-By: Claude Code --- backend/app/api/router.py | 2 + backend/app/api/v1/system/params.py | 147 +++++ backend/app/core/constants.py | 1 + backend/app/repositories/param_repository.py | 195 ++++++- backend/app/schemas/param.py | 235 ++++++++ backend/app/services/formula.py | 6 +- backend/app/services/param_service.py | 563 ++++++++++++++++++- backend/pyproject.toml | 1 + backend/tests/unit/test_param_snapshot.py | 5 +- backend/uv.lock | 23 + 10 files changed, 1159 insertions(+), 19 deletions(-) create mode 100644 backend/app/api/v1/system/params.py create mode 100644 backend/app/schemas/param.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 9999577..392453d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -10,6 +10,7 @@ from app.api import health from app.api.v1 import auth from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import logs as system_logs +from app.api.v1.system import params as system_params from app.api.v1.system import roles as system_roles from app.api.v1.system import users as system_users @@ -27,6 +28,7 @@ system_router = APIRouter(prefix="/system") system_router.include_router(system_users.router) system_router.include_router(system_roles.router) system_router.include_router(system_business_roles.router) +system_router.include_router(system_params.router) system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.login_router) v1_router.include_router(system_router) diff --git a/backend/app/api/v1/system/params.py b/backend/app/api/v1/system/params.py new file mode 100644 index 0000000..8adbd14 --- /dev/null +++ b/backend/app/api/v1/system/params.py @@ -0,0 +1,147 @@ +"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。 + +全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读); +写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志, +变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。 +导出为文件流(非统一信封),前端以 blob 接收。 +""" + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Path, Query +from fastapi.responses import StreamingResponse + +from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.response import ApiResponse, PageResult, ok +from app.schemas.param import ( + ParamBatchDetailOut, + ParamBatchOut, + ParamBatchQuery, + ParamCatalogItemOut, + ParamChangeBatchOut, + ParamChangeIn, + ParamDiffOut, + ParamHistoryOut, + ParamHistoryQuery, + ParamPreviewIn, + ParamPreviewOut, +) +from app.services import param_service + +router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"]) + + +@router.get( + "", response_model=ApiResponse[list[ParamCatalogItemOut]], summary="参数目录(含当前生效值)" +) +def get_catalog( + session: DbSession, + _: AdminUser, + as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None, +) -> ApiResponse[list[ParamCatalogItemOut]]: + """全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。""" + return ok(param_service.get_catalog(session, as_of=as_of)) + + +@router.post("/changes", response_model=ApiResponse[ParamChangeBatchOut], summary="提交变更批次") +def submit_change( + session: DbSession, + current_user: AdminUser, + payload: ParamChangeIn, + meta: RequestMetaDep, +) -> ApiResponse[ParamChangeBatchOut]: + """提交一批参数变更(1~50 项),返回版本号;立即生效或指定日期生效。""" + return ok( + param_service.submit_change(session, payload, operator=current_user, meta=meta), + message="变更已提交", + ) + + +@router.get( + "/changes", response_model=ApiResponse[PageResult[ParamBatchOut]], summary="变更记录分页" +) +def list_changes( + session: DbSession, + _: AdminUser, + query: Annotated[ParamBatchQuery, Query()], +) -> ApiResponse[PageResult[ParamBatchOut]]: + """按生效日期区间分页查询变更批次(创建时间倒序)。""" + return ok(param_service.list_batches(session, query)) + + +@router.get( + "/changes/{batch_id}", response_model=ApiResponse[ParamBatchDetailOut], summary="批次明细" +) +def get_batch( + session: DbSession, + _: AdminUser, + batch_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ParamBatchDetailOut]: + """批次头 + 逐项旧值 → 新值。""" + return ok(param_service.get_batch_detail(session, batch_id)) + + +@router.delete("/changes/{batch_id}", response_model=ApiResponse[None], summary="撤回未生效批次") +def withdraw_batch( + session: DbSession, + current_user: AdminUser, + batch_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """撤回尚未生效的批次;已生效批次不可撤回。""" + param_service.withdraw_batch(session, batch_id, operator=current_user, meta=meta) + return ok(message="批次已撤回") + + +@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比") +def compare( + session: DbSession, + _: AdminUser, + from_date: Annotated[date, Query(description="起始业务日期(含)")], + to_date: Annotated[date, Query(description="截止业务日期(含)")], +) -> ApiResponse[list[ParamDiffOut]]: + """两个业务日期的全量参数快照逐项对比,changed 标记是否变化。""" + return ok(param_service.compare(session, from_date=from_date, to_date=to_date)) + + +@router.get("/export", summary="导出变更记录 Excel") +def export_changes( + session: DbSession, + _: AdminUser, + start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None, + end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None, +) -> StreamingResponse: + """变更记录导出为 Excel 文件流(非统一信封,前端以 blob 接收)。""" + content = param_service.export_changes(session, start_date=start_date, end_date=end_date) + filename = f"param_changes_{date.today():%Y%m%d}.xlsx" + return StreamingResponse( + iter([content]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览") +def preview( + session: DbSession, + _: AdminUser, + payload: ParamPreviewIn, +) -> ApiResponse[ParamPreviewOut]: + """输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。""" + return ok(param_service.preview(session, payload)) + + +@router.get( + "/{param_code}/history", + response_model=ApiResponse[PageResult[ParamHistoryOut]], + summary="参数版本历史", +) +def get_history( + session: DbSession, + _: AdminUser, + param_code: Annotated[str, Path(max_length=64)], + query: Annotated[ParamHistoryQuery, Query()], +) -> ApiResponse[PageResult[ParamHistoryOut]]: + """单参数的版本历史(含版本号、变更人与旧值 → 新值),最新在前。""" + return ok(param_service.get_history(session, param_code, query)) diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 4d9f579..f21a4c0 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -51,6 +51,7 @@ PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" MODULE_USER = "用户管理" MODULE_ROLE = "角色管理" MODULE_BUSINESS_ROLE = "岗位角色" +MODULE_PARAM = "计提参数" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/repositories/param_repository.py b/backend/app/repositories/param_repository.py index 8c461d6..65f4765 100644 --- a/backend/app/repositories/param_repository.py +++ b/backend/app/repositories/param_repository.py @@ -6,20 +6,20 @@ from datetime import date -from sqlalchemy import func, select +from sqlalchemy import Select, delete, func, select from sqlalchemy.orm import Session -from app.models import ParamDefinition, ParamValue +from app.models import ParamChangeBatch, ParamDefinition, ParamValue # ==================== 读取 ==================== -def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str]]: +def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str, date]]: """时点化解析:每个 (param_id, dimension_value) 取生效日期 <= 业务日期的最新条目。 排序 (effective_date DESC, id DESC) 保证同日多次变更取最新一条 (不设日期唯一键,同日修正是合法留痕)。仅返回启用状态的参数定义。 - 返回 (param_code, dimension_value, value_text),供 ParamSnapshot 构造。 + 返回 (param_code, dimension_value, value_text, effective_date)。 """ row_number = ( func.row_number() @@ -34,13 +34,194 @@ def resolve_values(session: Session, biz_date: date) -> list[tuple[str, str, str ParamDefinition.param_code.label("param_code"), ParamValue.dimension_value.label("dimension_value"), ParamValue.value_text.label("value_text"), + ParamValue.effective_date.label("effective_date"), row_number, ) .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) .where(ParamValue.effective_date <= biz_date, ParamDefinition.status == 1) .subquery() ) - stmt = select(subq.c.param_code, subq.c.dimension_value, subq.c.value_text).where( - subq.c.rn == 1 + stmt = select( + subq.c.param_code, subq.c.dimension_value, subq.c.value_text, subq.c.effective_date + ).where(subq.c.rn == 1) + return [ + (row.param_code, row.dimension_value, row.value_text, row.effective_date) + for row in session.execute(stmt) + ] + + +def latest_value_before( + session: Session, param_id: int, dimension_value: str, biz_date: date +) -> ParamValue | None: + """取某参数某维度在业务日期(含)之前的最新条目 —— 提交变更时记录「旧值」。""" + stmt = ( + select(ParamValue) + .where( + ParamValue.param_id == param_id, + ParamValue.dimension_value == dimension_value, + ParamValue.effective_date <= biz_date, + ) + .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) + .limit(1) ) - return [(row.param_code, row.dimension_value, row.value_text) for row in session.execute(stmt)] + return session.execute(stmt).scalar_one_or_none() + + +def list_definitions(session: Session, *, status: int | None = None) -> list[ParamDefinition]: + """参数定义列表(目录)。""" + stmt = select(ParamDefinition).order_by(ParamDefinition.sort_order, ParamDefinition.id) + if status is not None: + stmt = stmt.where(ParamDefinition.status == status) + return list(session.execute(stmt).scalars()) + + +def get_definition_by_code(session: Session, param_code: str) -> ParamDefinition | None: + """按编码取参数定义。""" + stmt = select(ParamDefinition).where(ParamDefinition.param_code == param_code) + return session.execute(stmt).scalar_one_or_none() + + +def list_history( + session: Session, + param_id: int, + *, + offset: int, + limit: int, + dimension_value: str | None = None, +) -> list[tuple[ParamValue, ParamChangeBatch]]: + """单参数的版本历史(含批次信息),最新在前。""" + stmt = _history_stmt(session, param_id, dimension_value=dimension_value) + return [(value, batch) for value, batch in session.execute(stmt.offset(offset).limit(limit))] + + +def count_history(session: Session, param_id: int, *, dimension_value: str | None = None) -> int: + """统计单参数的历史条目数。""" + stmt = select(func.count()).select_from( + _history_stmt(session, param_id, dimension_value=dimension_value).subquery() + ) + return int(session.execute(stmt).scalar_one()) + + +def _history_stmt(session: Session, param_id: int, *, dimension_value: str | None) -> Select: + """历史查询基语句:条目 join 批次(版本号/操作人),最新在前。""" + stmt = ( + select(ParamValue, ParamChangeBatch) + .join(ParamChangeBatch, ParamChangeBatch.id == ParamValue.batch_id) + .where(ParamValue.param_id == param_id) + .order_by(ParamValue.effective_date.desc(), ParamValue.id.desc()) + ) + if dimension_value is not None: + stmt = stmt.where(ParamValue.dimension_value == dimension_value) + return stmt + + +# ==================== 批次 ==================== + + +def create_batch(session: Session, batch: ParamChangeBatch) -> ParamChangeBatch: + """新增变更批次。只 flush 以取得自增 id。""" + session.add(batch) + session.flush() + return batch + + +def get_batch_by_id( + session: Session, batch_id: int, *, for_update: bool = False +) -> ParamChangeBatch | None: + """按主键取批次。撤回校验走 ``for_update`` 锁行(防并发双撤)。""" + if for_update: + stmt = select(ParamChangeBatch).where(ParamChangeBatch.id == batch_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(ParamChangeBatch, batch_id) + + +def list_batches( + session: Session, + *, + offset: int, + limit: int, + start_date: date | None = None, + end_date: date | None = None, +) -> list[ParamChangeBatch]: + """分页查询变更批次(按创建时间倒序)。""" + stmt = _batch_filters(select(ParamChangeBatch), start_date=start_date, end_date=end_date) + return list( + session.execute( + stmt.order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc()) + .offset(offset) + .limit(limit) + ).scalars() + ) + + +def count_batches( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> int: + """统计符合条件的批次总数。""" + stmt = _batch_filters( + select(func.count(ParamChangeBatch.id)), start_date=start_date, end_date=end_date + ) + return int(session.execute(stmt).scalar_one()) + + +def _batch_filters(stmt: Select, *, start_date: date | None, end_date: date | None) -> Select: + """批次筛选(生效日期区间,含边界)。列表与计数共用。""" + if start_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date >= start_date) + if end_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date <= end_date) + return stmt + + +def list_values_by_batch( + session: Session, batch_id: int +) -> list[tuple[ParamValue, ParamDefinition]]: + """批次内全部条目(join 参数定义取编码/名称),按 id 排序。""" + stmt = ( + select(ParamValue, ParamDefinition) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .where(ParamValue.batch_id == batch_id) + .order_by(ParamValue.id) + ) + return [(value, definition) for value, definition in session.execute(stmt)] + + +def delete_values_by_batch(session: Session, batch_id: int) -> None: + """删除批次下全部条目(撤回未生效批次时使用,2.0 风格 delete 语句)。""" + session.execute(delete(ParamValue).where(ParamValue.batch_id == batch_id)) + + +def delete_batch(session: Session, batch: ParamChangeBatch) -> None: + """删除批次本体(调用方须先删条目并完成保护校验)。""" + session.delete(batch) + session.flush() + + +# ==================== 写入 ==================== + + +def create_value(session: Session, value: ParamValue) -> ParamValue: + """新增版本条目(append-only,禁改禁删已生效条目)。只 flush。""" + session.add(value) + session.flush() + return value + + +# ==================== 导出 ==================== + + +def list_export_rows( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> list[tuple[ParamChangeBatch, ParamValue, ParamDefinition]]: + """导出行:批次 × 条目 × 定义(变更人/时间/参数/维度/旧值/新值/生效日期)。""" + stmt = ( + select(ParamChangeBatch, ParamValue, ParamDefinition) + .join(ParamValue, ParamValue.batch_id == ParamChangeBatch.id) + .join(ParamDefinition, ParamDefinition.id == ParamValue.param_id) + .order_by(ParamChangeBatch.create_time.desc(), ParamChangeBatch.id.desc(), ParamValue.id) + ) + if start_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date >= start_date) + if end_date is not None: + stmt = stmt.where(ParamChangeBatch.effective_date <= end_date) + return [(batch, value, definition) for batch, value, definition in session.execute(stmt)] diff --git a/backend/app/schemas/param.py b/backend/app/schemas/param.py new file mode 100644 index 0000000..773c52e --- /dev/null +++ b/backend/app/schemas/param.py @@ -0,0 +1,235 @@ +"""计提参数管理的请求/响应模型。 + +字段命名与 user/role 一致采用蛇形直出(前端 TS 接口同构)。 +金额类入参用字符串承载(Decimal 解析),杜绝 float 精度损失。 +""" + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.response import PageQuery + + +def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str: + """金额字符串校验:必须可被 Decimal 解析;默认拒绝负数(毛利润额除外)。""" + try: + amount = Decimal(value.strip()) + except InvalidOperation as exc: + raise ValueError(f"{field_name}必须是有效数字:{value}") from exc + if not allow_negative and amount < 0: + raise ValueError(f"{field_name}不得为负数:{value}") + return value + + +class ParamDimensionValueOut(BaseModel): + """某参数单个维度的当前生效值。""" + + dimension_value: str + value_text: str + effective_date: date | None = None + + +class ParamCatalogItemOut(BaseModel): + """参数目录项:定义元数据 + 各维度截至查询时点的生效值。""" + + param_code: str + param_name: str + param_group: str + value_type: str + unit: str | None = None + dimension_type: str + options: list[str] = Field(default_factory=list) + default_value: str | None = None + value_min: str | None = None + value_max: str | None = None + description: str | None = None + values: list[ParamDimensionValueOut] = Field(default_factory=list) + + +class ParamHistoryOut(BaseModel): + """单参数的版本历史条目(append-only 留痕)。""" + + version_no: str + dimension_value: str + old_value_text: str | None = None + value_text: str + effective_date: date + operator_name: str + create_time: datetime + + +class ParamChangeItemIn(BaseModel): + """变更条目:参数 + 维度 + 新值。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + param_code: str = Field(min_length=1, max_length=64) + dimension_value: str = Field(default="", max_length=32) + value: str = Field(min_length=1, max_length=64) + + +class ParamChangeIn(BaseModel): + """提交变更批次。 + + change_type=1 立即生效(生效日期取今日);change_type=2 指定日期生效, + 生效日期必填且不得早于今日(过去没有「预约」的意义,历史回溯走重算授权)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + change_type: int = Field(ge=1, le=2, description="生效方式:1立即生效 2指定日期生效") + effective_date: date | None = Field(default=None, description="指定日期生效时必填") + remark: str | None = Field(default=None, max_length=255) + items: list[ParamChangeItemIn] = Field(min_length=1, max_length=50) + + @model_validator(mode="after") + def _check_effective_date(self) -> "ParamChangeIn": + if self.change_type == 2 and self.effective_date is None: + raise ValueError("指定日期生效时必须提供生效日期") + return self + + +class ParamChangeBatchOut(BaseModel): + """变更批次(提交/撤回/列表共用)。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + + +class ParamBatchItemOut(BaseModel): + """批次明细条目(旧值 → 新值)。""" + + param_code: str + param_name: str + dimension_value: str + old_value_text: str | None = None + value_text: str + effective_date: date + + +class ParamBatchOut(BaseModel): + """变更批次(列表项)。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + + +class ParamBatchDetailOut(BaseModel): + """批次明细:批次头 + 逐项变更。""" + + id: int + version_no: str + effective_date: date + change_type: int + remark: str | None = None + operator_name: str + item_count: int + create_time: datetime + items: list[ParamBatchItemOut] = Field(default_factory=list) + + +class ParamDiffOut(BaseModel): + """两日期快照对比项(全量参数,changed 标记是否变化)。""" + + param_code: str + param_name: str + param_group: str + dimension_value: str + old_value: str | None = None + new_value: str | None = None + changed: bool + + +class ParamPreviewIn(BaseModel): + """计算预览入参。金额用字符串承载,Decimal 解析。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + business_role: str | None = Field( + default=None, max_length=32, description="岗位编码,如 salesperson" + ) + customer_type: str | None = Field( + default=None, max_length=32, description="客户类型:personal个人户 enterprise企业户" + ) + product_category: str | None = Field( + default=None, max_length=32, description="产品类别:municipal_water/rail_transit" + ) + ship_date: date = Field(description="发货日") + receipt_date: date = Field(description="回款到账日") + revenue: str = Field(min_length=1, description="销售额(不含运费)") + profit: str = Field(min_length=1, description="毛利润额(可为负)") + received_amount: str = Field(min_length=1, description="本笔回款金额") + barter: bool = Field(default=False, description="是否以物抵顶") + probation: bool = Field(default=False, description="是否试用期") + + @field_validator("revenue", "received_amount") + @classmethod + def _check_non_negative(cls, value: str, info) -> str: + return _parse_amount(value, field_name=info.field_name, allow_negative=False) + + @field_validator("profit") + @classmethod + def _check_profit(cls, value: str, info) -> str: + return _parse_amount(value, field_name=info.field_name, allow_negative=True) + + @model_validator(mode="after") + def _check_dates(self) -> "ParamPreviewIn": + if self.receipt_date < self.ship_date: + raise ValueError("回款到账日不得早于发货日") + return self + + +class ParamUsedOut(BaseModel): + """预览实际用到的参数(对账透明:取值与生效时点)。""" + + param_code: str + value_text: str + effective_date: date | None = None + + +class ParamPreviewOut(BaseModel): + """计算预览出参。分数串(如 97/10000)为精确值,金额为 Decimal 串。""" + + payment_days: int + rate: str + rate_text: str + profit_rate: str + converted_profit: str + performance_amount: str + barter_applied: bool + performance_after_barter: str | None = None + start_payment_ok: bool + payment_weight_r: int + payment_weight: str + payable_amount: str + probation_note: str | None = None + params_used: list[ParamUsedOut] = Field(default_factory=list) + + +class ParamBatchQuery(PageQuery): + """变更记录查询条件。""" + + start_date: date | None = Field(default=None, description="生效日期起(含)") + end_date: date | None = Field(default=None, description="生效日期止(含)") + + +class ParamHistoryQuery(PageQuery): + """参数历史查询条件。""" + + dimension_value: str | None = Field( + default=None, max_length=32, description="按维度值筛选,空为全部" + ) diff --git a/backend/app/services/formula.py b/backend/app/services/formula.py index fd2c999..56c9b43 100644 --- a/backend/app/services/formula.py +++ b/backend/app/services/formula.py @@ -13,11 +13,15 @@ from decimal import Decimal from fractions import Fraction -def _to_decimal(value: Fraction) -> Decimal: +def to_decimal(value: Fraction) -> Decimal: """Fraction → Decimal。有限小数精确;循环小数按 Decimal 上下文精度(28 位)。""" return Decimal(value.numerator) / Decimal(value.denominator) +# 模块内沿用私有名;公开别名供 service 层金额换算复用 +_to_decimal = to_decimal + + def payment_days(ship_date: date, receipt_date: date) -> int: """回款天数:发货日首日计第 1 天(对照表 #1;需规「26 天」示例作废)。""" return (receipt_date - ship_date).days + 1 diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index a0b6bd8..f8da0a3 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -1,20 +1,55 @@ -"""计提参数服务:时点化取值(解析部分)。 +"""计提参数服务:时点化取值、版本管理(变更/撤回/历史/对比)、计算预览与导出。 ParamSnapshot 是 M3 核算与计算预览共用的输入契约:按业务日期(回款到账日) 冻结当时口径,禁读「当前值」计算历史数据(算法红线)。 -变更提交/历史/对比/预览/导出在后续任务追加。 +变更批次 = 版本(一次提交一批次);条目逐项留痕(旧值/新值),append-only。 """ +import logging from collections.abc import Iterable from dataclasses import dataclass from datetime import date +from decimal import Decimal from fractions import Fraction +from io import BytesIO +from sqlalchemy import select from sqlalchemy.orm import Session -from app.core.exceptions import BadRequestError -from app.repositories.param_repository import resolve_values -from app.services.param_registry import get_spec, parse_fraction +from app.core.constants import ( + ACTION_DELETE, + ACTION_UPDATE, + MODULE_PARAM, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import ParamChangeBatch, ParamDefinition, ParamValue, User +from app.repositories import business_role_repository, param_repository +from app.schemas.param import ( + ParamBatchDetailOut, + ParamBatchItemOut, + ParamBatchOut, + ParamBatchQuery, + ParamCatalogItemOut, + ParamChangeBatchOut, + ParamChangeIn, + ParamDiffOut, + ParamDimensionValueOut, + ParamHistoryOut, + ParamHistoryQuery, + ParamPreviewIn, + ParamPreviewOut, + ParamUsedOut, +) +from app.services import audit_service, formula +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import to_decimal +from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize + +logger = logging.getLogger(__name__) + + +# ==================== 快照 ==================== @dataclass(frozen=True, slots=True) @@ -26,17 +61,24 @@ class ParamSnapshot: biz_date: date _values: dict[str, dict[str, str]] + _effective: dict[tuple[str, str], date] @classmethod - def from_rows(cls, biz_date: date, rows: Iterable[tuple[str, str, str]]) -> "ParamSnapshot": - """由 (param_code, dimension_value, value_text) 行构造。 + def from_rows( + cls, biz_date: date, rows: Iterable[tuple[str, str, str, date]] + ) -> "ParamSnapshot": + """由 (param_code, dimension_value, value_text, effective_date) 行构造。 rows 须按仓储输出顺序(同一 (code, dim) 的最新生效值在前)——以先出现者为准。 """ values: dict[str, dict[str, str]] = {} - for code, dimension, value_text in rows: + effective: dict[tuple[str, str], date] = {} + for code, dimension, value_text, effective_date in rows: + if (code, dimension) in effective: + continue + effective[(code, dimension)] = effective_date values.setdefault(code, {}).setdefault(dimension, value_text) - return cls(biz_date=biz_date, _values=values) + return cls(biz_date=biz_date, _values=values, _effective=effective) def get( self, code: str, *, role_code: str | None = None, product_category: str | None = None @@ -52,6 +94,23 @@ class ParamSnapshot: raise BadRequestError(f"参数 {code} 无生效值且无默认值,请先在系统设置中维护") return spec.default_value + def effective_date_of( + self, code: str, *, role_code: str | None = None, product_category: str | None = None + ) -> date | None: + """取参数当前生效值的生效日期(回退注册表默认值时为 None)。""" + candidates = self._values.get(code) + if candidates: + for dimension in (role_code, product_category, ""): + if dimension is not None and (code, dimension) in self._effective: + return self._effective[(code, dimension)] + return None + + def iter_items(self) -> Iterable[tuple[str, str, str]]: + """遍历快照全部 (param_code, dimension_value, value_text),供对比。""" + for code, dims in self._values.items(): + for dimension, value_text in dims.items(): + yield code, dimension, value_text + def fraction( self, code: str, *, role_code: str | None = None, product_category: str | None = None ) -> Fraction: @@ -84,4 +143,488 @@ class ParamSnapshot: def resolve_snapshot(session: Session, biz_date: date) -> ParamSnapshot: """按业务日期解析全量参数快照(M3 核算与计算预览共用入口)。""" - return ParamSnapshot.from_rows(biz_date, resolve_values(session, biz_date)) + return ParamSnapshot.from_rows(biz_date, param_repository.resolve_values(session, biz_date)) + + +# ==================== 目录 ==================== + + +def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCatalogItemOut]: + """参数目录:全部启用定义 + 各维度截至 asOf(默认今日)的生效值。""" + biz_date = as_of or date.today() + resolved = param_repository.resolve_values(session, biz_date) + by_code: dict[str, list[ParamDimensionValueOut]] = {} + for code, dimension, value_text, effective_date in resolved: + by_code.setdefault(code, []).append( + ParamDimensionValueOut( + dimension_value=dimension, value_text=value_text, effective_date=effective_date + ) + ) + items: list[ParamCatalogItemOut] = [] + for definition in param_repository.list_definitions(session, status=1): + spec = get_spec(definition.param_code) + items.append( + ParamCatalogItemOut( + param_code=definition.param_code, + param_name=definition.param_name, + param_group=definition.param_group, + value_type=definition.value_type, + unit=definition.unit, + dimension_type=definition.dimension_type, + options=list(spec.options or ()), + default_value=definition.default_value, + value_min=definition.value_min, + value_max=definition.value_max, + description=definition.description, + values=by_code.get(definition.param_code, []), + ) + ) + return items + + +# ==================== 变更与版本管理 ==================== + + +def _next_version_no(session: Session) -> str: + """生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。""" + today = date.today() + prefix = f"P{today:%Y%m%d}" + seq = 0 + for (version_no,) in session.execute( + select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%")) + ): + try: + seq = max(seq, int(version_no.rsplit("-", 1)[1])) + except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号 + continue + return f"{prefix}-{seq + 1:02d}" + + +def submit_change( + session: Session, + payload: ParamChangeIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ParamChangeBatchOut: + """提交变更批次:全量校验 → 建批次 → 逐项写条目(旧值取提交时生效值)。 + + :raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 生效日期早于今日 / + 同批次重复条目 + """ + effective = payload.effective_date if payload.change_type == 2 else date.today() + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PARAM, + action=ACTION_UPDATE, + summary=( + f"提交计提参数变更 {len(payload.items)} 项," + f"{'立即生效' if payload.change_type == 1 else f'{effective} 起生效'}" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if payload.change_type == 2 and effective < date.today(): + raise BadRequestError("指定生效日期不得早于今日") + + # 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据) + normalized: list[tuple[ParamDefinition, str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in payload.items: + spec = get_spec(item.param_code) # 未注册编码属代码缺陷 + definition = param_repository.get_definition_by_code(session, item.param_code) + if definition is None: + raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") + dimension = item.dimension_value + if spec.dimension_type == "NONE" and dimension != "": + raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") + if ( + spec.dimension_type == "BUSINESS_ROLE" + and dimension + and business_role_repository.get_by_code(session, dimension) is None + ): + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" + ) + value = validate_and_normalize(spec, item.value) + key = (item.param_code, dimension) + if key in seen: + raise BadRequestError( + f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})" + ) + seen.add(key) + normalized.append((definition, dimension, value)) + + batch = param_repository.create_batch( + session, + ParamChangeBatch( + version_no=_next_version_no(session), + effective_date=effective, + change_type=payload.change_type, + remark=payload.remark, + operator_id=operator.id, + operator_name=operator.username, + item_count=len(normalized), + ), + ) + today = date.today() + for definition, dimension, value in normalized: + old = param_repository.latest_value_before(session, definition.id, dimension, today) + param_repository.create_value( + session, + ParamValue( + param_id=definition.id, + dimension_value=dimension, + effective_date=effective, + value_text=value, + old_value_text=old.value_text if old else None, + batch_id=batch.id, + ), + ) + session.commit() + logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count) + return ParamChangeBatchOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + ) + + +def withdraw_batch( + session: Session, batch_id: int, *, operator: User, meta: RequestMeta | None = None +) -> None: + """撤回未生效批次。已生效禁撤;条目删除后解析自然回退到更早版本。 + + :raises NotFoundError: 批次不存在 + :raises BadRequestError: 批次已生效 + """ + batch = param_repository.get_batch_by_id(session, batch_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PARAM, + action=ACTION_DELETE, + summary=f"撤回参数变更批次 {batch.version_no if batch else f'#{batch_id}'}", + ), + meta=meta, + ): + if batch is None: + raise NotFoundError("变更批次不存在") + if batch.effective_date <= date.today(): + raise BadRequestError("批次已生效,不可撤回") + + param_repository.delete_values_by_batch(session, batch.id) + param_repository.delete_batch(session, batch) + session.commit() + logger.info("撤回参数变更批次 | version_no=%s", batch.version_no) + + +def get_history( + session: Session, param_code: str, query: ParamHistoryQuery +) -> PageResult[ParamHistoryOut]: + """单参数版本历史(分页,最新在前)。 + + :raises NotFoundError: 参数不存在 + """ + definition = param_repository.get_definition_by_code(session, param_code) + if definition is None: + raise NotFoundError("参数不存在") + rows = param_repository.list_history( + session, + definition.id, + offset=query.offset, + limit=query.page_size, + dimension_value=query.dimension_value, + ) + total = param_repository.count_history( + session, definition.id, dimension_value=query.dimension_value + ) + return PageResult[ParamHistoryOut]( + records=[ + ParamHistoryOut( + version_no=batch.version_no, + dimension_value=value.dimension_value, + old_value_text=value.old_value_text, + value_text=value.value_text, + effective_date=value.effective_date, + operator_name=batch.operator_name, + create_time=value.create_time, + ) + for value, batch in rows + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def list_batches(session: Session, query: ParamBatchQuery) -> PageResult[ParamBatchOut]: + """变更批次分页(按创建时间倒序)。""" + batches = param_repository.list_batches( + session, + offset=query.offset, + limit=query.page_size, + start_date=query.start_date, + end_date=query.end_date, + ) + total = param_repository.count_batches( + session, start_date=query.start_date, end_date=query.end_date + ) + return PageResult[ParamBatchOut]( + records=[ + ParamBatchOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + ) + for batch in batches + ], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_batch_detail(session: Session, batch_id: int) -> ParamBatchDetailOut: + """批次明细:批次头 + 逐项旧值 → 新值。 + + :raises NotFoundError: 批次不存在 + """ + batch = param_repository.get_batch_by_id(session, batch_id) + if batch is None: + raise NotFoundError("变更批次不存在") + items = [ + ParamBatchItemOut( + param_code=definition.param_code, + param_name=definition.param_name, + dimension_value=value.dimension_value, + old_value_text=value.old_value_text, + value_text=value.value_text, + effective_date=value.effective_date, + ) + for value, definition in param_repository.list_values_by_batch(session, batch_id) + ] + return ParamBatchDetailOut( + id=batch.id, + version_no=batch.version_no, + effective_date=batch.effective_date, + change_type=batch.change_type, + remark=batch.remark, + operator_name=batch.operator_name, + item_count=batch.item_count, + create_time=batch.create_time, + items=items, + ) + + +# ==================== 对比与预览 ==================== + + +def compare(session: Session, *, from_date: date, to_date: date) -> list[ParamDiffOut]: + """两日期快照逐项对比(全量参数,changed 标记变化)。 + + :raises BadRequestError: 起始日期晚于截止日期 + """ + if from_date > to_date: + raise BadRequestError("起始日期不得晚于截止日期") + old_items = { + (code, dimension): value + for code, dimension, value in resolve_snapshot(session, from_date).iter_items() + } + new_items = { + (code, dimension): value + for code, dimension, value in resolve_snapshot(session, to_date).iter_items() + } + out: list[ParamDiffOut] = [] + for code, dimension in sorted(set(old_items) | set(new_items)): + spec = get_spec(code) + old_value = old_items.get((code, dimension)) + new_value = new_items.get((code, dimension)) + out.append( + ParamDiffOut( + param_code=code, + param_name=spec.name, + param_group=spec.group, + dimension_value=dimension, + old_value=old_value, + new_value=new_value, + changed=old_value != new_value, + ) + ) + return out + + +def _preview_rate( + session: Session, + payload: ParamPreviewIn, + snapshot: ParamSnapshot, + days: int, + take, +) -> tuple[Fraction, str]: + """按岗位模板分派计提比例,返回(纯比例, 人读说明)。 + + 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 + """ + if payload.customer_type == "enterprise": + rate = take("enterprise.rate") + return rate, "企业户固定计提比例" + + role_code = payload.business_role + template = "TPL_SALESPERSON_DECAY" + if role_code: + role = business_role_repository.get_by_code(session, role_code) + if role is None: + raise BadRequestError(f"岗位 {role_code!r} 不存在") + template = role.calc_template + + if template == "TPL_MANAGER_SHARE": + raise BadRequestError( + "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" + ) + if template == "TPL_INFO_SALES_FIXED": + rate = take("info.rate") + return rate, "信息员:本人销售额 × 计提比例" + if template == "TPL_SALESPERSON_DECAY": + start = take("salesperson.rate.start") + slope = take("salesperson.rate.slope") + floor = take("salesperson.rate.floor") + boundary = int(take("salesperson.days.boundary")) + rate = formula.salesperson_rate( + days, start=start, slope=slope, floor=floor, boundary_days=boundary + ) + return ( + rate, + f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", + ) + raise BadRequestError(f"公式模板 {template} 暂不支持预览") + + +def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: + """计算预览:入参(发货/回款/金额/岗位)→ 公式纯函数 → 出参(含所用参数)。""" + # 参数按回款到账日取值(对照表 #11) + snapshot = resolve_snapshot(session, payload.receipt_date) + revenue = Decimal(payload.revenue) + profit = Decimal(payload.profit) + received = Decimal(payload.received_amount) + used: list[ParamUsedOut] = [] + + def take(code: str) -> Fraction: + raw = snapshot.get( + code, + role_code=payload.business_role, + product_category=payload.product_category, + ) + used.append( + ParamUsedOut( + param_code=code, + value_text=raw, + effective_date=snapshot.effective_date_of( + code, + role_code=payload.business_role, + product_category=payload.product_category, + ), + ) + ) + return parse_fraction(raw) + + days = formula.payment_days(payload.ship_date, payload.receipt_date) + rate, rate_text = _preview_rate(session, payload, snapshot, days, take) + + profit_rate = formula.profit_rate(profit, revenue) + threshold = take("product.threshold") + converted = formula.converted_profit(revenue, profit_rate, threshold) + performance = formula.performance_amount(converted, rate) + + barter_applied = payload.barter + after_barter: Decimal | None = None + if barter_applied: + discount = take("barter.discount") + after_barter = formula.apply_barter(performance, discount) + + start_ratio = take("payment.start.ratio") + start_ok = formula.start_payment_ok(received, revenue, start_ratio) + weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0 + weight = formula.payment_weight(weight_r) + base_amount = after_barter if barter_applied else performance + payable = to_decimal(Fraction(base_amount) * weight) + + probation_note: str | None = None + if payload.probation: + annual = take("probation.annual.threshold") + probation_note = f"试用期绩效照算不发放;年累计超 {to_decimal(annual)} 元部分年底一次性结算" + + return ParamPreviewOut( + payment_days=days, + rate=str(rate), + rate_text=rate_text, + profit_rate=str(profit_rate), + converted_profit=str(converted), + performance_amount=str(performance), + barter_applied=barter_applied, + performance_after_barter=str(after_barter) if after_barter is not None else None, + start_payment_ok=start_ok, + payment_weight_r=weight_r, + payment_weight=str(weight), + payable_amount=str(payable), + probation_note=probation_note, + params_used=used, + ) + + +# ==================== 导出 ==================== + + +def export_changes( + session: Session, *, start_date: date | None = None, end_date: date | None = None +) -> bytes: + """变更记录导出 Excel(openpyxl)。列:版本号/变更人/变更时间/生效日期/参数/维度/旧值/新值。""" + from openpyxl import Workbook + + rows = param_repository.list_export_rows(session, start_date=start_date, end_date=end_date) + workbook = Workbook() + sheet = workbook.active + sheet.title = "参数变更记录" + sheet.append( + [ + "版本号", + "变更人", + "变更时间", + "生效日期", + "参数编码", + "参数名称", + "维度值", + "旧值", + "新值", + ] + ) + for batch, value, definition in rows: + sheet.append( + [ + batch.version_no, + batch.operator_name, + batch.create_time.strftime("%Y-%m-%d %H:%M:%S"), + value.effective_date.strftime("%Y-%m-%d"), + definition.param_code, + definition.param_name, + value.dimension_value, + value.old_value_text, + value.value_text, + ] + ) + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0a7dce5..a23f11e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ # 图形验证码(纯 Python,依赖 Pillow) "captcha>=0.6", "pillow>=11.0", + "openpyxl>=3.1.5", ] [dependency-groups] diff --git a/backend/tests/unit/test_param_snapshot.py b/backend/tests/unit/test_param_snapshot.py index 8207964..551dbec 100644 --- a/backend/tests/unit/test_param_snapshot.py +++ b/backend/tests/unit/test_param_snapshot.py @@ -16,7 +16,10 @@ from app.services.param_service import ParamSnapshot def _snap(rows: list[tuple[str, str, str]]) -> ParamSnapshot: """构造快照。rows 须模拟仓储输出:同一 (code, dim) 的最新值在前。""" - return ParamSnapshot.from_rows(date(2026, 10, 1), rows) + # 仓储实际返回 4 元组(含生效日期);单测关注覆盖链,统一补同一个日期 + return ParamSnapshot.from_rows( + date(2026, 10, 1), [(code, dim, value, date(2026, 1, 1)) for code, dim, value in rows] + ) class TestCoverChain: diff --git a/backend/uv.lock b/backend/uv.lock index d8159fe..b1080f3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -221,6 +221,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fakeredis" version = "2.39.0" @@ -378,6 +387,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -407,6 +428,7 @@ dependencies = [ { name = "alembic" }, { name = "captcha" }, { name = "fastapi" }, + { name = "openpyxl" }, { name = "pillow" }, { name = "pwdlib", extra = ["bcrypt"] }, { name = "pydantic" }, @@ -433,6 +455,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "captcha", specifier = ">=0.6" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pillow", specifier = ">=11.0" }, { name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" }, { name = "pydantic", specifier = ">=2.9" }, -- 2.27.0 From fd801455f08b5183ca9685d25080ac7f0ed9fee7 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 21:47:59 +0800 Subject: [PATCH 011/100] =?UTF-8?q?feat(system):=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=93=81=E7=89=8C=20token=20=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E9=9D=9B=E8=93=9D=E8=BD=BB=E8=B4=A8=E6=84=9F=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主色改靛蓝 #2E4BA8(白字对比 7.5:1),EP 色阶按混合公式重算 - 中性灰白体系:雾灰底/纸白/半透明界线/三级墨色文字 - 旧 token 名保留映射(深水青→冷墨、金色→靛蓝),存量引用平滑过渡,迁移完成后移除 - 设计依据:docs/design/视觉方向预览.html(2026-10-12 确认) Co-Authored-By: Claude Code --- frontend/src/assets/styles/index.css | 58 +++++++++++++++++----------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index 6bb84be..7091743 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -2,32 +2,46 @@ :root { /* - * 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架 - * 与系统管理页面共用后提升为全局 token)。 - * 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。 + * 品牌色(2026-10-12 视觉方向:企业级扁平 · 轻质感 · 靛蓝点缀)。 + * 靛蓝只作重点点缀(主按钮/链接/焦点/当前菜单),大面积用中性灰白。 + * 设计预览与规范:docs/design/视觉方向预览.html */ - --ys-ink-900: #061a22; - --ys-ink-800: #0a2833; - --ys-ink-700: #0f3646; - --ys-ink-600: #1a4f63; - --ys-gold-bright: #c9a227; - --ys-gold-deep: #8f6520; - --ys-paper: #fafaf7; - --ys-text: #16262d; - --ys-muted: #6b7b84; + --ys-indigo: #2e4ba8; + --ys-indigo-deep: #223a85; + --ys-indigo-wash: #edf1fa; + --ys-paper: #ffffff; + --ys-mist: #f4f5f8; + --ys-line: rgba(28, 35, 51, 0.09); + --ys-ink: #1c2333; + --ys-ink-2: #5b6577; + --ys-ink-3: #9aa3b2; /* - * Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。 + * 旧 token 名保留映射(BasicLayout / 登录页存量引用,值迁移到新体系: + * 深水青 → 冷墨系、金色 → 靛蓝系),存量页面迁移完成后移除。 + */ + --ys-ink-900: #16203a; + --ys-ink-800: #1c2333; + --ys-ink-700: #242e4a; + --ys-ink-600: #3a4a6e; + --ys-gold-bright: #6d81c2; + --ys-gold-deep: #2e4ba8; + + --ys-text: #1c2333; + --ys-muted: #5b6577; + + /* + * Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。 * 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 —— * 不用 color-mix(),避免依赖较新的浏览器特性。 */ - --el-color-primary: #8f6520; - --el-color-primary-light-3: #b19363; - --el-color-primary-light-5: #c7b290; - --el-color-primary-light-7: #ddd1bc; - --el-color-primary-light-8: #e9e0d2; - --el-color-primary-light-9: #f4f0e9; - --el-color-primary-dark-2: #72511a; + --el-color-primary: #2e4ba8; + --el-color-primary-light-3: #6d81c2; + --el-color-primary-light-5: #97a5d4; + --el-color-primary-light-7: #c0c9e5; + --el-color-primary-light-8: #d5dbee; + --el-color-primary-light-9: #eaedf6; + --el-color-primary-dark-2: #253c86; } * { @@ -46,8 +60,8 @@ body { font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; font-size: 14px; - color: #303133; - background-color: #f5f7fa; + color: var(--ys-ink); + background-color: var(--ys-mist); -webkit-font-smoothing: antialiased; } -- 2.27.0 From 4c442b8cf21fa64e5ff7cf79fc83ac292efefdf2 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 12 Oct 2026 21:48:00 +0800 Subject: [PATCH 012/100] =?UTF-8?q?feat(system):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E8=A7=92=E8=89=B2=E4=B8=8E=E8=AE=A1=E6=8F=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E9=85=8D=E7=BD=AE=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 岗位角色页:列表/新增/编辑/启停/删除,内置岗位保护、模板受控下拉(含声明参数引导) - 计提参数页:分组目录(多维度生效值)→ 修改入「待提交」→ 批次提交(立即/指定日期 + 二次确认); 版本管理抽屉(记录分页/明细旧值→新值/两日期对比/撤回未生效/xlsx 导出);计算预览抽屉(按回款到账日取参数) - request.ts 对 responseType=blob 直通(导出文件流非统一信封) - 路由注册岗位角色/计提参数(系统管理组 order 5/6);日志模块词表补两处 Co-Authored-By: Claude Code --- frontend/src/api/businessRoles.ts | 90 ++ frontend/src/api/params.ts | 208 ++++ frontend/src/api/request.ts | 4 + frontend/src/router/index.ts | 14 +- frontend/src/utils/constants.ts | 2 +- .../src/views/system/business-roles/index.vue | 374 +++++++ frontend/src/views/system/params/index.vue | 973 ++++++++++++++++++ 7 files changed, 1663 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/businessRoles.ts create mode 100644 frontend/src/api/params.ts create mode 100644 frontend/src/views/system/business-roles/index.vue create mode 100644 frontend/src/views/system/params/index.vue diff --git a/frontend/src/api/businessRoles.ts b/frontend/src/api/businessRoles.ts new file mode 100644 index 0000000..eaadf70 --- /dev/null +++ b/frontend/src/api/businessRoles.ts @@ -0,0 +1,90 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 公式模板下拉项(含声明参数,供岗位表单展示) */ +export interface TemplateOption { + code: string + name: string + description: string + params: { code: string; name: string }[] +} + +/** 岗位角色列表项(对应后端 `BusinessRoleOut`) */ +export interface BusinessRoleItem { + id: number + role_code: string + role_name: string + calc_template: string + description: string | null + sort_order: number + status: number + /** 1内置(禁删禁停) 0自定义 */ + is_builtin: number +} + +export interface BusinessRoleQueryParams { + pageNum?: number + pageSize?: number + keyword?: string + status?: number +} + +export interface BusinessRoleCreateParams { + role_code: string + role_name: string + calc_template: string + description?: string + sort_order?: number +} + +/** 编辑:不含 role_code(创建后不可修改,编码是参数维度与模板绑定的锚点) */ +export interface BusinessRoleUpdateParams { + role_name: string + calc_template: string + description?: string + sort_order?: number +} + +/** 分页查询岗位角色(含停用) */ +export function listBusinessRoles( + params: BusinessRoleQueryParams, +): Promise> { + return request({ url: '/v1/system/business-roles', method: 'get', params }) +} + +/** 新增岗位 */ +export function createBusinessRole( + data: BusinessRoleCreateParams, +): Promise { + return request({ url: '/v1/system/business-roles', method: 'post', data }) +} + +/** 编辑岗位 */ +export function updateBusinessRole( + roleId: number, + data: BusinessRoleUpdateParams, +): Promise { + return request({ url: `/v1/system/business-roles/${roleId}`, method: 'put', data }) +} + +/** 删除岗位(内置岗位后端拒绝,前端同步禁用按钮) */ +export function deleteBusinessRole(roleId: number): Promise { + return request({ url: `/v1/system/business-roles/${roleId}`, method: 'delete' }) +} + +/** 启用 / 禁用(内置岗位后端拒绝停用) */ +export function setBusinessRoleStatus( + roleId: number, + status: number, +): Promise { + return request({ + url: `/v1/system/business-roles/${roleId}/status`, + method: 'put', + data: { status }, + }) +} + +/** 公式模板下拉(含声明参数说明) */ +export function listTemplateOptions(): Promise { + return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' }) +} diff --git a/frontend/src/api/params.ts b/frontend/src/api/params.ts new file mode 100644 index 0000000..d8a50b0 --- /dev/null +++ b/frontend/src/api/params.ts @@ -0,0 +1,208 @@ +import service, { request } from './request' +import type { PageData } from './types' + +/** 参数目录项(含各维度截至查询时点的生效值) */ +export interface ParamDimensionValue { + dimension_value: string + value_text: string + effective_date: string | null +} + +export interface ParamCatalogItem { + param_code: string + param_name: string + param_group: string + value_type: string + unit: string | null + dimension_type: string + options: string[] + default_value: string | null + value_min: string | null + value_max: string | null + description: string | null + values: ParamDimensionValue[] +} + +/** 参数目录(asOf 缺省为今日) */ +export function getCatalog(asOf?: string): Promise { + return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} }) +} + +/** 变更条目:参数 + 维度 + 新值 */ +export interface ParamChangeItem { + param_code: string + dimension_value: string + value: string +} + +export interface ParamChangePayload { + /** 生效方式:1立即生效 2指定日期生效 */ + change_type: number + effective_date?: string + remark?: string + items: ParamChangeItem[] +} + +export interface ParamChangeBatch { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string +} + +/** 提交变更批次 */ +export function submitChanges(data: ParamChangePayload): Promise { + return request({ url: '/v1/system/params/changes', method: 'post', data }) +} + +export interface ParamBatchItem { + param_code: string + param_name: string + dimension_value: string + old_value_text: string | null + value_text: string + effective_date: string +} + +export interface ParamBatchDetail { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string + items: ParamBatchItem[] +} + +/** 批次明细(旧值 → 新值) */ +export function getBatchDetail(batchId: number): Promise { + return request({ url: `/v1/system/params/changes/${batchId}`, method: 'get' }) +} + +export interface ParamBatch { + id: number + version_no: string + effective_date: string + change_type: number + remark: string | null + operator_name: string + item_count: number + create_time: string +} + +/** 变更记录分页 */ +export function listBatches(params: { + pageNum?: number + pageSize?: number + start_date?: string + end_date?: string +}): Promise> { + return request({ url: '/v1/system/params/changes', method: 'get', params }) +} + +/** 撤回未生效批次 */ +export function withdrawBatch(batchId: number): Promise { + return request({ url: `/v1/system/params/changes/${batchId}`, method: 'delete' }) +} + +export interface ParamDiff { + param_code: string + param_name: string + param_group: string + dimension_value: string + old_value: string | null + new_value: string | null + changed: boolean +} + +/** 两日期快照对比 */ +export function compareParams(fromDate: string, toDate: string): Promise { + return request({ + url: '/v1/system/params/compare', + method: 'get', + params: { from_date: fromDate, to_date: toDate }, + }) +} + +export interface ParamHistory { + version_no: string + dimension_value: string + old_value_text: string | null + value_text: string + effective_date: string + operator_name: string + create_time: string +} + +/** 单参数版本历史 */ +export function getParamHistory( + paramCode: string, + params: { pageNum?: number; pageSize?: number; dimension_value?: string }, +): Promise> { + return request({ url: `/v1/system/params/${paramCode}/history`, method: 'get', params }) +} + +/** + * 导出变更记录(文件流)。 + * + * 走 axios 实例(带令牌)而非 request 封装:响应是 xlsx 文件流, + * 不是统一信封,拦截器已对 responseType=blob 直通。 + */ +export async function exportChanges(params: { + start_date?: string + end_date?: string +}): Promise { + const response = await service.get('/v1/system/params/export', { + params, + responseType: 'blob', + }) + return response.data as Blob +} + +/** 计算预览入参(金额为字符串,Decimal 承载) */ +export interface ParamPreviewPayload { + business_role?: string + customer_type?: string + product_category?: string + ship_date: string + receipt_date: string + revenue: string + profit: string + received_amount: string + barter: boolean + probation: boolean +} + +export interface ParamUsed { + param_code: string + value_text: string + effective_date: string | null +} + +export interface ParamPreviewResult { + payment_days: number + rate: string + rate_text: string + profit_rate: string + converted_profit: string + performance_amount: string + barter_applied: boolean + performance_after_barter: string | null + start_payment_ok: boolean + payment_weight_r: number + payment_weight: string + payable_amount: string + probation_note: string | null + params_used: ParamUsed[] +} + +/** 计算预览 */ +export function previewCalc(data: ParamPreviewPayload): Promise { + return request({ url: '/v1/system/params/preview', method: 'post', data }) +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 7f6ff39..b4fb33e 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -51,6 +51,10 @@ function handleUnauthorized(message: string): void { service.interceptors.response.use( (response: AxiosResponse) => { + // 文件流(导出下载)不是统一信封,直通给调用方处理 + if (response.config.responseType === 'blob') { + return response + } const body = response.data if (body.code !== 200) { // 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况 diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 51c84ef..08e4639 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -52,11 +52,23 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/system/roles/index.vue'), meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 }, }, + { + path: '/system/business-roles', + name: 'system-business-roles', + component: () => import('@/views/system/business-roles/index.vue'), + meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }, + }, + { + path: '/system/params', + name: 'system-params', + component: () => import('@/views/system/params/index.vue'), + meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 }, + }, { path: '/system/logs', name: 'system-logs', component: () => import('@/views/system/logs/index.vue'), - meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 }, + meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 }, }, ], }, diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 8109b6d..37ec5e0 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -7,7 +7,7 @@ export const ROLE_ADMIN = 'admin' * 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的 * `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。 */ -export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const +export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const /** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */ diff --git a/frontend/src/views/system/business-roles/index.vue b/frontend/src/views/system/business-roles/index.vue new file mode 100644 index 0000000..f0b3a74 --- /dev/null +++ b/frontend/src/views/system/business-roles/index.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue new file mode 100644 index 0000000..aae4091 --- /dev/null +++ b/frontend/src/views/system/params/index.vue @@ -0,0 +1,973 @@ + + + + + -- 2.27.0 From d7fbec11775ef2a14cd069eaf523a6ac737ba38e Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 00:09:41 +0800 Subject: [PATCH 013/100] =?UTF-8?q?feat(system):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=E9=87=8D=E5=81=9A=E3=80=81=E5=B8=83=E5=B1=80=E7=99=BD?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=E9=A6=96=E9=A1=B5=20Bento=20=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=EF=BC=88=E8=A7=86=E8=A7=89=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=90=BD=E5=9C=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录页:雾灰点阵底 + 右上涟漪同心圆 + 顶部靛蓝天光;居中纸白卡片(品牌区/欢迎回来/表单);逻辑不变 - BasicLayout:侧栏深色面板改为中性灰微渐变 + 白底浮起当前项(左 2px 靛蓝条);logo 区水线;内容区雾灰点阵 - 首页:Bento Grid 仪表盘(参数 16 项/岗位 3 个为真实数据;绩效/回款/待办为业务上线前的精致空态) - AppMenu:激活项白底浮起样式,菜单变量随浅色侧栏调整 Co-Authored-By: Claude Code --- frontend/src/assets/styles/index.css | 3 + frontend/src/components/layout/AppMenu.vue | 34 +- .../src/components/layout/BasicLayout.vue | 49 ++- frontend/src/views/dashboard/index.vue | 369 +++++++++++++++-- frontend/src/views/login/index.vue | 382 +++++++----------- 5 files changed, 545 insertions(+), 292 deletions(-) diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index 7091743..daad5d6 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -30,6 +30,9 @@ --ys-text: #1c2333; --ys-muted: #5b6577; + /* 品牌字:思源黑体(实施时自托管 4 字子集,内网可用) */ + --ys-font-brand: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; + /* * Element Plus 主色 = 靛蓝(白字对比约 7.5:1)。 * 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 —— diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue index ac0f945..c850200 100644 --- a/frontend/src/components/layout/AppMenu.vue +++ b/frontend/src/components/layout/AppMenu.vue @@ -24,26 +24,42 @@ const { topLevelMenus, groupedMenus } = useAppMenu() diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue index 9476f51..be56057 100644 --- a/frontend/src/components/layout/BasicLayout.vue +++ b/frontend/src/components/layout/BasicLayout.vue @@ -42,6 +42,7 @@ async function handleLogout(): Promise {
衍水集团 营销人员工资管理系统 +
@@ -96,6 +97,7 @@ async function handleLogout(): Promise {
衍水集团 营销人员工资管理系统 +
@@ -110,27 +112,39 @@ async function handleLogout(): Promise { .app-aside { display: flex; flex-direction: column; - background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%); + /* 中性灰侧栏:比页面底沉一档,与纸白卡片拉开层次 */ + background: linear-gradient(180deg, #f2f4f8 0%, #ecedf3 100%); + border-right: 1px solid var(--ys-line); } .app-brand { display: flex; flex-direction: column; - gap: 2px; - padding: 18px 20px 14px; + gap: 3px; + padding: 18px 20px 12px; } .app-brand__name { - font-family: 'Songti SC', SimSun, serif; - font-size: 18px; - letter-spacing: 2px; - color: var(--ys-paper); + font-family: var(--ys-font-brand); + font-size: 16px; + font-weight: 500; + letter-spacing: 4px; + color: var(--ys-ink); } .app-brand__sub { - font-size: 12px; - letter-spacing: 1px; - color: rgb(250 250 247 / 55%); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--ys-ink-3); +} + +/* 品牌签名:logo 下的渐隐水线(全站唯一装饰元素,页面标题处同款) */ +.app-brand__line { + width: 24px; + height: 2px; + border-radius: 1px; + margin-top: 8px; + background: linear-gradient(90deg, var(--ys-indigo), rgba(46, 75, 168, 0)); } .app-aside__menu { @@ -143,8 +157,8 @@ async function handleLogout(): Promise { align-items: center; justify-content: space-between; height: 56px; - background-color: #fff; - border-bottom: 1px solid #e5e7eb; + background: linear-gradient(180deg, #ffffff, #fdfdfe); + border-bottom: 1px solid var(--ys-line); } .app-header__left { @@ -173,7 +187,7 @@ async function handleLogout(): Promise { } .app-header__menu-toggle:focus-visible { - outline: 2px solid var(--ys-gold-deep); + outline: 2px solid var(--ys-indigo); outline-offset: 2px; } @@ -190,7 +204,10 @@ async function handleLogout(): Promise { .app-main { padding: 20px; - background-color: #f5f7fa; + /* 雾灰底 + 点阵网格:内容区的秩序与密度 */ + background-color: var(--ys-mist); + background-image: radial-gradient(rgba(28, 35, 51, 0.05) 1px, transparent 1px); + background-size: 24px 24px; } @media (max-width: 768px) { @@ -211,9 +228,9 @@ async function handleLogout(): Promise { diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 835dbbd..07815ca 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,49 +1,350 @@ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 31e6e7b..244dc6a 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -77,53 +77,29 @@ onMounted(refreshCaptcha) + + +
+ + +
+
衍水集团
+

营销人员工资管理系统 · 视觉方向

+
企业级扁平 · 轻质感 · Bento 布局 · 品牌色点缀 · 微动效 · 精致文案
+
+
+ 表面微渐变(受光) + 半透明描边 + 分层阴影 + 内高光 / 内阴影 + 2% 噪点 + 等宽数字 + 120–180ms 轻动效 +
+
+ + +
+

01 · 色板与材质

+
+
+
+
靛蓝
#2E4BA8 ↑#3A58BC
+
品牌色。按钮/链接/焦点/当前菜单,配微渐变与内高光
+
+
+
+
侧栏灰
#F2F4F8 → #ECEDF3
+
侧栏微渐变(自上而下微沉)
+
+
+
+
雾灰
#F4F5F8
+
页面底
+
+
+
+
纸白
#FFFFFF → #FCFCFE
+
卡片/表格:顶白底微灰的「受光」渐变
+
+
+
+
界线
rgba(28,35,51,.09)
+
1px 半透明描边:在不同底色上自然融合
+
+
+
+
墨色
#1C2333
+
主文字(冷调近黑)
+
+
+
+
次墨
#5B6577
+
次级文字、表头、说明
+
+
+
+
语义色
#1E9E6A · #C98A1B · #C94F4F
+
成功/警示/危险,浅底微渐变 + 深字
+
+
+
+ + +
+

02 · 字体与数字

+
+
页面标题岗位角色18px / 600 · 字距 −0.01em
+
卡片标题计提参数 · 业务员个人户15px / 600
+
正文 / 表格回款天数 = 发货日首日起算,按自然日累计14px / 400 · 13px / 400
+
表头 / 标签岗位编码 公式模板 生效日期12px / 500 · 字距 +0.04em
+
品牌字衍水集团无衬线 600 · 大字距 · 仅品牌落款一处
+
+
+ + + + + + + +
回款日期回款金额(元)回款天数计提比例绩效金额(元)
2026-01-10386,200.00279.7‰14,062.31
2026-02-181,240,000.003614‰19,840.00
合计1,626,200.00——33,902.31
+
金额与比例一律等宽数字(tabular-nums)右对齐,合计行加粗放大半档——列对列、位对位,扫一眼就能核对。
+
+
+
+
衍水集团
+
A · 思源黑体 500 · 0.32em 当前应用
+
+
+
衍水集团
+
B · 思源黑体 300 细体 · 0.42em
+
+
+
衍水集团
+
C · 微软雅黑 600 · 0.25em
+
+
+
+ + +
+

03 · 组件基调

+
+
+

按钮 · 三态

+
+
静置
+
悬停:抬升 1px · 影加深
+
按下:回落 · 内阴影
+
+
微渐变 + 内高光给按钮「实体感」;次级按钮一律白底细边框,无阴影污染。
+
+
+

输入框 · 状态

+
+ + +
+
+ 启用 + 禁用 + 待生效 +
+
输入框带微内阴影(凹陷暗示);聚焦 = 靛蓝描边 + 3px 柔光外环。
+
+
+

分页

+
+ ‹123› + 共 28 条 · 每页 10 条 +
+
当前页与主按钮同材质:微渐变 + 内高光。
+
+
+

动效原则

+
+ 过渡 120–180ms,只做颜色、阴影与 1px 内的位移;弹层淡入 + 4px 上移; + 数字与表格不加动画;尊重 prefers-reduced-motion。 + 动效的目标是「顺滑可信」,不是表演。 +
+
+
+
+ + +
+

04 · 应用界面(布局与列表页)

+
+
+ +
+
+
系统管理 / 岗位角色
+
管管理员退出登录
+
+
+
+
+

岗位角色

+
决定人员适用的计提公式模板与参数;新增岗位无需改代码
+
+
+ +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
岗位编码岗位名称公式模板说明状态操作
salesperson营销员业务员个人户 · 天数线性递减回款天数线性递减(360 天=6‰,361 天起 4‰)启用编辑删除
sales_manager营销经理营销经理 · 团队与全公司分成本团队 12.5% + 全公司 5%启用编辑删除
info_officer信息员信息员 · 销售额固定比例本人销售额 × 1‰启用编辑删除
senior_sales高级业务员业务员个人户 · 天数线性递减自定义岗位(新增岗位无需改代码)禁用编辑删除
+
+
+ ‹1› + 共 4 条 +
+
+
+
+
+
+
+
+ 质感的三层材料:侧栏灰渐变(沉)→ 内容底雾灰 + 顶部受光 → 卡片纸白微渐变 + 半透明描边 + 分层阴影。 + 当前项白底浮起,白色被灰托住,不再扎眼。 +
+
+ + +
+

05 · 首页仪表盘(Bento Grid)

+
+
+ +
+
+
仪表盘
+
管管理员退出登录
+
+
+
+
+

仪表盘

+
数据截至今日 06:00 · 12 笔回款待核算
+
+
+
+
+
+
本月绩效总额
+
268,430.16元
+
较上月 +12.4% · 已核销 86 笔
+
+
+
+
近 6 个月回款
+
单位:万元
+
+
+
+
待办
+
+
12 笔回款待核算
+
3 笔业务归属待确认
+
+
+
+
营销人员
+ +
+
36人
+
+
+
岗位角色
+ +
+
3个
+
+
+
常用操作
+
+ + + +
+
+
+
计提参数
+ +
+
16项
+
版本 P20260101-00
+
+
+
T+ 同步
+ +
+
正常
+
每周一 06:00
+
+
+
+
+
+
+
+ Bento Grid:大小不一的卡片按重要性排布——绩效总额与回款趋势占 2×2 主角位,数字型指标 1×1 紧凑排列。 + 卡片入场为 45ms 交错上浮(320ms),悬停抬升 2px、阴影加深——打开预览即可看到微动效。 +
+
+ + +
+

06 · 登录页(重做)

+ +
+ 背景三层:点阵网格(秩序)+ 顶部靛蓝天光(氛围)+ 右上涟漪同心圆(「衍水」的品牌装饰); + 卡片内品牌区承担门面——品牌字、标语、水线;表单以「欢迎回来」承接。装饰与文案都来自「衍水」本身。 +
+
+ + +
+

07 · 签名元素:水线

+
+
计提参数
+
+
+ 页面标题下的渐隐靛蓝细线(56px,左实右虚)——「衍水」之水的提纯,全站唯一的装饰性元素, + 只在每个页面标题处出现一次。它与登录页的水纹层理同源:品牌连续,而非推倒重来。 +
+
+
+ + +
+

08 · 微动效与精致文案(全局规范)

+
+
+

微动效 · 全站统一

+ + + + + + + + + +
页面 / 卡片入场淡入 + 上浮 6px,45ms 交错320ms
按钮悬停 / 按下抬升 1px、影加深 / 回落内阴影150ms
卡片悬停抬升 2px、阴影加深180ms
输入聚焦靛蓝描边 + 3px 柔光外环150ms
弹层 / 抽屉淡入 + 上移 4px160ms
表格行底色渐显(无位移)120ms
数据刷新数字不做滚动动画(核对场景,动即干扰)—
无障碍prefers-reduced-motion 时全部关闭—
+
目标不是「有动画」,而是顺滑可信:每次操作都有轻微、及时、可预期的反馈。
+
+
+

精致文案 · 全站统一

+ + + + + + + +
按钮提交保存变更 · 录入回款
空态暂无数据还没有岗位角色,点「新增岗位」创建第一个
错误操作失败岗位编码已存在,请换一个
危险确认确定删除吗?删除后无法恢复,该岗位下的人员需重新归属
成功提示操作成功岗位已创建 · 参数 11-01 起生效
页面描述本页面用于管理决定人员适用的计提公式模板与参数;新增岗位无需改代码
+
说清楚会发生什么;动词开头、句子短;不道歉、不卖萌;空态是邀请,错误是路标。
+
+
+
+ +
+ + + + diff --git a/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md b/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md new file mode 100644 index 0000000..70715c5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-M1-系统设置与参数体系.md @@ -0,0 +1,268 @@ +# M1 开发计划:系统设置与参数体系(#28) + +## Context + +《剩余开发计划》已定稿(`docs/superpowers/plans/2026-10-12-剩余开发计划.md`),M1 是首个工作块。本文档即 M1 的正式开发计划(2026-10-12 批准):涵盖设计(四表/参数/模板/接口)、任务分解(T1–T8 步骤级)、执行安排与验收标准;开工后按第二章逐任务执行。 + +**为什么先做 M1**:岗位角色被基础数据的人员管理引用(外键上游);参数快照与公式模板是绩效核算的输入契约。先立地基可避免后续模块加表、加外键、改表单的连锁返工;计算预览还可作为业务对账工具先行验收公式口径。 + +**#28 需求原文要点**:① 人员岗位角色(营销员/营销经理/信息员等)支持增删,新增岗位时在系统设置中绑定计提公式与参数,**无需改代码**;② 全部计提比例、系数、阈值、分成比例、起付比例统一在系统设置维护,**支持版本管理与留痕**。 + +## 现状(开工前已核查) + +| 项 | 状态 | +| --- | --- | +| 分支 | `feat/system-params`(基于 `feature/system-management` HEAD;后者领先 main 5 提交、未合并——是否先合并 main 由用户决定,不阻塞开发) | +| 容器 | Docker Desktop 位于 `D:\work\app\Docker\Docker Desktop.exe`(非默认路径) | +| 工作区 | docs/ 下有用户侧整理改动(业务清单删除、v2 新增、superpowers 目录),与 M1 无关,不动 | +| 基线 | 301 用例全绿(系统管理交付时);开工先复跑确认 | + +## 范围 + +**做**:四张新表与种子(V3 迁移双轨);岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数与模板注册表;计算预览;前端两个页面(岗位角色、计提参数)。 + +**不做**:核算引擎与台账(M3);基础数据与业务数据(M2);参数 Redis 缓存(一期不做,保留键位);规则引擎(既有决策)。 + +--- + +## 一、技术设计 + +### 1.1 数据模型(四张新表,V3 迁移) + +**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明) + +| 列 | 类型 | 约束/说明 | +| --- | --- | --- | +| id | BIGINT UNSIGNED | PK AUTO_INCREMENT | +| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改 | +| role_name | VARCHAR(64) | NOT NULL | +| calc_template | VARCHAR(32) | NOT NULL,公式模板编码(以代码注册表为准) | +| description | VARCHAR(255) | NULL | +| sort_order | INT | NOT NULL DEFAULT 0 | +| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) | +| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) | +| create_time / update_time | DATETIME | TimestampMixin(update 用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`) | + +**`param_definitions` 参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天);`dimension_type` VARCHAR(32) DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value`/`value_min`/`value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。 + +**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(`P{YYYYMMDD}-{当日序号}`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT DEFAULT 0;仅 `create_time`(append-only,不继承 TimestampMixin)。 + +**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`(用空串不用 NULL);`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64);`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。热路径索引 `idx_param_values_param_id_dimension_value_effective_date`。已生效条目禁改禁删;未生效批次可整批撤回。 + +**种子**(迁移 + init-dev 双写):3 个内置岗位(`salesperson 营销员`/TPL_SALESPERSON_DECAY、`sales_manager 营销经理`/TPL_MANAGER_SHARE、`info_officer 信息员`/TPL_INFO_SALES_FIXED)+ 16 项参数定义 + 版本条目 + 一条初始批次(`P20260101-00`)。 + +### 1.2 参数清单(16 项) + +| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 生效日期 | +| --- | --- | --- | --- | --- | --- | +| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 2026-02-17 | +| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 2026-02-17 | +| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 2026-02-17 | +| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 2026-02-17 | +| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | 2026-08-13 | +| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 2026-08-13 | +| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | **2(02-17 起)/ 3(08-13 起)** | 双行 | +| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | 2026-08-13 | +| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | 2026-01-01 | +| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | 2026-01-01 | +| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | 2026-01-01 | +| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | 2026-01-01 | +| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | 2026-02-17 | +| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | 2026-01-01 | +| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | 2026-01-01 | +| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 2026-01-01 | + +**不设参数**(作废条款落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。 + +**值校验规则**(`validate_and_normalize`):DECIMAL 用 `Fraction(text)` 解析(拒绝 `1/0`、非数字),并按 `value_min`/`value_max` 校验;INT 必须整数;BOOL 仅 0/1;ENUM 必须在 `options_text` 内;规范化去首尾空白、分数式原样存储。 + +### 1.3 公式模板与消费契约 + +**模板注册表**(`app/services/calc_templates.py`,代码内有限集合,一期不引入规则引擎的落地方式): + +| 模板编码 | 形态 | 声明参数 | +| --- | --- | --- | +| TPL_SALESPERSON_DECAY | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary | +| TPL_MANAGER_SHARE | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* | +| TPL_ENTERPRISE_FIXED | 乘法:折算毛利润额 × 比例 | enterprise.rate | +| TPL_INFO_SALES_FIXED | 乘法:本人销售额 × 比例 | info.rate | +| TPL_PREPAY_FIXED | 乘法:回款额 × 比例 | prepay.rate | + +修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目;新增岗位 = 新增行 + 选模板(零代码)。 + +**消费接口契约**(M3 核算与预览共用): + +```python +# app/services/param_service.py +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + biz_date: date + def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链:角色 > 产品类别 > 默认 + def fraction(self, code, **dims) -> Fraction: ... + def integer(self, code, **dims) -> int: ... + def boolean(self, code, **dims) -> bool: ... + def text(self, code, **dims) -> str: ... + +def resolve_snapshot(session, biz_date: date) -> ParamSnapshot +def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut +def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut] + +# app/services/formula.py(纯函数,禁 float) +def payment_days(ship_date, receipt_date) -> int # 发货日首日=1 +def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零 +def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值) +def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3) +def apply_barter(amount, discount) -> Decimal +def payment_weight(r: int) -> Fraction # r×r +def start_payment_ok(received, revenue, ratio) -> bool +``` + +### 1.4 API 设计(/api/v1/system/*,AdminUser) + +**岗位角色**:`GET/POST /business-roles`、`GET/PUT/DELETE /business-roles/{id}`、`PUT /business-roles/{id}/status`、`GET /business-roles/meta/templates`(模板下拉,含声明参数说明)。 + +**计提参数**:`GET /params`(目录+当前快照,支持 `asOf`)、`GET /params/{param_code}/history`、`POST /params/changes`(提交变更批次)、`DELETE /params/changes/{batch_id}`(撤回未生效)、`GET /params/changes`(记录分页)、`GET /params/changes/{batch_id}`(明细)、`GET /params/compare`(两日期对比)、`GET /params/export`(Excel,`StreamingResponse` 非统一信封)、`POST /params/preview`(计算预览)。 + +**关键示例 1:提交变更** + +```json +// POST /api/v1/system/params/changes +{ + "changeType": 2, + "effectiveDate": "2026-11-01", + "remark": "四季度系数调整", + "items": [ + { "paramCode": "enterprise.rate", "dimensionValue": "", "value": "3" }, + { "paramCode": "product.threshold", "dimensionValue": "rail_transit", "value": "22" } + ] +} +// data: +{ "batchId": 12, "versionNo": "P20261012-01", "effectiveDate": "2026-11-01", + "changeType": 2, "itemCount": 2, "operatorName": "admin", "createTime": "..." } +``` + +**关键示例 2:计算预览**(示意值已验算) + +```json +// POST /api/v1/system/params/preview +{ "businessRole": "salesperson", "customerType": "personal", + "productCategory": "municipal_water", "shipDate": "2025-12-15", + "receiptDate": "2026-01-10", "revenue": "100000", "profit": "45000", + "receivedAmount": "60000", "barter": false, "probation": false } +// data: +{ "paymentDays": 27, "rate": "97/10000", "rateText": "9.7‰(10‰ − 27 天 × 4/360‰)", + "profitRate": "9/20", "convertedProfit": "15000", "performanceAmount": "145.5", + "startPaymentOk": true, "paymentWeightR": 60, "paymentWeight": "9/25", + "payableAmount": "52.38", "paramVersion": "P20260101-00", "paramEffectiveDate": "2026-02-17" } +``` + +### 1.5 前端设计 + +**岗位角色页**(`views/system/business-roles/index.vue`):列表(复用 `usePagedList`/`AppPagination`/`StatusTag`)+ 新增/编辑弹窗(模板下拉,选中后展示模板声明的参数清单与当前值)+ 启停/删除(内置岗位按钮禁用并 title 提示原因)。路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。 + +**计提参数页**(`views/system/params/index.vue`):按 `param_group` 分组展示(名称/当前值+单位/生效日期/口径说明);编辑后进入「待提交」区,顶部选生效方式(立即/指定日期)+ 备注;「提交变更」二次确认(列出每项 旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + Excel 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」)。路由 meta order 6。导出接口走 axios 实例取 blob(非统一信封,api 文件内注明)。 + +--- + +## 二、任务分解(T1–T8) + +任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。每任务遵循「写测试 → 确认失败 → 实现 → 确认通过 → commit」。 + +### T1 迁移与模型(四表 + 种子) + +**文件**:`app/models/business_role.py`、`app/models/param.py`(创建);`app/models/__init__.py`(改);`alembic/versions/xxxx_*.py`(autogenerate+种子);`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql`(创建)。 + +- [ ] 写两个模型文件(参照 `app/models/role.py` 写法;`ParamChangeBatch`/`ParamValue` 不继承 TimestampMixin) +- [ ] 注册进 `models/__init__.py`(漏注册会让 autogenerate 生成删表迁移) +- [ ] `uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表"` → 人工检查命名/注释/ON UPDATE → 补种子(3 岗位/16 定义/版本条目/初始批次) +- [ ] `uv run alembic upgrade head` → `uv run alembic upgrade :head --sql > db/migration/V3__*.sql` +- [ ] 同步 `db/init/init-dev.sql`(建表 + 种子 + alembic_version 更新) +- [ ] 写 `db/queries/计提参数.sql`(时点化解析与对比查询留档) +- [ ] 验证:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿 +- [ ] Commit:`feat(system): 新增岗位角色与计提参数四表及 V3 迁移` + +### T2 参数注册表 + 公式纯函数 + +**文件**:`app/services/param_registry.py`、`calc_templates.py`、`formula.py`(创建);`app/core/constants.py`(改);`tests/unit/test_formula.py`、`test_param_registry.py`。 + +- [ ] 写失败测试(关键断言):`salesperson_rate(360,...) == Fraction(6,1000)`;`361 天 == Fraction(4,1000)`;`第 1 天 == Fraction(10) - Fraction(1,90)`;`payment_days(2025-12-15, 2026-01-10) == 27`;同日回款 == 1;`converted_profit(0,...) == 0`(防除零);负绩效不归零;`payment_weight(60) == Fraction(36,100)`;`parse_fraction("4/360") == Fraction(1,90)`;注册表 16 项且模板声明参数均存在 +- [ ] 运行确认失败 → 实现三个文件 → 运行 `uv run pytest tests/unit -q` 全绿 +- [ ] Commit:`feat(system): 计提公式模板注册表与精确分数计算函数` + +### T3 参数仓储 + 取值服务(时点化解析) + +**文件**:`app/repositories/param_repository.py`、`app/services/param_service.py`(解析部分);`tests/unit/test_param_snapshot.py`。 + +- [ ] 写失败测试:覆盖链(角色 > 产品类别 > 默认);未定义参数按注册表默认值兜底;同日多次变更取 id 最大 +- [ ] 实现 `resolve_values(session, biz_date)`(一条 SQL 取全量快照)+ `ParamSnapshot`/`resolve_snapshot` → `uv run pytest tests/unit -q` 全绿 +- [ ] Commit:`feat(system): 计提参数按业务日期时点化取值服务` + +### T4 岗位角色 CRUD 后端(含审计与保护规则) + +**文件**:`app/repositories/business_role_repository.py`、`app/schemas/business_role.py`、`app/services/business_role_service.py`、`app/api/v1/system/business_roles.py`(创建);`app/api/router.py`、`app/core/constants.py`(改)。整体参照 `roles` 模块模式。 + +- [ ] 实现(保护规则:内置禁删禁停、code 不可改、模板必须存在于注册表、人员引用计数钩子预留;写操作包 `audit_service.audit()`) +- [ ] 冒烟:新增自定义岗位 ✓、删除内置 → 403、非法模板 → 400 +- [ ] Commit:`feat(system): 业务岗位角色管理接口与保护规则` + +### T5 参数变更/历史/对比/导出/预览后端 + +**文件**:`app/schemas/param.py`、`app/api/v1/system/params.py`(创建);`param_service.py` 扩展、`app/api/router.py`、`constants.py`(改);`pyproject.toml`(`uv add openpyxl`)。 + +- [ ] 实现 9 个端点(提交/撤回/历史/分页/明细/对比/导出/预览/目录快照);撤回已生效批次 → 400;导出列含变更人/时间/项/旧值/新值/生效日期 +- [ ] 冒烟链路:提交(指定未来日期)→ 当前快照不变 → 立即生效提交 → 快照变化 → 撤回未生效 → 回退 → 预览 360/361 → 导出可打开 +- [ ] Commit:`feat(system): 计提参数版本管理、变更留痕与计算预览接口` + +### T6 前端岗位角色页 + +**文件**:`src/api/businessRoles.ts`、`src/views/system/business-roles/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。 + +- [ ] 实现页面与路由 → `pnpm type-check && pnpm build` 通过 +- [ ] Commit:`feat(system): 前端岗位角色管理页面` + +### T7 前端参数配置页(版本管理 + 预览) + +**文件**:`src/api/params.ts`、`src/views/system/params/index.vue`(创建);`router/index.ts`、`utils/constants.ts`(改)。 + +- [ ] 实现页面(分组表/生效方式/二次确认/版本抽屉/预览抽屉)→ `pnpm type-check` 通过 +- [ ] e2e 链路:改参数 → 变更记录出现 → 预览值变化 → 导出可下载 +- [ ] Commit:`feat(system): 前端计提参数配置与版本管理页面` + +### T8 模块测试统一编写与文档 + +**文件**:`tests/unit/test_param_service.py`、`tests/integration/test_business_roles.py`、`test_param_management.py`(创建);`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`、`frontend/e2e/*`(改)。 + +- [ ] 集成用例:参数精度全链路(注册表→种子→解析→预览一致);未来生效不泄漏;撤回回退;同日多次变更;审计留痕;权限 403;岗位保护规则 +- [ ] 全量回归 `uv run pytest -q`(301 基线 + 新增全绿);e2e;三份 README 更新 +- [ ] Commit:`test(system): 参数体系模块用例与测试记录` + +--- + +## 三、执行安排 + +- **分支**:`feat/system-params`(已创建);`feature/system-management` 是否先合并 main 由用户决定,不阻塞 +- **环境**:启动 `D:\work\app\Docker\Docker Desktop.exe` → `docker compose -f docker/docker-compose.dev.yml up -d`;T2 纯函数开发不依赖容器 +- **方式**:原生执行(当前会话逐任务推进);每任务后跑验证命令;T8 全量回归;收尾一轮 code review +- **注意**:Windows uvicorn `--reload` 偶发卡死——验证 reload 以「新 worker 的 startup complete」为准;开放问题 7 项(见下)按默认处理,不阻塞 + +## 四、验收标准(M1 完成的定义) + +1. 管理员可新增岗位并绑定公式模板(无需改代码);内置岗位禁删禁停 +2. 任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录(批次 + 操作日志双保险) +3. 任意业务日期可解析出当时快照(08-12 企业户=2‰、08-13=3‰ 回溯正确) +4. 计算预览精确复现:360 天=6‰、361 天=4‰、12-15→01-10=27 天 +5. `uv run pytest -q` 301 基线 + 新增全绿;`pnpm type-check && pnpm build` 通过;e2e 链路通过 +6. `db/migration/V3__*.sql` 与 `db/init/init-dev.sql` 双轨同步(`test_schema_sync.py` 把关) + +## 五、风险与开放问题 + +**风险(已定对策)**:参数缓存一期不做(保留 `ys:param.ver:*` 键位,未来按批次版本号精确失效);同日多次变更 UI 明示「按最新一条生效」;`4/360` 展示注明精确值;模板扩展摩擦一期接受(受控下拉);预览出参与需规 2.4.1 明细列同名(减少 M3 映射);openpyxl 在 T5 增补依赖。 + +**开放问题(不阻塞,按默认处理)**:① 第 1 天 10‰ vs 9.9889‰ → 按公式实现并参数化;② 企业户 2‰ 历史种子 → 已双种子;③ BALANCE 模式语义 → M3 前确认;④ `probation.salary.basic` 归属 → M2 时复核;⑤ 参数查询权限 → 暂 AdminUser;⑥ 导出列集合 → 以需规 2.3.1(四) 为准;⑦ 经理个人户业绩进入 12.5%+5% 的衔接 → **M3 开工前必须确认**。 + +## 六、验证方式 + +1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e; +2. 验收标准 6 条逐条核对(见第四章); +3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。 diff --git a/docs/superpowers/plans/2026-10-12-剩余开发计划.md b/docs/superpowers/plans/2026-10-12-剩余开发计划.md new file mode 100644 index 0000000..56a7444 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-剩余开发计划.md @@ -0,0 +1,607 @@ +# 剩余开发计划(路线图 + 首工作块实施计划) + +> **面向 AI 代理的工作者:** 首工作块(M1)实施时使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现本计划第二章;步骤使用复选框(`- [ ]`)语法跟踪进度。第一章路线图供全局导航。 + +**目标:** 在需求裁决全部落定(2026-10-12)后,为剩余 5 大模块与 T+ 同步给出推进路线;并交付首个工作块「系统设置与参数体系(#28)」的可执行实施计划(任务级,含测试与验证)。 + +**架构:** 沿用现有六层(api → services → repositories → models,schemas 承载请求/响应,core 横切);新增「公式模板注册表(代码内有限集合)+ 参数按生效日期时点化存储 + 计算预览」作为核算模块的输入契约,不引入规则引擎。 + +**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。 + +**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。 + +## 全局约束 + +- 表:小写蛇形复数、列小写蛇形、每列中文 COMMENT、主键 `id`、`uk_`/`idx_`/`fk_` 前缀;金额类 DECIMAL;时间列用 `TimestampMixin`(`ON UPDATE CURRENT_TIMESTAMP` 须 `text()` 显式写);append-only 表不继承 TimestampMixin(与 `login_logs` 先例一致) +- 迁移双轨:Alembic 生成迁移 → 导出 SQL 到 `db/migration/V{n}__*.sql`(按序编号)→ 同步 `db/init/init-dev.sql`;`tests/integration/test_schema_sync.py` 拦截漂移 +- 六层红线:api 只参数绑定(写 `def` 非 `async def`)→ services 唯一事务边界 → repositories 只 `flush()`;统一响应 `{code,message,data}`,分页 `pageNum/pageSize` → `list/total/pageNum/pageSize` +- 写操作接入 `audit_service.audit()`;审计脱敏在格式化后执行(顺序不可倒) +- 金额全程 `Decimal`/`Fraction`,**禁 float**;`4/360` 按精确分数存储(`"4/360"`,禁 `0.011111`) +- Redis 键前缀 `ys:`,TTL 按业务语义;禁 `KEYS *` / `FLUSHALL` / `FLUSHDB` +- 前端:TS 锁 `~5.9.3`(不可 7.x);复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`;视觉沿用 `--ys-*` token;新页面必须写 `meta.title` +- 测试:业务模块整体功能完成时统一编写用例;记录写入 `backend/tests/README.md` / `frontend/README.md` +- 提交:`feat(scope): 中文说明`;不直推 main/develop + +## 审查重点(Review Focus) + +以下输入/失败模式最可能伤到使用者,对应测试已钉进第二章各任务: + +1. 畸形参数值(`"1/0"` 除零分数、非数字、超取值域)→ 保存必须 400 拒绝且不落库(T2/T5) +2. 指定未来生效的变更泄漏进当前快照 → 快照解析必须不含(T3/T5) +3. 撤回未生效批次后解析未回退 → 必须回退到撤回前值(T5) +4. 回款天数边界:同日回款 = 1 天;12-15 → 01-10 = 27 天;跨年连续(T2) +5. 计提比例边界:360 天 = 6‰、361 天 = 4‰、第 1 天 = 10 − 4/360 ≈ 9.9889‰(精确分数断言)(T2) +6. 毛利润额 = 0 → 折算额 0、绩效 0 不除零;负绩效按公式正常计算不归零(T2) + +--- + +## 第一章 模块级路线图 + +### 1.1 推荐顺序与依赖 + +```text +① 系统设置与参数体系(#28) ← 地基:岗位角色 + 参数 + 公式模板 +② 基础数据管理 ← 人员引用 ① 岗位角色(外键上游) +③ 业务数据管理 ← 引用 ② 人员/产品/客户 +④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 +⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;⑥ 写入 ②③ 目标表,受 IT 字段映射制约 +``` + +### 1.2 顺序论证(要点) + +**① 先于基础数据:** 外键方向在上游(人员→岗位,先建避免迁移返工);参数取值契约是 ④ 的输入面,先定型让后续模块按契约编码;计算预览(需规 2.3.1 四)首块即可交给业务对账「360 天=6‰、361 天=4‰」,验收前移;参数清单提炼本身 = 28 项裁决逐条复核。阈值参数归 ① 有明文依据(#28「全部阈值统一在系统设置维护」)。 + +**⑥ 放最后:** 字段映射未定(对照表 #26 ⏳IT),放前部会把外部不确定性引入关键路径;目标表由 ②③ 定稿后同步才有意义;一期可手工录入闭环。**但预留前移**:②③ 表设计必须含外部唯一键列(`source_no`/`t_plus_id`)、同步批次时间列,并约定「同步不覆盖人工字段(指导价、归属、岗位)」。 + +**⑤ 可后置:** 纯消费端无下游,口径依赖真实核算数据校准;唯一约束是 ④ 设计台账时对照需规 2.4.1 明细列(合同编号/发货日/回款日/回款天数/产品类别/客户类型/人员岗位/计提比例/支付权重R/绩效金额)一次性建全。 + +### 1.3 各块范围与验收标准 + +| 块 | 范围界定 | 验收标准(一句话) | +| --- | --- | --- | +| ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览 | 管理员可新增岗位并绑定模板;任一参数变更产生含变更人/时间/项/旧值/新值/生效日期的记录;任意业务日期可解析当时快照;预览精确复现 360 天=6‰、361 天=4‰;301 基线不回归 | +| ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更 | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 | +| ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器 | 一笔业务可多产品多批次发货、多笔回款;回款天数按「发货日首日=1」自动算;折算走精确分数;评级只出等级不影响金额 | +| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕 | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成 | +| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致 | +| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 | + +### 1.4 里程碑(交付批次) + +| 批次 | 内容 | 说明 | +| --- | --- | --- | +| **M1** | ① 系统设置与参数体系 | 独立可交付;预览工具可作业务对账入口先行验收 | +| **M2** | ② + ③ | 合并交付:一次表结构评审把 T+ 预留列与报表字段清单同时定稿 | +| **M3** | ④ 绩效工资核算 | 核心钱袋子;测试重点在精度与参数回溯 | +| **M4** | ⑤ 报表 + ⑥ T+ 同步(并行) | ⑥ 受 IT 制约,谁先就绪谁先交 | + +### 1.5 需求依据对照 + +| 块 | 依据 | 关联作废条款(不引用) | +| --- | --- | --- | +| ① | 需规 2.3.1(四)/2.5.2/2.1.2;对照表 #28/#5/#8/#20–#24 | 经理个人户 8‰(不设参数);含运费口径;协助 25%(不设参数) | +| ② | 需规 2.1.1–2.1.3;对照表 #9/#19/#26 | 信息部负责人条款(信息员独立角色) | +| ③ | 需规 2.2.1–2.2.5;对照表 #1/#2/#12/#13/#14/#18/#25 | 26 天示例;含运费公式(2.2.4/2.3.3 整段) | +| ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24 | 协助 25%;经理 8‰;26 天示例;含运费公式 | +| ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R) | — | +| ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — | + +--- + +## 第二章 首工作块「系统设置与参数体系(#28)」实施计划 + +### 2.1 关键设计取舍(已定) + +1. **参数存储 = 参数定义 + 逐项版本条目(时点化 append-only)+ 变更批次表承载「版本」**;否决全量快照(改一项复制全量、单参数历史需遍历)与单行 JSON(无回溯、无字段校验); +2. 数值一律以 **Fraction 可解析字符串**存储(`"4/360"`,禁 `0.011111`),解析走 `Fraction(text)` 不经 float; +3. **不设日期唯一键**:同日多次修正是合法留痕,解析按 `effective_date DESC, id DESC` 取最新; +4. **一期不做参数 Redis 缓存**(算钱系统缓存失效即错钱;20 行级查询成本可忽略),保留 `ys:param:ver:*` 键位待实测后启用; +5. 维度覆盖链:**角色维度 > 产品类别维度 > 默认行**;`dimension_value` 用空串不用 NULL(MySQL 组合索引对 NULL 不去重); +6. append-only 表(`param_values`/`param_change_batches`)**不继承 TimestampMixin**(无 update_time,与 `login_logs` 先例一致)。 + +### 2.2 表设计(V3 迁移,四张新表) + +**`business_roles` 业务岗位角色表**(与系统登录角色 `roles` 严格区分,表注释写明;这是 #28「人员岗位角色」的落点) + +| 列 | 类型 | 约束/说明 | +| --- | --- | --- | +| id | BIGINT UNSIGNED | PK AUTO_INCREMENT | +| role_code | VARCHAR(32) | NOT NULL,uk;创建后不可改(同 roles 保护) | +| role_name | VARCHAR(64) | NOT NULL | +| calc_template | VARCHAR(32) | NOT NULL,计提公式模板编码(以代码注册表为准) | +| description | VARCHAR(255) | NULL | +| sort_order | INT | NOT NULL DEFAULT 0 | +| status | TINYINT | NOT NULL DEFAULT 1(1 启用 0 禁用) | +| is_builtin | TINYINT | NOT NULL DEFAULT 0(1 内置禁删) | +| create_time / update_time | DATETIME | TimestampMixin | + +种子(迁移 + init-dev 双写):`salesperson 营销员`(TPL_SALESPERSON_DECAY)、`sales_manager 营销经理`(TPL_MANAGER_SHARE)、`info_officer 信息员`(TPL_INFO_SALES_FIXED),均内置。 + +**`param_definitions` 计提参数定义表**:`param_code` VARCHAR(64) uk;`param_name` VARCHAR(64);`param_group` VARCHAR(32);`value_type` VARCHAR(16)(DECIMAL/INT/BOOL/ENUM/TEXT);`unit` VARCHAR(16)(‰/%/元/天,可 NULL);`dimension_type` VARCHAR(32) NOT NULL DEFAULT 'NONE'(NONE/PRODUCT_CATEGORY/BUSINESS_ROLE);`options_text` VARCHAR(255)(枚举竖线分隔);`default_value` / `value_min` / `value_max` VARCHAR(64)(Fraction 可解析字符串);`description` VARCHAR(255);`sort_order` INT;`status` TINYINT;TimestampMixin。 + +**`param_change_batches` 参数变更批次表**:`version_no` VARCHAR(32) uk(如 `P20261012-01`);`effective_date` DATE NOT NULL idx;`change_type` TINYINT(1 立即 2 指定日期);`remark` VARCHAR(255);`operator_id` BIGINT UNSIGNED FK→users ON DELETE SET NULL + `operator_name` VARCHAR(64) 冗余固化;`item_count` INT NOT NULL DEFAULT 0;仅 `create_time`。 + +**`param_values` 参数版本条目表(append-only)**:`param_id` FK→param_definitions RESTRICT idx;`dimension_value` VARCHAR(32) NOT NULL DEFAULT `''`;`effective_date` DATE NOT NULL idx;`value_text` VARCHAR(64) NOT NULL;`old_value_text` VARCHAR(64) NULL;`batch_id` FK→param_change_batches RESTRICT idx;仅 `create_time`。索引 `idx_param_values_param_id_dimension_value_effective_date`(解析热路径)。已生效条目禁改禁删;未生效批次可整批撤回(删批次+条目)。解析 SQL 留存 `db/queries/计提参数.sql`。 + +### 2.3 参数清单(16 项,迁移种子) + +| param_code | 名称 | 类型/单位 | 维度 | 默认值 | 依据 | +| --- | --- | --- | --- | --- | --- | +| salesperson.rate.start | 业务员起始计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 10 | 需规 2.3.1(一)1 | +| salesperson.rate.slope | 业务员日递减斜率 | DECIMAL ‰/天 | BUSINESS_ROLE | **4/360** | 对照表 #4 | +| salesperson.rate.floor | 业务员计提下限 | DECIMAL ‰ | BUSINESS_ROLE | 4 | 对照表 #4 | +| salesperson.days.boundary | 线性递减分界天数 | INT 天 | BUSINESS_ROLE | 360 | 对照表 #4 | +| manager.share.team | 经理本团队分成 | DECIMAL % | BUSINESS_ROLE | 12.5 | #6/#15/#16 | +| manager.share.company | 经理全公司分成 | DECIMAL % | BUSINESS_ROLE | 5 | 同上 | +| enterprise.rate | 企业户计提比例 | DECIMAL ‰ | PRODUCT_CATEGORY | 3 | #20 | +| info.rate | 信息员计提比例 | DECIMAL ‰ | BUSINESS_ROLE | 1 | #9 | +| prepay.rate | 预付款计提比例 | DECIMAL ‰ | NONE | 10 | #23 | +| barter.discount | 以物抵顶折算比例 | DECIMAL % | NONE | 80 | #24 | +| payment.start.ratio | 支付起付比例 | DECIMAL % | NONE | 60 | #21 | +| payment.weight.mode | 支付权重应用方式 | ENUM FULL\|BALANCE | NONE | FULL | #22 | +| product.threshold | 折算毛利润额阈值 | DECIMAL % | PRODUCT_CATEGORY | 市政水利 30 / 轨交 20 | #12、需规 2.1.2 | +| salary.basic.mode | 月工资基本工资口径 | ENUM INCLUDE\|DEDUCT\|EXCLUDE | BUSINESS_ROLE | 业务员 INCLUDE / 经理 DEDUCT | #8 | +| probation.annual.threshold | 试用期年累计结算阈值 | DECIMAL 元 | NONE | 18000 | #17 | +| probation.salary.basic | 试用期基本工资标准 | DECIMAL 元 | NONE | 3000 | 需规(归人员字段则撤,见开放问题) | + +**不设参数**(作废条款的落地):经理个人户 8‰ 起始/斜率;协助 25% 分成;含运费口径开关。 + +**种子生效日期**(迁移与 init-dev 双写):`product.threshold`/`salesperson.*` = 2026-02-17;`enterprise.rate` = **2026-02-17 起 2‰ + 2026-08-13 起 3‰ 两行**;`manager.share.*`/`info.rate` = 2026-08-13;支付环节/预付款/以物抵顶/月工资口径 = 2026-01-01。 + +### 2.4 公式模板与角色绑定 + +**模板注册表**(`app/services/calc_templates.py`,代码内有限集合): + +| 模板编码 | 名称 | 形态 | 声明参数 | +| --- | --- | --- | --- | +| TPL_SALESPERSON_DECAY | 业务员个人户·天数线性递减 | 分段:≤分界 = 起始 − 天数×斜率;> 分界 = 下限 | salesperson.rate.* / days.boundary | +| TPL_MANAGER_SHARE | 经理·团队与全公司分成 | 聚合:∑本团队×12.5% + ∑全公司×5% | manager.share.* | +| TPL_ENTERPRISE_FIXED | 企业户·固定计提 | 乘法:折算毛利润额 × 比例 | enterprise.rate | +| TPL_INFO_SALES_FIXED | 信息员·销售额固定比例 | 乘法:本人销售额 × 比例 | info.rate | +| TPL_PREPAY_FIXED | 预付款·固定计提 | 乘法:回款额 × 比例 | prepay.rate | + +修饰器(不绑角色,参数驱动):以物抵顶 ×80%、支付权重 R×R、起付 60%、试用期缓发。 + +**绑定模型**:`business_roles.calc_template` → 模板;角色级覆盖经 `dimension_type=BUSINESS_ROLE` 条目。新增岗位 = 新增行 + 选模板(零代码,满足 #28 ①)。 + +**消费接口契约**(④ 与预览共用,函数签名级): + +```python +# app/services/param_service.py +@dataclass(frozen=True, slots=True) +class ParamSnapshot: + biz_date: date + def get(self, code, *, role_code=None, product_category=None) -> str: ... # 覆盖链解析后原始值 + def fraction(self, code, **dims) -> Fraction: ... + def integer(self, code, **dims) -> int: ... + def boolean(self, code, **dims) -> bool: ... + def text(self, code, **dims) -> str: ... + +def resolve_snapshot(session, biz_date: date) -> ParamSnapshot +def submit_change(session, payload, *, operator, meta=None) -> ParamChangeBatchOut +def compare(session, *, from_date: date, to_date: date) -> list[ParamDiffOut] + +# app/services/formula.py(纯函数,预览与 ④ 共用;禁 float) +def payment_days(ship_date, receipt_date) -> int # 发货日首日=1 +def salesperson_rate(days, *, start, slope, floor, boundary_days) -> Fraction +def profit_rate(profit: Decimal, revenue: Decimal) -> Fraction # 不含运费;revenue=0 防除零 +def converted_profit(revenue, rate, threshold) -> Decimal # 化简式:销售额 ×(毛利润率−阈值) +def performance_amount(base, rate) -> Decimal # 乘法方向(对照表 #3) +def apply_barter(amount, discount) -> Decimal +def payment_weight(r: int) -> Fraction # r×r +def start_payment_ok(received, revenue, ratio) -> bool +``` + +### 2.5 文件清单 + +#### 后端 + +| 层 | 文件 | 内容 | +| --- | --- | --- | +| models | `app/models/business_role.py`、`app/models/param.py`、`app/models/__init__.py`(改) | 四张表;**必须注册进 `__init__`**(漏注册会让 autogenerate 生成删表迁移) | +| repositories | `app/repositories/business_role_repository.py`、`param_repository.py` | 只 flush;`resolve_values(session, biz_date)` 一条 SQL 取全量快照;批次撤回用 `for_update` 锁行 | +| schemas | `app/schemas/business_role.py`、`param.py` | Out/CreateIn/UpdateIn/StatusIn/Query、TemplateOptionOut;ParamDefinitionOut/SnapshotOut/ChangeIn/Out/BatchOut/BatchDetailOut/DiffOut/PreviewIn/Out | +| services | `app/services/param_registry.py`、`calc_templates.py`、`formula.py`、`business_role_service.py`、`param_service.py` | 参数目录注册表(种子唯一来源 + 运行期校验);模板注册表;纯函数;岗位 CRUD+保护规则;参数提交/历史/对比/预览/导出 | +| api | `app/api/v1/system/business_roles.py`、`params.py`、`app/api/router.py`(改) | 挂载 `/api/v1/system/business-roles`、`/api/v1/system/params`(AdminUser) | +| core | `app/core/constants.py`(改) | `MODULE_BUSINESS_ROLE`/`MODULE_PARAM`、内置岗位编码、产品类别码 | +| 迁移 | `alembic/versions/xxxx_*.py`、`db/migration/V3__add_business_roles_and_params.sql`、`db/init/init-dev.sql`(改)、`db/queries/计提参数.sql` | 四表 + 种子;双轨同步 | +| 依赖 | `backend/pyproject.toml`(改) | 增补 `openpyxl`(参数导出用;`uv add openpyxl`) | + +**接口集合**:business-roles 列表/新增/详情/编辑/删除/启停 + `meta/templates` 模板下拉;params 目录快照(`asOf`)、单参数历史、变更批次提交/撤回/分页/明细、双日期对比、Excel 导出(`StreamingResponse`,非统一信封)、计算预览。写操作全走 `audit_service.audit()`;变更批次表与操作日志互为双保险。 + +#### 前端 + +| 类型 | 文件 | 说明 | +| --- | --- | --- | +| api | `src/api/businessRoles.ts`、`src/api/params.ts` | params 的导出接口直接用 axios 实例(响应为 blob,非统一信封,文件内注明例外) | +| 页面 | `src/views/system/business-roles/index.vue` | 列表+表单+启停+删除(保护提示)+模板选择(展示模板声明参数) | +| 页面 | `src/views/system/params/index.vue` | 按 param_group 分组;生效方式(立即/指定日期)+备注;保存二次确认;版本管理抽屉(记录/明细/对比/导出);预览抽屉 | +| 路由 | `src/router/index.ts`(改) | `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`、`{ title: '计提参数', …, order: 6 }` | +| 常量 | `src/utils/constants.ts`(改) | MODULE_OPTIONS 增补;参数分组/单位展示映射 | +| 复用 | `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` | 不新增组件 | + +### 2.6 任务序列(步骤级) + +任务依赖:T1 → T2 → T3 → (T4 ∥ T5) → (T6 ∥ T7) → T8。 + +--- + +#### 任务 T1:迁移与模型(四张新表 + 种子) + +**文件:** + +- 创建:`backend/app/models/business_role.py`、`backend/app/models/param.py` +- 修改:`backend/app/models/__init__.py` +- 创建:`backend/alembic/versions/xxxx_add_business_roles_and_params.py`(autogenerate 后人工补种子) +- 创建:`db/migration/V3__add_business_roles_and_params.sql` +- 修改:`db/init/init-dev.sql` +- 创建:`db/queries/计提参数.sql` + +- [ ] **步骤 1:写两个模型文件** + +按 2.2 字段设计实现 `BusinessRole`、`ParamDefinition`、`ParamChangeBatch`、`ParamValue`。写法参照 `backend/app/models/role.py`(`Mapped`/`mapped_column`、`mysql_table_args("中文表注释")`、`BIGINT_UNSIGNED`、`TINYINT_TYPE`);`ParamChangeBatch`/`ParamValue` 不继承 `TimestampMixin`,仅手写 `create_time`(`server_default=text("CURRENT_TIMESTAMP")`);外键目标类型必须与 `users.id` 一致(`BIGINT_UNSIGNED`)。 + +- [ ] **步骤 2:注册进 `models/__init__.py`** + +四个类加入导入与 `__all__`(漏注册会让 autogenerate 把新表当成待删除)。 + +- [ ] **步骤 3:生成迁移并补种子** + +```bash +cd backend && uv run alembic revision --autogenerate -m "新增岗位角色与计提参数四表" +``` + +人工检查:表/列中文 COMMENT、`uk_`/`idx_`/`fk_` 命名、`ON UPDATE CURRENT_TIMESTAMP` 子句;然后按 2.2/2.3 用 `op.bulk_insert` 或 `op.execute` 补种子:3 个内置岗位、16 项参数定义、版本条目(`enterprise.rate` 两行:2026-02-17=2、2026-08-13=3;其余按 2.3 生效日期表;维度行展开——`product.threshold` 两行市政/轨交、`salary.basic.mode` 两行业务员/经理)。种子条目的 `batch_id` 需要一条「初始版本」批次(`version_no` 如 `P20260101-00`、`remark` 初始版本)。 + +- [ ] **步骤 4:应用迁移并导出 SQL** + +```bash +cd backend && uv run alembic upgrade head +uv run alembic upgrade :head --sql > ../db/migration/V3__add_business_roles_and_params.sql +``` + +(V2 revision 从 `alembic/versions/` 中操作日志迁移文件读取。) + +- [ ] **步骤 5:同步 `db/init/init-dev.sql`** + +追加四表 DDL(与迁移产出一致)+ 种子 INSERT + 更新末尾 `alembic_version` 为 V3 revision id。 + +- [ ] **步骤 6:写 `db/queries/计提参数.sql`** + +留存解析查询(时点化取值:`WHERE effective_date <= :biz_date ORDER BY effective_date DESC, id DESC` 取每组最新)与对比查询。 + +- [ ] **步骤 7:验证** + +```bash +cd backend && uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q +``` + +预期:全部 PASS(schema 双轨无漂移、新表规范断言通过)。 + +- [ ] **步骤 8:Commit** + +```bash +git add backend/app/models backend/alembic db/init/init-dev.sql db/migration db/queries +git commit -m "feat(system): 新增岗位角色与计提参数四表及 V3 迁移" +``` + +--- + +#### 任务 T2:参数注册表 + 公式纯函数 + +**文件:** + +- 创建:`backend/app/services/param_registry.py`、`backend/app/services/calc_templates.py`、`backend/app/services/formula.py` +- 修改:`backend/app/core/constants.py`(内置岗位编码、产品类别码常量) +- 测试:`backend/tests/unit/test_formula.py`、`backend/tests/unit/test_param_registry.py` + +- [ ] **步骤 1:编写失败的测试(公式,算钱核心)** + +代表性用例(全部精确分数断言,禁近似): + +```python +def test_业务员360天_比例恰为6‰(): + rate = salesperson_rate(360, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(6, 1000) + +def test_业务员361天_固定下限4‰_分界线(): + rate = salesperson_rate(361, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(4, 1000) + +def test_业务员第1天_按公式为899比90000_精确分数(): + rate = salesperson_rate(1, start=Fraction(10), slope=Fraction(4, 360), + floor=Fraction(4), boundary_days=360) + assert rate == Fraction(10) - Fraction(1, 90) # 899/90 ‰ + +def test_回款天数_发货日首日为第1天_12月15日到1月10日为27天(): + assert payment_days(date(2025, 12, 15), date(2026, 1, 10)) == 27 + +def test_回款天数_同日回款为1天(): + d = date(2026, 1, 1) + assert payment_days(d, d) == 1 + +def test_毛利润额为零_折算额与绩效均为零_不除零(): + assert converted_profit(Decimal("0"), Fraction(0), Fraction(3, 10)) == Decimal("0") + +def test_毛利润率低于阈值_折算为负_绩效负值不归零(): + # 销售额 100、毛利润率 10%、阈值 30% → 折算 -20 + assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(3, 10)) == Decimal("-20") + assert performance_amount(Decimal("-20"), Fraction(6, 1000)) == Decimal("-0.12") + +def test_支付权重_R为60时权重0点36(): + assert payment_weight(60) == Fraction(36, 100) + +def test_注册表_16项_编码唯一_必填字段非空(): + assert len(PARAM_REGISTRY) == 16 + assert len({s.code for s in PARAM_REGISTRY.values()}) == 16 + # 每项 name/group/value_type 非空 + +def test_解析分数_4除以360_按Fraction不丢精度(): + assert parse_fraction("4/360") == Fraction(1, 90) + +def test_模板注册表_五个模板_声明参数均在注册表内(): + # TEMPLATES 的 params 引用必须全部存在于 PARAM_REGISTRY(跨表一致性) +``` + +- [ ] **步骤 2:运行确认失败** + +```bash +cd backend && uv run pytest tests/unit/test_formula.py tests/unit/test_param_registry.py -q +``` + +预期:FAIL(`ModuleNotFoundError` / `NameError`)。 + +- [ ] **步骤 3:实现三个 service 文件** + +`formula.py`:8 个纯函数(签名见 2.4 契约)。要点:`salesperson_rate` 中 `days <= boundary_days` 用线性式、否则取 `floor`;`converted_profit` 用化简式 `revenue × (rate − threshold)`;`performance_amount` 为乘法;`payment_days` 为 `(receipt_date - ship_date).days + 1`。 + +`param_registry.py`:`ParamSpec` 冻结 dataclass + `PARAM_REGISTRY`(16 项,值与 2.3 种子一致)+ `get_spec`(未注册编码抛 500 级异常,属代码 bug)+ `parse_fraction`(`Fraction(text)`,捕获 `ZeroDivisionError`/`ValueError` → 400)+ `validate_and_normalize`(类型/取值域/枚举校验,返回规范串)。 + +`calc_templates.py`:`TemplateSpec` + `TEMPLATES`(5 个模板,见 2.4)+ `get_template`/`required_params`。 + +- [ ] **步骤 4:运行确认通过** + +```bash +cd backend && uv run pytest tests/unit -q +``` + +预期:全部 PASS(含既有单元基线)。 + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/services/formula.py backend/app/services/param_registry.py backend/app/services/calc_templates.py backend/app/core/constants.py backend/tests/unit/test_formula.py backend/tests/unit/test_param_registry.py +git commit -m "feat(system): 计提公式模板注册表与精确分数计算函数" +``` + +--- + +#### 任务 T3:参数仓储 + 取值服务(时点化解析) + +**文件:** + +- 创建:`backend/app/repositories/param_repository.py` +- 创建:`backend/app/services/param_service.py`(先实现 `ParamSnapshot`/`resolve_snapshot` 与覆盖链选择) +- 测试:`backend/tests/unit/test_param_snapshot.py`(覆盖链纯逻辑;真实 DB 解析在 T8 统一集成测试) + +- [ ] **步骤 1:编写失败的测试(覆盖链与兜底)** + +```python +def test_覆盖链_角色维度优先于产品类别与默认(): + # rows: (code, dimension) 含 ('enterprise.rate', ''), ('enterprise.rate', 'municipal_water') + # 取 role_code=None, product_category='municipal_water' → 返回产品类别行 + +def test_未定义参数_按注册表默认值兜底(): + # 某参数无任何版本条目 → snapshot.fraction(code) == 注册表 default_value 的 Fraction + +def test_同日多次变更_取id最大的一条(): + # 同日两行 → 取 id 大者 +``` + +- [ ] **步骤 2:运行确认失败**(`uv run pytest tests/unit/test_param_snapshot.py -q`,预期 FAIL) + +- [ ] **步骤 3:实现** + +`param_repository.py`:`resolve_values(session, biz_date)` 一条 SQL 取全量快照(按 `(param_id, dimension_value)` 分组取 `effective_date DESC, id DESC` 首行;可用窗口函数或子查询,SQL 留存 `db/queries/计提参数.sql`);批次 CRUD 与 `for_update` 加锁函数(签名参照 2.5 与设计报告)。 + +`param_service.py`:`ParamSnapshot`(frozen dataclass,持有解析后 `dict[(code, dim)] → value` 与 `biz_date`)+ `resolve_snapshot(session, biz_date)`(读定义 + 读条目 + 合并注册表默认值);`get/fraction/integer/boolean/text` 五类取值(覆盖链:role_code 维度 > product_category 维度 > 默认行;`fraction` 走 `parse_fraction`)。 + +- [ ] **步骤 4:运行确认通过**(`uv run pytest tests/unit -q`,预期 PASS) + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/repositories/param_repository.py backend/app/services/param_service.py backend/tests/unit/test_param_snapshot.py +git commit -m "feat(system): 计提参数按业务日期时点化取值服务" +``` + +--- + +#### 任务 T4:岗位角色 CRUD 后端(含审计与保护规则) + +**文件:** + +- 创建:`backend/app/repositories/business_role_repository.py`、`backend/app/schemas/business_role.py`、`backend/app/services/business_role_service.py`、`backend/app/api/v1/system/business_roles.py` +- 修改:`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_BUSINESS_ROLE) + +- [ ] **步骤 1:实现四层文件** + +整体参照 `roles` 模块(`role_repository.py`/`role_service.py`/`api/v1/system/roles.py`)的写法与保护规则模式。接口:列表(分页+关键字+状态)、新增、详情、编辑、删除、启停、`GET /meta/templates` 模板下拉。保护规则:`is_builtin` 禁删禁停;`role_code` 创建后不可改;`calc_template` 必须存在于 `TEMPLATES`(否则 400);删除前检查人员引用(② 落地前以引用计数钩子预留,接入后生效);写操作全部包 `audit_service.audit()`。 + +- [ ] **步骤 2:冒烟验证(容器 + 后端在跑)** + +```bash +cd backend && uv run uvicorn app.main:app --reload --port 8000 +# 另一终端:curl 登录取 token 后依次打 7 个端点;重点验证: +# - 新增自定义岗位(绑 TPL_ENTERPRISE_FIXED)→ 201/200 +# - 删除内置 salesperson → 403;删除自定义岗位 → 成功 +# - calc_template 传不存在的编码 → 400 +``` + +- [ ] **步骤 3:Commit** + +```bash +git add backend/app/repositories/business_role_repository.py backend/app/schemas/business_role.py backend/app/services/business_role_service.py backend/app/api/v1/system/business_roles.py backend/app/api/router.py backend/app/core/constants.py +git commit -m "feat(system): 业务岗位角色管理接口与保护规则" +``` + +(模块级正式用例按项目约定在 T8 统一编写。) + +--- + +#### 任务 T5:参数变更/历史/对比/导出/预览后端 + +**文件:** + +- 创建:`backend/app/schemas/param.py`、`backend/app/api/v1/system/params.py` +- 修改:`backend/app/services/param_service.py`(提交/撤回/历史/对比/预览/导出)、`backend/app/api/router.py`、`backend/app/core/constants.py`(MODULE_PARAM)、`backend/pyproject.toml`(`uv add openpyxl`) + +- [ ] **步骤 1:加依赖**:`cd backend && uv add openpyxl`(参数导出用) + +- [ ] **步骤 2:实现 schemas 与 service 扩展** + +`submit_change`:事务内校验全部 items(`validate_and_normalize`)→ 建批次(`version_no` 规则 `P{YYYYMMDD}-{当日序号}`)→ 逐项写 `param_values`(`old_value_text` 取当前生效值)→ `item_count` → commit。`withdraw_batch`:已生效(`effective_date <= today`)禁撤(400);`for_update` 锁批次行后删条目与批次。`compare`:两日期各取快照后逐项 diff。`preview`:入参(产品类别/客户类型/岗位/发货日/回款日/回款金额/销售额/毛利润额/是否以物抵顶/是否试用期)→ 输出(回款天数/计提比例/折算毛利润额/单笔绩效/起付判定/R×R 结果/所用参数生效版本)。`export_changes`:openpyxl 流式写变更记录(列:变更人/时间/参数/旧值/新值/生效日期/批次号)。 + +- [ ] **步骤 3:实现 api 层** + +9 个端点(目录快照/历史/提交/撤回/批次分页/批次明细/对比/导出/预览);写操作包审计;导出返回 `StreamingResponse`(非统一信封,前端 api 层注明)。 + +- [ ] **步骤 4:冒烟验证** + +```bash +# 提交一次变更(3 项参数,指定未来日期)→ 当前快照不变 +# 到该日期(或提交立即生效)→ 快照变化、历史出现记录 +# 撤回未生效批次 → 快照回退;撤回已生效批次 → 400 +# 预览:360 天/361 天边界、12-15→01-10=27 天精确复现 +# 导出 → xlsx 可打开且列齐全 +``` + +- [ ] **步骤 5:Commit** + +```bash +git add backend/app/schemas/param.py backend/app/services/param_service.py backend/app/api/v1/system/params.py backend/app/api/router.py backend/app/core/constants.py backend/pyproject.toml backend/uv.lock +git commit -m "feat(system): 计提参数版本管理、变更留痕与计算预览接口" +``` + +--- + +#### 任务 T6:前端岗位角色页 + +**文件:** + +- 创建:`frontend/src/api/businessRoles.ts`、`frontend/src/views/system/business-roles/index.vue` +- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts` + +- [ ] **步骤 1:api 封装 + 页面** + +页面整体复用用户管理页模式(`usePagedList` + `useConfirmAction` + `AppPagination` + `StatusTag`);表单含模板下拉(选中后展示模板声明的参数清单与当前值);内置岗位删除/停用按钮禁用并给 title 提示;路由 meta `{ title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 }`。 + +- [ ] **步骤 2:验证** + +```bash +cd frontend && pnpm type-check && pnpm build +``` + +预期:类型检查与构建通过。 + +- [ ] **步骤 3:Commit** + +```bash +git add frontend/src/api/businessRoles.ts frontend/src/views/system/business-roles frontend/src/router/index.ts frontend/src/utils/constants.ts +git commit -m "feat(system): 前端岗位角色管理页面" +``` + +--- + +#### 任务 T7:前端参数配置页(含版本管理抽屉与预览) + +**文件:** + +- 创建:`frontend/src/api/params.ts`、`frontend/src/views/system/params/index.vue` +- 修改:`frontend/src/router/index.ts`、`frontend/src/utils/constants.ts` + +- [ ] **步骤 1:api 封装**(导出接口用 axios 实例取 blob,注明非统一信封例外) + +- [ ] **步骤 2:页面实现** + +按 `param_group` 分组展示参数(名称/当前值+单位/生效日期/口径说明);顶部「生效方式(立即/指定日期)+ 生效日期 + 备注」;保存前二次确认(列出改动项与旧值→新值);版本管理抽屉(变更记录分页 + 批次明细 + 两日期对比 + 导出下载);预览抽屉(表单 → 结果卡,注明「斜率精确值 4/360」);路由 meta order 6。 + +- [ ] **步骤 3:验证** + +```bash +cd frontend && pnpm type-check +# e2e(需容器+前后端在跑):改一个参数 → 变更记录出现 → 预览值变化 → 导出可下载 +``` + +- [ ] **步骤 4:Commit** + +```bash +git add frontend/src/api/params.ts frontend/src/views/system/params frontend/src/router/index.ts frontend/src/utils/constants.ts +git commit -m "feat(system): 前端计提参数配置与版本管理页面" +``` + +--- + +#### 任务 T8:模块测试统一编写与文档更新 + +**文件:** + +- 创建:`backend/tests/unit/test_param_service.py`(可 fakeredis 化的部分)、`backend/tests/integration/test_business_roles.py`、`backend/tests/integration/test_param_management.py` +- 修改:`backend/tests/README.md`、`frontend/README.md`、`backend/README.md`(接口清单)、`frontend/e2e/system_management.py` 或新增 `frontend/e2e/param_management.py` + +- [ ] **步骤 1:编写集成用例**(覆盖审查重点 1/2/3) + +参数精度专项(Fraction/1/90 全链路:注册表 → 种子 → 解析 → 预览一致);未来生效不泄漏;撤回回退;同日多次变更取最新;审计留痕(成功与失败);权限(普通用户 403);岗位保护规则(内置禁删、模板校验)。 + +- [ ] **步骤 2:全量回归** + +```bash +cd backend && uv run pytest -q +``` + +预期:301 基线 + 新增用例全绿。 + +- [ ] **步骤 3:e2e 与文档** + +前端 e2e 步骤(菜单出现两个新页、改参数→记录→预览链路);更新三份 README(用例清单/运行记录/发现缺陷/待补用例、接口清单)。 + +- [ ] **步骤 4:Commit** + +```bash +git add backend/tests frontend/e2e backend/README.md frontend/README.md +git commit -m "test(system): 参数体系模块用例与测试记录" +``` + +--- + +### 2.7 风险与开放问题 + +#### 风险(已定对策) + +1. 参数缓存:一期不做(见 2.1-4);若未来启用必须按批次版本号精确失效,禁通配删除; +2. 同日多次变更:UI 明示「同日多次变更按最新一条生效」; +3. `4/360` 展示:存储分数、展示格式化(预览页注明「精确值 4/360,非截断 0.011111」); +4. 模板扩展摩擦:新公式形态仍需加模板(一期接受,与「不引入规则引擎」一致);岗位表单把模板做成带说明的受控下拉; +5. 报表字段前移:预览出参尽量与需规 2.4.1 明细列同名,减少 ④ 映射工作; +6. 参数导出用 openpyxl:已在 T5 增补依赖(AI_CONTEXT 已认可方向)。 + +#### 开放问题(不阻塞首块开工,逐项有默认处理) + +| # | 问题 | 默认处理 | +| --- | --- | --- | +| 1 | 第 1 天计提:需规图示「第 1 天 10‰」vs 公式 `10 − 4/360 ≈ 9.9889‰` | 按公式实现(上限为参考);起始值参数化,业务如需 10‰ 可在系统设置调整 | +| 2 | 企业户 02-17 前历史(2‰ 是否回溯) | 已按双种子(02-17=2‰、08-13=3‰)实现;如无需回溯可撤首行 | +| 3 | `payment.weight.mode` BALANCE 模式语义(余额口径) | 首块只存枚举;语义在 ④ 开工前确认 | +| 4 | `probation.salary.basic`(3000 元)是参数还是人员工资标准字段 | 暂列参数;② 开工时如归人员字段则移除 | +| 5 | 参数查询权限(普通用户可否只读快照) | 建议「查看可、修改仅管理员」,实现为 AdminUser 时可放宽——待确认 | +| 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 | +| 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) | + +## 验证方式 + +1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款); +2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose); +3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测); +4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。 diff --git a/docs/业务确认问题清单.docx b/docs/业务确认问题清单.docx deleted file mode 100644 index 5b0d35c0c1eb8bb4a1212377b8210f5c9351d08f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11571 zcmc(FWmH^Ev+m#$+=9DHa0{-%U4s+c-QC?KSa5f@5F8TRo!}0^HOQU365)K`UHASv zz1C*WEc&Ub?yBmpdb;H#!N6ewKU%J^xYl1E{`ms|d^=m)8PLo9*Df#q?V@96Xkq`e zBlKUg`ch>L?|>bMzySc48mX{#VNB7NY94+9XTG0#V302WqJaaiJLdMv*u(Vq+}9GkRCz)SwJHN- zd2aG47VG1_YqhSHalXEZZaiv{@2;JO0uxhJ3E}UD5p+=c1QmB*SdoMHa(0$pvZCLA z-ap#;o{U41Gh{IiDWg6hX@WUDAGb$K9J=EQvAb*&>07uFNXYu~Bujb%ZdVDZU&D55 zBH@q(!dkWkb_{lXCZTkI|u@L&!h7uzZ}x5 zpifSryefxBmrdYKa&Rr`X^FMi#=cp*D5NlUc9GXB+xcM}E2gIU{3qSR+Kjs!LN-{3 z?>s$D8h!b3#1K!2WTX2JVBmc%N^^-t4;$dXA5^7~EvMnuNL9sxhJ?sOs#131NN1&w zFl7z={a&6C-sOAAW~oG+OZcZfpc6Inse~-@S`UM3x|_2#;cr5%$)V0kT`iNwj!N1+ z+J>gr!-VT1PooVRtlcovaGsvxdmnY~C`f16HAmILRArzc?V1mB@Im(a@=K={vYN6# zv@08~z4nGwFNi$|C$CRR(fNMMUk};xjfG;U2QO)Mq~-2bSFA}fzB5Bz3~I6Wwd{+U z3?U@6vAzrpL%v!{sBn8n3AuPRULt5aM+t@aEMBT&fi>KncmpoF;&YF0FLjjX!g03dT?8D$J%E85(NUpXqXbAU{`(-6$G3 zo!Z&)NGV1oY!>?V$Shv1?7h3jy2#mC0Cw^ZTG(L~*r%%`+dU z*>J?oGm;nJO?h`TzR7|slK7u^%bwiZl_d4Kn}YAP<0En%8sp$wQyBNYvF^EGY79$2 z(+p9f711D}krKIRRC$!rGnc2p2!pSn&Sz$!JDwb5IYt4~0yxcM+ zdnr&XPRk(N6UXGOV186?F0H;?I{Egfq@e!o`(jq+4wov)i?GgtTCSFp%&h=WOL#OB za~a?Lo*a}>O2yKF`*nF!jMaUUjWWxbil7qMOP67Ps=EH~d@i~slmNkHXBSQIEPgF5 zJefF>5F7L{Sc9)kC}4?)Yfe)%m=c{^2Ms4+M94Z4&H{U+)vCMj8Dw73n6ROrYV)Ao z?(Rk}pb_8q)7?8`S!nkW%9@=_7VO}(yniAkvhHB@WjKE4n2iisYA8Br^_EmF* zQq8|ZM2uYmFKxaPUnOcZ`Y2Hqey>u2O_YBpRlEXE)P_%AwCgsD*no2dNliRGN^>HB zs#TGBs|&A25y(~EZ{u*rrdrIVyyPLq4?#{4)1hMDH8b#CzEZyz^dtU|!BGQWvIg(A zRau|+%U!F-nU3-*)*=sV23(P$Ey0Mpl8|+JjX>$)9r-y;*g2$`Stg63#%9ebfnL9) zVNjO@_Q=;Sj6r3M1Hly!H4zRIbh6)2IlhBKpW%(bYv}thDiudX4kwj$sD?*wrphWc z)g>kM9wZYFb*cAM%j~{A^>lp6uckR#x#f5gCa7*xwIn5>(D|;R>-kZ&nIoijAUEl^~;wG*)$Z+sblhyf~8oH&C z5_LP7(3^|e#!$Y=?+qvUgl46KumoUAFONJRl;euvt+TUCPi&&{LCUqPESab3oB0BW zfgZz5^JkQsX3E2!Si@2EHfjGVuy^uu?rx8sZ{neV_J&EIQ&jzAi}h0tPw@I^qOcE=^n(vSXL1Q5A2r!{?n}FG5KAIkZzy9G8a`y- z!aM2j z4tS`!emv*pzKXu`#!o~!?(SC6z?p?%PaHQL(jZPNN?<$YSmfYZik0lPvr-8?t~O&V zH_BPKgAN_4)8w{o!wcdYLoeLDfzDfTgkS>#5L4Y7rF_Jwsl-0`cep zku1jCUGXwrp8U^f=8@Ww*jcUw_zheZiKwlN@Luqb#?Ws>W8ohjgW{wZFeTx>HRUrW zyqIK*v0I~uh4n_s+|1%m0o~}{;52xmTIF7Nd}qm5DML;bSuj*iMuLj6@dI0JfB)O#b+;JzZ^y zYygO+i=IjU%BfOju=sj~9Qmu{9ik;@HW)`YjaHq$?t#5RZ_MaVw%txg*jwYtaM5;F z;dU0V@v@zH@xXTkqP>M7Z`74aeItp)dJ1ptdVsN9ob=wPX_Kx21En27C50v}V5K4$ zUTmK! z8uJ>)tNlUQ3pIs|MddzKd0J_8R!elM(gKFtp-&MtX0Q=KkGVCkBC7F%oDp&nkNU!= zHa%GyB|};BMc^@rv1yv2@w#gHBB=0{G54+qZyZ1R60>qCr_pA3fs7~gMrgi|e{rxo z&M-foCNgix4TkXwqmL53@Vz-VR*%o*A#{qi@JCd#i8`V@;nc3p^RB#cs94I_f)0-6 zykIF2(pFkId}+>M3J{?NJGPY}92}RJ<@5}G&$>o;x0`H6m-ihJZr0VWjq_?|-=x=m z2oZC7mz9v(RY`Rd%NpFApqWZm3R~aERVXKl#cs(+wE-MfI>XvquTEL2>*UY~4IRci zO*HSuD_i_j3W-{(QB!D?BQLBw{j*2h`T3}uqn- zUCpSHl8;%R`JP>`xhH5%_MO_-n0pXr+C3F{RqL&myxB$fKZ$TEt#`H5-VMe%KznDN zg{fg5))_w5f054}CMw4kz0E$hORRkXP`393p9;Ft-cU`P+-TCaI;OTJKV>`Jvt$aC z6ekyxqEi$-=W;Qh)(lfJPjKaT8_E;J@n@}tkFe2P_SF#*(#}>0@pjD{qxXn8esSjO zr6Bs`6Ww#+V(nh)Qv9`buOh(K=(ZF_Mt#B+a7uvWn!S@qy^Sf`N8d?ja%5w~~qw~ZJYK~~w#wXKLGZat`78Fp{ zKlg?tEemXko5jyqD5PP^V%QmK&&ay>U2(eP$c*?9fTa}~ZcM)X0{Zp(_A)_}tz0~+V0oTYoaFS04b9?eqEUQ?z>Jar;0ZH{+jw(r)5~1N_tJUAh#mV z%cO9_``9-$Bsit3)Osd6X@sXdYeVax%ApT8fhLK1$eY0e9n?exTHg~49w1nIA0 zda)Lh?iM&ho7=B2CcKZ@J)k1LykyV>j5D0CV~~&a-R%gFbChE^N4lb~q&k|L)d=q( z4UI47z&vkZZ1-IxCyqW=SM$}2Nq$5rTjijpcVOh#DRBf*1=nTPK)qpAvr4G)LJLLp z6Ku-ImrQ-|cfbzG*Zh!dANZOtQ#i%8z9}$=svdLN>6@Zty%p8TJ2@(B#0=7}If+`` zhLN+2^6sr~xIb{h#((W|VE*_ufwQ9JAV?r$NFqz9QjR^y?h^8`8)RbHD!iM6NZncG z!d#+QeN_{_p{7OE*ndHJ7_y#W64J;g+g8@!7HYKc4N1Dnz4Jsw`gt7|r?H|DIC04) z@z{M4YGsyUA;!yWUwI5B69R8T_(f~)3Nt=^&Sno?3o`b?5kq-mkyQ4jFB~%(^ngK; zYRZ9EMH?>6uhCp7l8viQ${m;bSE7+j%}!%$7!aowct2BXjPeG6iQ5E9p3HU1J}pZm-h- z&6szsxqb~2^J?i3*DVbGN5@SQeMZ9OTSP8?}0@Zn^a*OtI+B&mxcxpN>*?lk7lU+wZ|jQ%Z{ghjP9sDRL;p4W;bPkO*KA7|7!zpdT*|5) zvAIHu{#xG{d%u9hj?U=>CIoU}SK|un;iV6jR7%v4FmQ)z0}iVkpna4e`kDD5)W$t7 zpm}f4isJ!Z$@%UvWK=OOelSV){Ra+rWN<5xfg;F3yXl>@0!rIEcu^sO21-2~=wu5P zICWSXdIMO(f<B=}WV2~*9#Uw|eGGrB+DBWTX4s}FbCo=Mj=nSsz98&@<#f@aFHDHpVcSr2Z zsUy1W7L`D;^ga%Z_WC|mG^T#3P)$~wb4u7{mALq-meWWcu8pI9$R=_4DOK2cGm`*! zhV;&y^o~yK0*CC*QljS;6fy8zSjIVF!aQNZ-QRC1)}uYv;|X5kD1Vbrd}Oyybosr; zCJ$BB*_|uZ{I%L=+b#92vbLG7w$Sf>@0j5+X*ej6z0+;BHozxa-TG)oTNa1ky_KJm z^{!^@eiwzMDx} z&S)s{V9o1Wk;tSg{C3=+ek+}R|C3nxpTrgnFqVLvikzLb&2!F5Pjo<_76br51%}-Z z{^e)4ch+`>KY}hXPQV-(bp7pN6Y~d=MY*01lTb?&#JFqXbd@_up8!A6l9MODEe-VumZfZhv9n*_x-wlEit(!w;P#;gkM?4}T_a6+&s3AG%s#P_R? zx_O>8Sqg0F3QgHT6y@gCPKaeikq=&cyX-XPv3y-`zE>iYRNe_DX6XBfb*Uc)Ncz%> zCZ7}Vo$p=WPK$W44;N-~uY6~2&hW(C%01P;zkGD_c-k`J<;>L5CTpsoff)igrVzDa z1qE)~>8zw2y3OQrzCWe&R^USmF6TMxKRhB2m6bPwq|Y(~cP@ zv_(VhOL3!9UWdbhzBLn5WH;4sz#3qvwyHh;(Pe(W{;Z(+drfPw7>PyQJ>^CBkyR5q z`=ad}!b173CDt+W1_-q((PP@eQdR{~p2VX|>=&Is7gcnU}Je(|Pwa9=JR7q@eEN8a~6+kJlNEjIY$)^A+i)?|s>!p}P*yxyu^HGEG zf%xkL+FNL*!weN1GEXj*}kSxkzY@q=id-X54z^167j zoNE#jXywHs&W-HK#4i{NA(ud7$!hbJf`&Lbl;m-h#wm*&&~gF06Xb~_3Uzie6qt(^ zA`sQ8eukpz^){+0C~9;Vw7E?Rg2Hz0G62Rq7 zCBx|9Mt1`^NaLn4k1S!&tu^%)p`nYCqZVJVTjv1Md3^BT<#keP;`$?!8_XOFE zW#wrxwrPp#U}LJ%<$0JQ=dUi(g{+@nt$vMx4Wc{2ymdt-b;-Rq)JMawCcz&U!6nh- zGcFZIB5w?JZ%fTc#)*^xgF^&ybN#ygk-cP01Dh+FEnXIH@Z|$c*)aL>tKkn6+lyf) zVIG4-_6KlZmUaZe-DPWW-@ZJvq+mpANj#00c6O_^nfl0@z@1N7MwPP~<(Wse6>TmJ zqJ)9~J8R#4Ci}Io4PNhfP!a(F4w68N!bUDgZjkJfs!cYdvR7Cdm!%(STzcfiNbx~P z^hGoC!^1;u+EYw@ZTc&=Z+E4qVDX%D_YPs?LY1WI$jxPG7nd9tpIkN~%xAK1b{%`3 zIFvom`gx`hbwnO2`3g%2L^tsFE9o~^;R+}5_cvZ}auPNK-;D>Rr{A>5PDjyiZbo$R zDswfXUB&}nZAh0JH%%qI?(FNL%5qO^vm2f8uLlt^KoaOrhyaa*%gyI4WXE$2@-Fmc zUP-*1UvGJv9)526|8t>&1p4QHV%7>JM>2aeHV2nt?I%Gp_ zT#G8gd8YIVyV7~Q{9;DV{=)?1isjbUlwm6S3qsK;_uGT)LKvk&bH%Fb>xxqhmT%?I zEbVx&us<{&e8DSA3a41WDh_43(NwYw73n67J4O!2qkKP+CsqB`A=E$CZ4vOw zL%Xzgie7&GbYy=vMro#5D-78!jiMIr89Pq$zJTpnO+>ta91`{l13P@?mga)k<(J}X zndo@TIub5tjL8IlhICd`RDLRO(dK{#F5JNB&=9h=asZw#=qS0`7}{(7i0OKjL78p_ zr29ll)u|>Me(3tGjtywsRWMEWcx?SjLu=T3o`VY_h*X8FWU&tL9y*MHdVBmd;A{9*ai`6;zbNw0QqcyL7? z&rSU{#h0bKDB|hw=!sNZVM@oUJ%)~5*K_ix>Xj01IyhvZq569l$(#{0%#@&0bQG^5 zqF3NBBcV7w&sw?0;3Lz zOmsD^6M#xF*Ifdj5rKa~(vqJ0F1t4L&Li&7ovf&dQ zAsQRf@1Zv>Ea?8;QIe;CGtY7&3$bQr#$RTE)d|5j(;3nI1tNBiFlq2qqpgO^7!r3p z4;I^QRVE(Nsp3j-qbO8l7I2-X=z_#^!D)(3pB-z2H+Ml5tZN6YmHpF{YOagKaX=Gp z0-qnI{I&l&+B;ZV{xD_6hyZLC15)5xX~O)X(o5oeQ}*=*5l4avQydc`L2|mQD^YO7 z)w9#?e)E=OoiO-Z4s!VZ8b8o4XR9U?C%e|cFVN!P;EnVI;kjTPEbXIl8OVbb419#U z(-n#mQMhv16}F|CBXHZqUHimOIbMgWHk7`R*jG|EHdGZqL|p6doGop#qU5Wm#HvoI5(O1is~ z_(9p+-mGoZKVV=~3+l@jZB>`#1L`)pW!~}zEv^wm82gXV24rm^(0;FIqX>(sfx>{` zpRpU^WRs}QAi}JW=u;4t=-gzQ%rn7l3#6G>1q|&g%^E{!Ksu}%Qa4H8bKR%Z!suvcxh^vS2iD_$sR`FPa>bd-4?*{m zAa1D>;oJ=h*`eUj@@&bYLwZSzoWHygu;p-RvR{vSb=`qOCxuC@K!Iik*f2NUVfiH4 z-F*izl_eWD6DhzjP))vE{aTF>jO7w_Z|5*!rsS3SKHu0O?~N4xj@S+Rwm_)S#E@^h za%%3n(X0+w887JJx0-G4*CvNj@yX*Kh8BDF>91a@Re$L(z8&nbFgH4dH2c7J1jlhz zB8S{S+gU>KU@aY$nVvV;{WT}>WfCVw2l7&!p#a9bu1WA{?E#cERzI{!M+xDHQ(;?m zoaabssp;v&1(7c z2{320@k%HA!%VPr)6_n^ce_JndSdZcROWblGyE(+nsB~vv;?}XJMh5+K7XA6|GmNd z;RAo|HqsFS2%to8B9($I&S9@27`un85Ogm9@G67|fpYrXpJ=rnKVCn6^g6fVU1CAE zwMnPN#L)R5?&sg{$)%~QwT*K;6`ake%u0gnh~!?WDtsBsyr)DxNLV?+bjhgEA@L~f zdunimwfF?JdIt7qnDI$OMRo$cG!E#cKTk>xtqguT>2SiL)dnF_kWTM9Z)a4?^g!5T zIKE0-Y3edm7G{4@2Vuw6_K~v(^hkqp0vmf9V_}N*V+v!L^`lv0U6(6N0pUCoSttXp zgsBt6?fG}T)A%Ke8Ld}Rri9R$S#AH%p1IaIR5uh4Q@AqO0qY-(&M^%7YB|bmTN|g9 zzr|4ClNeby{`}r02pEKE`WK2GV%UAHGx;$VT<#@K+c(KvtC1MwaLC1|u&h3(^>y~x zI{9mQo<4PjaVGV|)e!U3hCEI7W!-v^A4uA9GwQGtGs%tiu<-Vf$2V|cp8B~d$E8v^ zPm(3c}#eN%U$xxk{@ zD3t=}B&+ttpETlgUsCLyRW+Kd1{y{|*nl@0sK^8ygR{!62OXM-{B%ouLk9<7aq$;xlb^5*tS;aJb$Z7>7wxrd1WQ%gnMQIOpIc;(#d5XP!HmGVgE2l9C@2#8%XC>4% z2;^IgRmQ8W1cTwt`*-@W1mm&Dwx2ory3B{?Lm?u=PYNZ@bDDa$Ont%|0W0aV>A|b7 z+s7_k>)tHks%^CL`qo>Y4VLzv4{_J8?m@hj=VjXk<;JlSrW-8UpTE(lwOtp(Yx6x~ zM@kDa^R-Lv>yOz-|C)VIwJ1Aw*AcQ!h<+_dV%)r1Ll@L?fK5y^2hC!zwJ4-Y`0E`3Aw6a z%b^;lrpP{7N0D)K;j;K5%cqOw9xiLVRY!qLJUK(g@6A5xN__HcNB5*SOE?K@5{h31Xlw2f^n)UDDaLSaEeycCJ1ybRVdOq^5=R$bT9gE6 zl|Z*h`gxRyE#!$mscWW?qgOj*l9#_TO(PenjPF3sV@Z5mm%;K)e=@z2BRqE;oEWd#*Cxn_au1ZvQnWZ4=F^UC)R5xt9fRkTid9JF#t4VIsMnC9f*Bb zX8hcTVDHMS+N z-iguX^CV_bf9;t|5{xl}6ML&~?r~Sf=o7Jr6$NU*k+B#kXku6gbzt!^lzR~NQMZc* zcBWo=8*dsbBHB|Md?PO81NP!XmX`MTD@+F*Ijo#1#->)NiMv?!HXZ?8W&W<$uN0Z1 z!`!HQRn@)0uA$0yKyFdxDD5+hE%1ZHz7)T^RM*f2TIvDu{{-2b}5AOHx_G9?-z_|HY zV*Y~tJ^1Hb@V`Ld8(5nDg#F75KZidj8h^pp(f(2S58Ck;{(ou>%yIu|KeYCH`7h=3 z3-hfBAXg{!4%l_V1JRvy6W(;Cb2nOMnp0 zZvy@)qMxIm*Q~$L;J{?@FaP+XdU=ljeaZh(_55m}`2XPlTMhJF)bqmRm#C6|Mg5+z zK8OFV$&WnuS4$=QLzCZy{70KUPep%85+M3V((j4sb48wKKEL3|z*Xa~DgAvnd=CHb zF8o> z;^=70;$?3aGY(aRW5Y&RE7zK?TU`kTlwTC(=58ZeK*w}ypp3C_od8-|B;h>8(U5CC zL=e?f5ore<)Ni^9z10WBWh8` zuA(dj9eNsL8e7sDb72mLW+Hd|Zt&tsa7s|Jy?&M11WV8OD)siGoH)61_wMk6?c?p~ z<@Li4^o@ySA_R=^8NU3rxkfbGA7vfoktl^B0tGxz0 zcdHHUedaWEj1&V>o*>uJ6bKSh$DhIwz9E)^&6oL56f#qIo42!Ix08eoCCc z!po#*U2SXnwgim{ji|_l6=-ru0uqVXD*ei2xiw$Y*#+2Jg}J`E+8xa{Y!d2Rd3f-r zMAa6tHV0D_>k)y7+5n=v%9*)%Zo?_Ts}OF35D8OxEkj-^iZPN?>iV$W+Y_lv z6%@{1Cq&EQ@%FwV(lW0*CI)uotjL(jxBI3BT)I;Y_yek8^^aFq+ z_clxj-qr-{FKI-~8GP1uf7*~#5Ri#nNV?kx0-QP}riDRsPUQo861`;k%w?&1;f%!* zA(2HB@m2oPm5Jt*d)8Ely3|%NJUO>|FuqK&QE9k|@9HoZj?_&JGKuQhN0v1)Wt1> znktj_m61nbksS{33d)U9k`uNLXBUEM2kt0bA`5f0Gq3N*-}!S0N!`9`RX?)`9?4c~ zTHy9FM-o`UFz%D#{H==FYz;Ot&(Gf}&p*0Fr_?KnexQt>N38Pp`P?kd!zZ)`y?u zFxe37S7}A~@&c35aEz5EVQSBG?o5(|?qVRw(+1Qi-I&wOU!eIN0FpO)Ovq?&sD*^5 z9V;)mBf}$%Xq{+<6pYAmy0qBi3cqttY3V$S91zh2hO;^CCx2zh7yk$rtxw7iR2bm$ zEr($&vGJmMwU&BGC2U+hfd1x{v~{QahL>7%Z^Y8)lf>6`B)t71XNMy*pe>aYwxF;|Q-PQhkh zw@)0qH4yM?hm?)VBAPH{jnufoG+v|Cm*La$(iFT>C*;C?v9Ld2EZ^?8!M;_dq_*GZ zN7P?IZ{cc5@uk6&|D53UZ10_%b2}BUfwAb8xMV}&7c;aTk{$|H^J>y_e)XXSg>EsU zfI+<}5mx-!wC`L>(2NB8hr00ZMLfDM0$edAJiDEM*;d3`OxtClxSq9`<5?4vlNjz_ z=)T2yy3}HSy{nE}E5X*M$LdxK+NNJ0v1!}Jp#ig?Y0&MxzhI)pw0P-h=I3Fn1ZEpI zicV|qPmC|M_}+SgE}>E2^xGKT1cKOVrW7b!O6thk@3h_c=o?{cikVieZEMY68}C8q=){Ednmu%!jx(HJR2YB z-5VPXhX7lZAatvs_EMn(Cd#EB zESClQ;})|d7VA-;6C_~7c3maeySNIG=(N#z-2&d49ZS7u?4BPRKx%YPRheDhdQM^) zXyPFyzJlhg@zpVY@bDn0uJm|Qv;%2d4DFqBuO_nNmL39Gk!Cp)`I^zgh?>-^--N`f@0g~<6ZY%Z}B+g&x0;R3I+M4 z$Fof=$nm{k*U9TBrMriK!|mm|-KZ;r+XE%oH-uMYZ$BX^-MQ99C7eU<#tp}yuQS0~nDV-@YR*s_qk^?0mAiI{bLV9<6rI30#{KknyIymGlV z|LNo1^5cZ&vO1$$`h2~&e!6RH?$OkYeru~OnR`>w(cJ9HChzg&3{-P0HvedLro*)4 zMI}gB^zz)!h=77%=XDb^Hg$N?@C)U+O{u&)fZ4;)f_+rMI83%5Tea}&-DSRN_=b-D z5!pr}INM`utnyG)da=NQpaOqORH76(D=)FK)HA9MRJdpZm5Do*v4nl{=RtpaFS#;} zaep|@{$o1Zd~a81cmnb|Dfj22hZrWTBAldmq{^xvsigPD+zeM!K^-|+#hM#3;p#=( zf}C-T=h^CD>Y(+0dtd@{hQoTG7yXB;wmiMTwQ1n0cgT?3y~FiUVKw(mxvYpHy!F?K z#mcj?;vIx-qit2Kru*UNbe=USA5(G1jxXD^tJ%Bx=y;0{4Y>;!l|$fH>2rKVtjg_T1H2 zs`4*8>G_F#c*LB4p1j&Sd3C)M`c=$c-_V6`dfgJaJQu^=QK}TUCEL%5j1EnF6%;$J ze2QIcbMHVP)dTL#oxyS{DmW^3E`nE}pmZ(q`f?m8=pX5eeAR}t?ThB9?o7kP6{UI( zm8i|I%~Q^h_CbnoE26fzcBQA;V_K)Xk)d$%?-DnGaJ@p*dCGo|-PpL^WimQbc{Ty;Jd%#X5tf6L0- zuJLi5i;`{?NVW=rM-s!*bd?t9%f8&!LjLqMx9+_^PZX}ykhf@Q6Btlnq4pSxZ!3A8 zENT*`$P-=UdmGf+w5gl`E2}ZnL5?ewUvm-?*SAli&X*H)girlnDHK)B` zrNc`mh1b+cL_TgwxiQSigaiPfVFEvGX$zvlLQof4?%eT?Q}Nmvzsl*vZlR&x`3w@vJGI72 z2Rf^@?Mpra%!zMo-}<*@Lv8eeCC0Z#(BHk4?#OYe4(zk%(;TRYpW>A;!O$`3HuVwk zlGhC!h{pFCNx>=^08(%mB4dS8WPa|~mPf+px-6`NOAH@EYG5phlgHd&3!N*hhPi;I zE-PQX_VSd=GQMW7CwV7(p(>rT!>*XMsY4zSwie#Kb5drhhb3g$$swrNoZ;C?MF;!zi%(+%3aO9j*nlG=C;fTyP|3IL#B3zhsfT+|+_OAaQY1beJb#fU%`Dh@hMeFD_n*d$EH_24&T?}yPFL4 z-pljAR{cbN+I*SPRPkKiNcR%HFJsttPwOC)tzv43YCp$Rz#6*c8f~1;o$(}>^n^h- z6AEk{E?GG6lxWvbO<2PGth$e_I6D!BZZ)|b&i!Erp&dT~4S!K8%~(sC_{WTmEb-^q z9#l&d#*7lyv*J0i+meBST0krG5bhkV(HPiq1Apk9&udJgG{v zlnG-Z(lQz2{q8}Aw@mAjNEIDiX`%7Cgq)&5CNTDr(I-JreNF^@s>(cB4I2!J?4zGn z>4~0z6(eAFaPbG||(zAuDwjSgDlRg%F<>-mmSPe3=0f1zDOIUB9S~Q4ABvFA8)}Qs z1znLZQa9!LSZ7FAXiHN+nFNJ>qix=A#kRq0!JFT`j{(hzT~5pHLD!4IH-`og6|WLS z;ZIf-1_#dyt1rpN(0KKKKD@ZP3MPz_9!;6#ao@#|6)z~Fa`)jpP6-O7(liP#^yc$c$wrwWp z!cATi&RQoPCu8lRcE;hVt*z#N)$ zH^CdfxEEn1O%aN#aZv8|!e>VlL3?p&5+zuM7Y@4bi4ZYFb`eFocHsHFpEa0jS`Ah1 z$%G}A?|h61w&vipo*X6dZjf(##%20LuBSzOVz9Qg4r$ijAjb~PfRR3=c6mXa0imo% zp6!SYXR|SsNRA02oeJvD*fo!cl|+x)kLjOWq&{vjZL`gPrdZT+ScyR^UYdNhTti>w z1IFo2RRTW{*u=n-(o&#pp|Wk{bNI)pjo_hB%YB4q*LsQzVYuL6-I&S*Av@1VC4?qv zTMbQk6J!u7v!PXli;$tUb%V9z5hIv<_fpen(sr;YNe`}CMTkgiYgq~XT>2JVWt+5Gs~{PEuZK$ zARfWRkX+Zd9hgWjJF=jcbZwPb4J=o_UcX}j6v6=cY-YE8@cWa+6>sW?6kcR1s=~)9 zV0#btA*Pp*zpZm13dA!^N@KGh>#~nWz|dj`627;|T*tJUqbmc*MA z@W$hSUaJI(W^6%C&O7faG&+gqOM z;~cv5Om!axVaJzamFO-Qa44ozr9l+2!my4JN(l5XX;SdYm`>kG8_{pHduVZg87A*e z)qL=B3bdeb)3D{K6@qq!%sw|z*UWdR66L|yMB=FZ>JZZue96?8(S7N>(?=^uR)xZA z$Q8>iww>*1Z^MGDNi@xj%a3yzEX+O`Attt-(~uNIV66IrK09uh%IS5icg_%dX_&4J zYH|b9^)vI@5u{M_^)NB+uY7(W^pKEiMt5xR1}~$`DWMPl`yy z0PaLbiaOyZn5~Fc3K86XAo}I{JkR>Ahqs>aa1+Zfq{h5gAnv&6XWLC zfc~&F&tj@wh95g9s98_7ZwdU4A4{@{zZrghMYJ4LXui+D#3eLQ?%HpTaEW-zAT27t z){m#3HOR+3`R$W*D%JzovPzk`vXsWeMd?MwbH1P7#>mAx7Xuh4%Y|i@i!}>~-&`*> z)v%5nIhs1Z_v?)Lq+WZ=O`sWZy`*)=s^vj8?{Ce&`*dD=9DIy^KvsWU%Y01vNC)8p}V}^kL{#Y&4(#VJLHZir>NG@j}ReQZ{5U>xL4+*JF1paivjMQ{aq` z^)b-*?2G;53rggXag402YfH*o!nHy3CTLnV)*jYO`~$}~%16rooQeT|N!+IW=uq&J zy#4D!dpZ>xx|rL!LRfy?kK4Kggq{R0Di3oPS8GQHD2R=P6+~t3VCrc0bdHCD+}$jg zc|k&g0;=YA#%@nDT&=8~T&bQ+99*FwD>pYMeh9?X^hqx>c4cvNGIx0T#lq3W-q`Kw z(Zv$tWNd0{Y-tW*XJzGvJpJQ87@*%Vsnook%>O@#jusZyrsm>~rtbFU4sQPf4EcQq zh)T`a#nRjj3WEHh?SrUf%%C7uc5@zXK0Z@+UUm)>PE$T(5EVpF0P@>@1^+|n-#vq| zd;lWO6PagEsyWHuGQ;1T{&(l#FMb>16%>0}u_dcayp;}kE7S9eR;rD4eP9MC=v40U z7FY@_O}pPU!{$*XQW4m-rhej?nod_bf4FU2Q5ws$47Wz^>^2u+gRKgr@SR_C<@V%S zJ#Iv*X2TCUOJ zkwc!{g%L#)0|nsSJ=V@CjpENfiT3P)O_5r<&1%1Dv++eu<~y8-5Or$kY0MCHw>YqY zc<_dxyjr(<`BmCuA7mHj(siOVtcTv`G}X&?MCW9?kojJ%- zxhk{Ij#oY~m7@JQF=%a1pEQ16p1nX(!rr45&Rsi6a|qr{*x>XXHTNuez0YxTGbORp z)Z5jF4UfeaWj48{ZgW@oPa z4|Ml7w0O&BuA%JS!DYJbvUD8OaVR@dc$TDa*mY0^$DeF4hy5e|-wx{2Ko7 z`#+8UYykaj3IG(mfqi;6zofnYZ3q2n`lojOU#9SXnf|Gy{|WG?koXS(H`2e$ihm;f zsrmhbF#0#bzbb(Lp!|D|{0C)|;J1wVkKw=Pf`1G-pA^U^^8Sy^@F&inq3{omAMw8i XhO!(2;;%`_PhYsFMAY{y69N7QL`5>h diff --git a/docs/功能模块清单.xlsx b/docs/功能模块清单.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..aa94e3de785141cbb0c4051ca6bbb9b7de21dc21 GIT binary patch literal 7706 zcma)B1yEaCyAAH{Zo!HKcXvqfLU9NXptwu%(&7$93N;F)P$=%2LQ8|YTXA=Xz@xqM zzx&_LeKRkaeRlSo{jKb?&i=kVvz87R1(g&40AK<@VGqq;nY>J#y&v%*0RRN|y}vWa z&&|`x+Re?0*WblCVW2{*gO3n>@o|MKyB15V4rw6{4wswNj+c$_nX(K;GYdITrSmx< zO;Y>U<@_anLbRcpna#Tb%LSj$#3~*UG1AItamDE2L&>KerB*p}aPg9>FV%JFA`?n9 zx?d?9c(5aL3k@|yP|*9xG%3N9bOrAOAE%F}7CxUo3d`!?$Z3>GlfTB44ZKdj0}=Vi zp&fT@Pt2|FAzVTN=7VPklC&Am5CsP1fD{uqGBfd}QO(lV=G1|X)EJ?~-tn=y$LOFR z&M4n+dNjngb~uj~+0mJlY2X z#$eYOKEjsR_Fxupt+Qw}aVG=voNXF+V*ylytvyL@=u2)e!JyCnApJWBH~n`F%JPWi zpk1SwbR`o9F~Wsr)jpl%wGE)%s1byU2xe|Y=N%T_z`phzgV*T6T?b({O~pX2s&q*L~>NGIry?0H|YDLzL@ z*ueS1+(82VVs=pB&I;pAAj{(|=eEk4h7olXImH86HGm;ixhaMb1sB^l_W~&65+?m$G{sA7pNb zXunDvHlY-{`E7U9S-D9}vPlcDXfP9L#1&J0G*ILin;_(T!5Z~kkEIGU&g6?kK}!)m z)kmO(4Ky*WtvYR1(-F`PKwFly&6!x+$69!UpR3y@6D0QPj7=-yf^d4-Blto;5SzX5 z^rtr42oddU(pxHZwt?rlHpVNJXc~pJ9A^e5%&Dkxy^ok5CS>bql#jk4A;nB|AQz9~ z=moxt;*_4)FCVy7`7tSakt+XDrq6s3DU!El7MN~lhy4<7SkB;wloDFY%nX^#S&vu} za``^897Jux9xT#9%vJ{FkvLU=eP&U3B2LX}1;4Ps9yfiT9IwrZCDs95dp8T=eh$oh z#?_J(n8n3?42(heR$4-eqU%cOuZh&ny%eMfUbtjD+PBp-Y29tu&%k|&SA4Ai9}MOq z-I{*)swbo_cc;0=LPl6%dw~fS14xeMMk1tOj^hj$HO*pw7 ziSIw4Hdk=bsZApGoX!bC_>m>>z8B$`QN=o)$q9=1&#>Y7gF94o&P^>7HblB zS~OI-f1XahEH@@RLF~!4F2c z1VVcC#Mf>6jIPM>%R;_87+~vxDdqL@uM>6GxejH~9%h4tS&z{N2@_%|M}$yKQ-Y3R zyM4mBak#5$ANW+6jBIs+?hv@u!CD+|I|ywRoYxxrKj5w=TawWzor|kuXDGtTurs#! zBP1V=Yw8Cqq)qp%8Tbc%fumqXb&M(9+})&XUN_pfG|fan6O~@BlVNgA6fv^ z;bz>JIN?mPawBeEPDNs)W2{)+S!Fe?=!n{k1dk_iR+W2KV#rx_8Frbq`V6^JkYK03W?YN&wiQ49IXeVkbzNmY>*0V zmTElM>Kp}FOx^e)Z0e}LDgyzC>BjsBvZ>#`Y@KO0eW;hrfN>bUO+Bv|v2WEl>}Y`e z;Az4+m%96N+lAEwH<%$BS!K75RdGxmo`%&U5e}e#U-?<|$U`-HG1UV)xy~fy6D0e@ z%_ihT9R?01Y?74FsINMgTA z{DC)vLqm8zlk8i`ex8+t3peG-i=PP1ISF8oB33zn_BuAL5&=1QaSYivUY>HZ;@j!_ z*#`SMX>;%oSjE3;{+^y%WVhXTfU9hn?|%znmFHv1pDhNH8Z*owy+>r%a$UBp?cMfp zi^Rua{HVfa2i2@|88{$!Tq09{p3UwV+USs0Qh&zGUoou7(D12>w)w``Tb#2NxuTGJ z{)tm1G}MX~xl51^#eshJp=$K|610sGu_Jp3ibB7U*UcCJD*q0z}hrDwYOY6AJopVcdO^nY4VnXy-A4o zRy1_03k27I{fZO{sPlpLMVM1c4gL&@^D>ca+e(npr{#U{J8 zC7sU+joNi{TF6~rhFLt7Ai3oFQxYlXszVbgBRmRTWpMU?|&;B_EEQ+K}J91C+ z?s5Ufo=az3TD3Qd+39&CMVNuS9Fj^ZYD z?~AnCU=O`1rbxT^R4h#3p*1ceQnU8)Ksc9daOtCrUnD`S151*VZhCJGDHZ$D#z}-B z_x37Xa40>TYiqsIVt#zd6C9JKa*L!{^PuHY@IlL|-~+mAs+aS(_wADEW#V0%;e+Aq zF=1H#mjK{U#s#Qa$rIdaDYa)Qm3ol5NHN$i^9ZCSBVUTZ;~BOXebI*MeTBrnwD2`f zrPbM)V$?)qAvBI$W@*C5EM2$Yi}LyD60Gh3?7*$jro4VUIGMrxxdzzXJR)m<;z*|q zR#OkfQ3c9Qi{FqZF5Zx9d>mSOFBF7FUt?#|!~LnIZA=BFr;x?6Lv`URQSjYM`nz|* z&P%gIxVaWG6aC#Bhp$9mmN~S!nGb~|-M59-JVdLP^0jox*j4Zo50(Zuj>XiVmdY+B z2@U#z7Z7SIUNTx6N_o5FRez(7-gq79*C!fe6nf=*eo2yU(_Yy6FOg%rO>EwZ+xzo; z8r5aNQAhWB{ym^ZS1fG$#D$ECGoKl$;m9*VRsoL+v( zQ5WvVH2w*dqlO41M8#2s81{MO+1MzhO&d3eE(d8*uG^%kSqt=^N5mf=Qr0D9umFMx zzI!z5?xOXFd{;F9GQ?g@g%cSY(nzZKlfiMOJ@lOn50|vS#vLz(fecG&(mZ5>*aFyG z&nAL3(`P48)4#OK)uszO#KQ|8BDXC-^VsGRaRZN-`1x46Ql=bgjbT)kbgJjZ>f~)j?2(#6#4X6^mNO#X2^l_ zFrzZE-{SqUD92R__m{&=heJ7p+4m_4B%y9hV;6C@kv=6t-}~G}dif zs6D=ZArgT^X6-CdqI{K}6dq2aO3&eQ$!^}-EafcIy?T^{NFXPu-eJoSYpfbgwKs?z(OFVMq2sR!e*LD|sCaZi>MVuzj z63p+=nJ7>Hap5BR!-l{X>Yc7ge#gTb)lkAaT`k!W@Kc{QRi({M)sGz-`XGiH(#tye z_L+F}6R+HbZ`(PJrQzfAi^Jm6;y|8!c@!5B9cQF?FNzsbDWK_%Yv##Cel%26?wMnJYO`*xu zE?rR)?Z2Lc$VN zn?OY%&0FWG$@%(svVf#8{5gCIL{rag>Rr-aSJ@HQkEpvUNw7$*D6%Wky0+YBf_l?f zXHpqTF3IoHSfj;{MKzgZ8_Qm13&32e$`YwID*DpDArV~K*nL@+3XKb&vk`ST?TQRZ zWw}y!R^S&65DgeVJ4q8;5tmk`qVgoE793cm?=mlaQKL8BTOhDM*#BTE3BKQJ%O0xW z3y%?#qW+0H&yo5vFL(drn~Iij^3)5l$PP^rns>gN*1LY$T;p^WHcd-J?1YEtFnPSbs zSEL;)1@~L4DUef$?X6YUkxC*)IHyBnxQtJ4SjWo!5)QaX!4W2!E49;kCV@{%Y)xW! zP8){$A?+BIwG*m(U9k4S2|l<+v`b*sPJtU-%_58gnSo~J*s&@MyiPBgKKObkzCI@M zOQ?iF4%*x6S7$Pbc^KbkVXQDI2zI0gRavIhrhyYDPhH;X6jhx#Y2riyljJMB7%Ax_ z$*o;sEOa_8;^by%$xd}TYS>iD*N#}tNlzd*2^X90)Ud$7?3+)tL?pC*(FH39p0S#v zZ}xz74Y>zFaGZASRHiYU3!8pi?U?#(ySigfHE~E-I6sg~d#zfJoO{fU^h%}kBeCw6 z1H--_k?O64V9&JO+ZBifgJk+qEQ+()_kh;dLQ(kbR)syA6C8-Ik@AVpt*yV=5}H0( z%bpR8F3b-3@!4J-)Bl}y1y!d|B2tc30;q~kI$X>pNy zt~L=>h8OZOlaV7BPGHHBQ*)-P1Sl!(MS$lOSr$#siI_Cp-hbl}d3!aI)qpbIR|xTF zXUC6^MWOF6-l&>^4PBu2bi=h1*?P6GC%+jhLlL?Z(Ofjcw7drruYREAfMy_(4sJp|>)i^*EdoH?}2R>D8aJh9>plCDx&_Xmu9V0a%MpktIyOZwnYJ)QZZXqheQ+URt#0Uc!0I;Ca2N zCV?@-WgcI!ixN!OjR}iKPi>wytMm|#RgrbqiN%&!PtB5*a|%vdCYO`FVLcOHgR0u4 zzZ30tiIFSGQnGUDmhv)XuBHPKLCjSWq$~Yyg$67if3M$*5wpmwSrgc(qi1gDl3DVh zDr*yj*7(K}hefb|awIh3#{`4sN=EuCd9 z%ZMNA8+l^Dn!m?m5dEOblN3#kHzMkSx`Mjz3uB+AymmX#c=wf7+*+wi+@&yFK%g7A z_%O|CQcLiMlUU=%Qx_=N+$Q#AH5Y^_NeH^BC9`LYrA8p!*|BXUiAS}R&y-x0>UJkRWQ5;2lPSHHAJKiO8&8B^bBye?roLJmoT zM2x@H=-G{;b4V2&q}{COjK!ihOhvBr85w=$ z?9UrZE>q!gd=`~6fiHUI7&4WVki)W?Zp06IQ}I&&O@p+n6cR!D-h91BkYqP8trHuy zO6wIut4#9Kk*VKI39M+N&ncP5Q?WnU3&?uEVoztz6nobs+Q;nrIwkMN^~74^Q+rYU z;@1POFA$g3wg{5f2aeG{6Px*6~%9p3S{50VDB-QRm1= zAz~d50sOgEu{xhHGtHDzFS@CQ(l0-KL(8Ca!g1Vf@$qQB)=6elFd$>(QI-fI-r%*XdNC*TI0>lpLYL|qNiGtMQhD<&siY;VS3|g z{?l45{1zFne5%9`wZlsGsQMh?)|y5R#F$}Go~3=H^Rr`IGc5;u+BVBIeZFWOY!yQc zyyeQtM*Kv!U}q^Wxj`eAJ&g3AZ0QE;5eOIC-9R@`SGB_Knr(x6T73L-lL~jy zV^5&<(D`L`lS}R7X79RB`L#uz7~^FpQVdV3Id7OImOd%dnluMej!)V7lI;fxMX+(p z4Np&#E0WvT;JP?$nWUFSMVCt15WZ=+Mu+M+nAqDi+zJ`x_!)q9?Kqn9T3n&v!_RMLu3C*kyFABVY^7qm0n*Aq8aX=Z+2K4wO!tBsrOz5F4~?Bi|6 zBgQN%Bc%s*hIrr4@N#(K?!|Z?;_4;M?BMO~E&&30+1$&F5HDUgcc|_tM|VEgZ?># zneh?C(;n(A%?$d(8DVBrx0PnrgNQ%{Z0+oX#e~G{goFg`m>EGbQlNi4ugrf4{mXG7 zjn3dByeISM-fW=$OP%?P)BpBee)C(Cs`<-&Q7N+y(B3h^iqRc$ZG@OUMVg@R(nXL= z@#fpxz*6dw^BB_^$(<{+zljcypRP>rZthfpBt^J!xCi>xGc zWh^&Ql-2aWorq?!gG&NF^-D)4h&O3P)GS0`YQH2a_uGc9&&6PLhW-QXT$Iu+{TL!+ zO%59O@{D8G#1tefbmf*peW`MUSBd7txX0>?D|0U4j2iU6y^W60Pnf{{mBpi)%~&U z{|x@qHv20S0LTx=x_>vn1kwLB(f$nm({cE(P}JX{fBFx90{khz{tY0F@$X{np9p^{ zrGF!g{)O_U;nFM{vG)5rQq*CPpaP!@P8DBKXLv{g}-qc?<>r| XB!&(c4gJ?7%=<6OeH}ug{Wba@c%aRq literal 0 HcmV?d00001 diff --git a/docs/问题与解决方案对照表_v2.xlsx b/docs/问题与解决方案对照表_v2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3283b8aa582a7f65f144c9e52a456bdd97b564c7 GIT binary patch literal 12918 zcmeHt1zTK8wszwXoF)W!cb7nLclY4#4vkxIch}$$+&yS;g1ZNI_scnV<~wsT^ZkN5 zyPv(Qdw0F7dRHx}Uh9>Yf`CK^Km%X_000rd;3U&b2Mhp+fdl}609bG>AzK?KV;d)3 zWsse*<7av|Yb(M$NN~y=0Qg(~{~rH`XP`V`Shj}|MdVieNobo+a=C^dmhISo0F7FK zx2re4ztm7G%iR1mEA$aXF!#L$M>X2`vIq0|s9BATb%TFMf2$%wxZhxxiVhA3L*Kv= zS?33=I0sFQ(;RdhekLqk-AI#kK#F~1k0Pt^hJa)_w%L0OUf-?E#Q{~+wf<&=Qq5wn z!KLnXS=Cik&Y_Yil&wkNw1qj9htA~|odje!vBxroi%x+&k-8F3udfgQ`M;TEy$U1A)tjzKzgZppn_22Q7+X0q(EoA$uQ~o7w!wdS^zt|v zxgJJ@pi}Xu;KAFOl^7I3DOUmURw89@Ux{Vp`pA4zoRv-rToh&O00=RkcJHU5r4^pY zgF&L3P1f>oG<0r~2G@$9by-t!uGEh; z649x1;cAp|dNph~U;%Cr#-~(2&3+k;RfD^4U^9YB#}z?UjT|`#@#E<}b4i8!hEI&v7QfSf_gdNddddRe)xwI6tBKTsDC$-h#IHm&Nl-AzL^m6+swFGF}T_~ zSQ^;cTK*BYzABH~PBEf)keu_WcbJAglGIK946P_j>6P!(YgJBL?zJu_bE(R#pQ)qS z>_u$?hq2La>UxC$J~Ft;CfR&uFdRnaW*C2XYQN>}9IXH)_4I^L*La%}lY%M_3cBleW^^Xc7mxdj{S?)t=%gMgI87+$AqAu zr(eu1QqZQW@Td^PkHa2>j8zOyNzsHObZR)D)DXsy#9_bt#fpOA9TKs@Q7dC_dNbDS zT0z3ZMFu(L8l{~m2^5VCz3Sy64vSVtT9nwi`M=G^%}+pHcjrkRegQ9jW{<4kC14c3H(9` z8m8@~iFkOQ52hW3r;6FkL76B6EKX!WE0|#5^X`1Q*uNdW_389_zPuUe3)cORy(UoG z@LWH_B}!eTRob4Cz*eC^oF*#t<;dtm(u3=v_v$0ef6{e7-h|}KHyv+z(|2qDEZCc_ z|8GqASB?K?d;ojfuDw~z|Lvn(QAVnl5v3jeC797Q-Q_*%f)fMrk;)-5?2kICC2}Gb z@2fQ&>Xy&yvr-HYwn3oN(E*oRHl$4m;B_}maTpq;JJ9Ns8_IU{aufpA;J5?R+ZBYC&9-ds?Mbg+Tp8HlkFb^Giz@v{qOG4 z+*v76;Z2$JDF6V>w;um+kB(->#!ik5e>O~i_{oek>*#!T^e>%i2REtB3d6EH=h?&uN3$8(x}TKH{I?1?~ie+s{_HOovl7JL*p~_GKctyF>3I( zYI6hkdh%qgS14-Fn<>Aph8rl6?PYX_cLkhjr|wF)_NoXVdp3;hLTZ<%$VQ}+nZu6R zrG7fcw&DJ4oBfio&--ovsqC{$xoiO*TJk26{r&yU9T63h81*L8lIeAFp?syQ_%FUs zyCPV8CnYPVFO;(Gr)1CYd#`88GPT(=PJ?kZ#bMSfMR70>8K6tkfCWO?F|zX;?)O{K z(8dpGB}i@P?>(2O?zbE@x(lk#aNyH3;FTN_FO!eVH=em!U%hfFDqj#7d}KSHcRwxj z8Bh0=sSKaMYiB#$<420I!ckRC=oxdUsd(OPiBtNHM8i3-}S4d|$LPuqYTSO6x?@bZKnWnq4uLgF>%hn_a4q6=3z05AH zjht~b)Qa-x%NYmEFN`>{X)wpe^Rttgn^s7SF{Dc2XbxIoN&`BPx;q_{ zUVCJ(Z!K{TX9*E$p(O``?m)~99!YPtqFxGi3#JqzE}aY$MnF$N@4A~gCIwsIk&_A{ zo-8e!-JmknQxXTOK|MN*jFNOZ)ZRwI^B$n4S1^Ctt zo~M(&PaVO{n}kVkWROwT0#^T`6zC*q$*m6=r1OxWZ(-JJLFMLc9afEb_9SZ1l@hC; zF};ULkTSPoA9*KF^)-Uwk za`2v=^;D;@DOIH?GMF(fvbRT=+aVPjkj6f1N&L0!`H!hDS$+b-9!9N zm*$CqL^6Sl1)(S%x{N4@G!@Cw$z`i9VJKQX8b0j26*>x91|2twNvhSXrv)WTT8SIA zDzWd*Qe%PjE_v*-37P>t;~maAn$+#uqLnM0Uw0g9s|pXu`lPRnMqcMVXC!VueqDAgEFlfRTo0Tpeka6(OSYB zeeU=mrOj&!npv7MAkqX*Q=BHb@5o-JCZV!<>^p#HEi^M69~$xttf)Q(5Zp+_SSw5y zF<4Yjfb5rhUjJhl7PO$lPKE#g<`MtMu>Bc^oy?4_jT!#D{uzPm)Rr958Bx4WrC5DBpjlB#X3J346& zXluSsMoGfub&&TgfpW*z@`aS_ei9zpYb}-5;tn5bW+Ew>F*YFqr|*VF;sSQ~JMDy_ zMg@S-7xFwq^T{~?h#vY*lvG!(**Uog>x0PzdVb>^$<6FXHxo=+hlg+1TP`z_*?k{L z)7-1tkOL4T_4|cSXGtlxvpUf{2aL4UB`(~c1XCFo8m!~3nD4ZfSnSNdEPkRYV=EY0 zOhC|7gVw!x$16{^Ph6zU*1_jTt_DN4v}uY(Q+*h=s^$=Mr*jfv$%Wgb!T?8{*fzyU zs7JxggmuF`i4CC@1Vntnqu3^ry&*<9%aX^vCibL3U)M~3Z$=Lvy1|G~C0VZ?2pc7m zh2Dz&<0+vx=)3Zoxn9&2`Au(1R*ZU5F6Nx`bQH>-cY6nGbL?Wn2`^E78bN#x2ey9v z_W*DplZE0`#YJ?Xs$H+ZF0k|xkUNV+kB)!!^(C^7|HR1?0O46$iABtWS&>+o~p5+4!<_b}bSj2FgfIopD&4@%v= z+ooV$1*a+Q1&t6ggI%8mY_5l`JRF#Y|UqY+_*0saj zYT3S{`%}NG7jzaex%r5=fL}mhN$33-Aq#jx6uHAH&gy&JMHKMvNCr3V-G!|J{+~g zj=u6?j}Lay@~X-x8u3TBn-v(?&%7>aKO6JVnoz|&{z)Y+5trlAvbkAh4EO7?2%?`vX5W-&1LPiBD6 z{B%+Hn&^_vt@$HeW_oXomnxK#MxB@*FlQtoNZcmnYKA0(UN7$Ji5+*Gnp8{X@k377 zK_==ngf*M@2AC8o7(P!8)RQj4$9n9OI!02!tZ{ZcGNtTOdBwJuCx{Oy)Yc-3c;-T| zn!Va_>ZRPm)*XTPRPM7RfBNG6{BoR4;Qe~vquV)@3Rw{Nt;{+j6H(k1!qODNmhx3@ zK~D;t>^hZfzF4feI7zrpO}AeZCpLH5+jeSW12X=PzuBJ4y{5zKlYgfjH11j#w; zEY~1aC*0<1rrJ*V>2@P|Q@Y_}lR3%Q0K5D1FrTf@3@XnG0R%#UU=D6R9llMQW1kmrR+mrtx-mCKC|Si5A@gzaj?YfmY0Lm_^vwkLVQO z{u~tH1#R$2JLRP%V|g-TA>%1!*Q zP07kqfDGX|?{&(~z5=upB_&XTca*_i{SbKjGs6u}m~H0{{4}3WM~~1Lto~-!?M|m% zzoQaYC(9zU$t)8xf>*Tj>0Hvt)ovKQ7>FdWF{tt~Ra9ubo+2^>1F=;#nd>SL@agUf zOmjzSFeY=kJ+YZ%UpZcv-|waVX|d?+OF1)fO7nL9d%yCRe3e&(CXWk)`ec)n#0M0s zut4mKm2{5N*SiuwNU^L+*!QGMUzgd7AV!<4q~KNZ;X#-=VNr;hY5iaLl~r71)BZI>8~pp42C(P|dM!FjVbzEyC7nVZIU+kJ$`+xnTNjctSM z(La$cjX2((C0(>J;ZT04mPryyfh%4NL?1k8;HIX;)jHFhPkWN##@Nf0712#h=5HPL z+PkWt8E=TFGF=E-5=-tk?%c)7HRYb_;7$eQ&TX7zy4mTcHhjkO6Tes$k3m<8V{5RW zoyLPA5x4~-8tF$f@!(;5`S73q*t59NayBCY0F|Tw0Qx^Xk(s`Ov5}IKgSn0A|2ibi z6r+q@bWba$_!;Cs`bM?lJS@vFwyFd9AA>@XJA|Qju)tW#(zdMSJDT^_|#O za%_=dbXEk?ZJ&+EV?^wvZMNsF+12AlMW<2j z*K_3C{{DW%ay&eolSh5er=m__V#Bo;#*4d+`3efY$cW8hzAPTECp%mp-e+Fso2jYT z;I+tSMrP)hy8&??Y`h2_?>n=)@w=NJIVDcc80DJ|F!1G7bmsRoB7oXqt3ATFsq*9Yw}{KIpRGc_E7J!OC3BqCIMA9lo0@NjMG&z{Kzor$rbz zUFRbhpK8ObH9SwR_8U&^GefY+F+ng0atWFZQjuH79^^aA3{afh9Vfi{v#%`h|0lHzR&4% zX$9KypTio)b1vDh%5;m(}3cohb>_5ooQmIKl!aRE16ElcBKBwrbA0dKyacaVFAdSoke>j3+b+1`lb zwpRfo?Z(#^KfGl%qd}AV<;xjapxlk|sT&Hs$kvS>`Uc2@QIv=@8Ck>{OZ3AO%qEW| z2ZNEpxD0KW5e9H3VK{-y?(pPuarm8~JW6U>ipIXI-D>7SL0ZNdQ%5f;qQwZ728bfg z+IeJV-lw|r-D;OABc*8W*jdgZbga&W%ZCMxjM>uhFhwP0RSz3fCq7)Lyg|xe5$y^6 zemQKBbuK6%U$9tEkesTaY=WfzXBgJk5nDnmaMo??O*$yXUv^)e`o+=lny?~?TJ9bq zDJM>4*T-|ynMU_>1GS|FcSs(P_+#a%ndsn2T zK(#Bv>b^B}*$cMT7KVt20&eHoB^`mba>E`lDY^-3310%6>{eA*zjG-66jwyQO80owOc zK-3|MjgB-y0<=_G1I@jlQijyhZ=b_sFcpHyUwUWs8N z!aL9cV>0hJkCvkJtA+85g_{N+CYkp`Q1WIPDs6Y0X~~Xoz9o+AF&P&orW}BbQP(lF z;zJdDWpDr3Dls#+0#~9C6-+7NIR|?Tz}(Uo?$IJzMx~eQgMGf&nXSA@QJ!H1uI zqqcHw@%5_elC;?Ppb6Wi0xa`iy;n%uzJt9W)o<-VL_eU?K*DIT5z->%6tNA$^mNFs z?%I5Eg7}vOlos(Q@Q*QHfaj8%6)?uWkVELHS^RXwCb!2wg2Wr#zV9M^bK0=BGYaW} zrcE%QCRFBb?LS|j5OL8U9m^%G+r#%%VeI3G&RC>phNvcO)}W9o%YzWobRy!?`Aqr~ zaT&_?J1!~)oJ6(elXbUW!4j&Phx!NGM83%u%AS=s~YRbaMXWIkI+=W%QH>CKvE2ifO zRoK-qT}THz);_$TKVAORb_ypy*&ENkMEtf8g^^P`BW&V6%56FOQygkc!f6*5YOC6$ zg8#E!>SC0npsKT8v0B zlu^B(`@Dg`ukK|jf$E?*%P#5O9|>-<>699vY+WI>V;5Ez2{6x?UGzcY_<5ZI)S1`3 zaF*oiT4^>nqzK8c{5#?ecoe!59CSRKm8v*-`=J5ocO0lZeFR1ye|U;~Wxh$bNQRog z<%o@QG^^g1MG@@qB|NTj^+*pgZ&%@S0#1}sO6iT7Gx3qs*xp<4gu!2@&sfR@S796* zp5xAm63d(B6AiuO!pa66&6L+HR}tGsPB2C9uCUg(l2xI?01J4b6M1Y5&{p?@NtUa{ zoQBaCd#O5ups}`%p+oQ3#ubvCj4NEA+wZZ>^n*?Fb^UMHZ=)i*_Yfgw!8`7Fa0by> z!LA`%BR?J_m96HefxCQkV3DBDr}VgG_t3{7B%e=x6h4pun+o{hYr-Nv74{TU@S&-X-{We{rVF6|Q=^Pn8K!*go-20#5r)JXf=4e$?LJPw&c1w= z+U3y8dWd7rf>~_M4ppS{OwRIzH@!l7X**}{y(s!HZ73Y8^@M2A18SgfibU#yf$_F? zvskt4IIy(6H;EK6!7OXkd}}c|ys01YoK+CXb%M!2;oS%NpBuB;4{}u>9#S>Yi~Up5 zK7WQ=c2$*l3M+QHjnFwB32vViqLpK87COC6ZWH z?SSpDN8X9?dC%xkU@QM%%dMeZq+1%hrCcyj|4 zyjwcZnZxO>u0Y&^my9;Id4jRJ8`q89nhpF`+X#ORD|cPNgHhP=Wf9E95;{5#I-y$W zwAY2MoNN`N)4R*vBBv0QJ|(vAQi9V%RNX$oUe+3h9qcmBa!)YxhCQ594h8#z?EN!K zlk_Kcg-VF}j;5|HO}LJL5BLqtncW;_$Ruaa;Y09kwqaWm@)C5>C9G1<)9a7aokw|A zqK)%5sms=9woo?;a?RZoI@*G;XYy2+&9L|2>m>)My<;?fT#gC$l9jYQybmr3)+3iKrAyIa*;ofAQYh(ovz&%tZ_GyzN@c`Q@^l9L+nLYww%vBGwx=#3 zE$X%G17OoMx-6N;RZq{&{hu~@A_AWJ+WDxHRHkGKbwanuP6i%N?X+{xkRdW8VGtE# zb^39uKA5_KKLoKM%8CRd(Z&}%uFMR7ky(cY6KlLR``C;()a8rzLXcd9+9?s*fi!6| zLkir}O-tY2p{`x5fD*}_a?{9_D>WbX4=t=bhPfs(p7sm`hVTW{ArGueFG^LUW%MEAw8;`S zu2@Zra;e+lKbyB!3ES&sh5e%U2P6b8Qi2YqH)M-zMmdh2xhAJq0mc3-<7Z3ouN2rv z21hpNmP42;94cYHJ#DTwPVq)%ID0!$-)Ij;wubT! zwswvThPDpI{~)LSm+kPD1F(u+k?LVY?^}a<4UW5If9_%uT=E;j@AaDq>uHQxcJ3xe zl&CxOv0a?`$cNvf8u+KVCA&!Q!{W`lV{~*g zKsfu(waOT)ex@JAlsTiUFCveG#&;*bs4;W@YGbjOY0=f(Hn+WquVZ{N0c^&$bzm9& z=kKKK5|qTwVG*tcg<0Zip-R?dxWPDkCj23HXl3&teLV0m&0q0S65xx_Z6;m{xlYK^ zYipaDqm%i*X;#OPslNcLV^PctbF`{$Zu>9G=uh zxY9RKxsV~fNw{_&<8*3TAKk-;SY_4p(Sjv32e2e;#T&dw&u#g+D(>&acHSru|3SMP zIE>9I-ZTs0tvrMBuiB+=XZK&_dYiI;92s%dvbanrL672~;6#rx4^qL&AC~9>#9_i2 zPB87~W2+kH3p5_;D?4EFr3n&YtiE~jlk)swZHRNLjNv$s9khd-TPH4DAfL!(eIg8>L^|voIla1o}c72x8EiScR!0 zaYnnpV|njE%B}d0z~o&CBJd>qyV?BBgB>)|y?dICT?$DP?nS+W`Jm>2z?WCcG>^}h zeb484qf~h?KfpD=^KC^}{2KbFCn| z31AFhl<^kjQ&9?v?d1JL3F6Fn6|D|wx#UqTG9px{F?V9B4(c1sEwn@tE`D7MT%Vr0 z4w(AKua2eFv@TYRN-fx86svzON7O_@Q-vFVrt4iXCE*E4wcjhmV`tu$CNP*(E!con1Q%&hW>l{{eOW5fcCa literal 0 HcmV?d00001 -- 2.27.0 From 4552f2e7038e73988ca4149d5316e4cde356206e Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 09:26:39 +0800 Subject: [PATCH 018/100] =?UTF-8?q?docs(plan):=20M2=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=8E=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=EF=BC=88=E8=A1=A8=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E8=AF=84=E5=AE=A1=E5=AE=9A=E7=A8=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- ...26-10-13-M2-基础数据与业务数据.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md diff --git a/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md b/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md new file mode 100644 index 0000000..9434353 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-M2-基础数据与业务数据.md @@ -0,0 +1,106 @@ +# M2 实施计划:基础数据管理(②)+ 业务数据管理(③) + +## 背景 + +- 需求 28 项裁决已落定(2026-10-12),M1(系统设置与参数体系)已完成并推送 `feat/system-params`。按剩余开发计划,M2 = ②基础数据 + ③业务数据 **合并交付**,一次表结构评审定稿 T+ 预留列与报表字段清单(即本计划 A 节)。 +- 目标:三类基础数据(人员/产品/客户)+ 四类业务数据(业务/发货/回款/评级)全流程可维护、可留痕,为 M3 绩效核算供数。 +- 设计调研已完成并抽查核对(`models/base.py` 命名约定、`formula.py` 纯函数、`test_models.py` 白名单、迁移现状 V3 均与设计一致)。 + +## 开工前处置 + +1. 当前 `feat/system-params` 工作区有未提交内容(2 个已删除 docs 文件、若干未跟踪的测试/文档)——先提交或与用户确认处置。 +2. 从 `feat/system-params` 切出 `feat/base-business-data`(M2 外键依赖 M1 的 `business_roles`/参数体系)。 + +## A. 表结构(V4 基础数据 6 表 + V5 业务数据 7 表) + +公共约定(照 `models/base.py` 现状):表名小写蛇形复数;`NAMING_CONVENTION` 自动产出 `uk_/idx_/fk_`;`mysql_table_args("中文表注释")`;可编辑表继承 `TimestampMixin`;**append-only 留痕表不继承**(仅手写 `create_time`,`login_logs` 先例)。金额 `DECIMAL(14,2)`、单价 `(14,4)`,schema 入出参一律 `str`,服务层 `Decimal`/`Fraction`。 + +### T+ 预留四列(覆盖 6 张主表:personnel/products/customers/businesses/shipments/receipts) + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `source_no` | VARCHAR(64) NULL | T+ 原始单据号,普通索引(幂等去重锚点;唯一键待 IT 定键后小迁移升级) | +| `t_plus_id` | VARCHAR(64) NULL | T+ 内部标识 | +| `sync_batch_no` | VARCHAR(32) NULL | 同步批次号(M4 写入) | +| `synced_at` | DATETIME NULL | 最近同步时间 | + +全部可空、不设唯一键(对照表 #26 键语义未定)。`personnel_plans` 仅 `source_no` 单列;明细行表不加(随主表同步)。**「同步不覆盖人工字段」写入表注释**:`business_items.guided_price`、各归属列、`personnel.business_role_id`/`salary_standard`、`customers.customer_type`、`businesses.status`。 + +### V4 六表 + +1. **`personnel`** 人员表:`personnel_name`、`business_role_id`(FK→business_roles RESTRICT,同步不覆盖)、`manager_id`(FK→personnel 自引用,NULL=经理本人/未分配,支撑「本团队业务员」)、`phone`、`hire_date`、`is_probation`(试用期照算不发放,裁决 #17)、`salary_standard DECIMAL(12,2)`、`status`(1在职 0离职)、T+4 列、`remark`。不与 `users` 关联(一期营销人员不登录)。 +2. **`personnel_regions`** 产品辐射区域:`personnel_id` FK、`region_name`、`remark`(1:N 子表,随人员维护)。 +3. **`personnel_plans`** 市场拓展计划:`personnel_id` FK、`plan_period`、`plan_content`、`target_desc`、`source_no`。 +4. **`products`** 产品表:`product_name`、`spec_model NOT NULL DEFAULT ''`(空串参与唯一键,对齐 MySQL 组合索引 NULL 不去重先例)、`unit`、`stock_quantity`(T+ 语义待确认,原样存储)、`sale_price`、`product_category`(`municipal_water`/`rail_transit`,**与 M1 `product.threshold` 参数维度同一值域常量 `PRODUCT_CATEGORIES`**,阈值由 M1 参数承担不建列)、`status`、T+4 列、`remark`。`uk_products_name_spec(product_name, spec_model)`。 +5. **`customers`** 客户表:`company_name`、`owner_name`(客户负责人**人名字符串**,非系统人员引用)、`contact_person`、`contact_phone`、`address`、`industry`、`customer_type`(`personal` 个人户 / `manager_personal` 经理个人户 / `enterprise` 企业户,计提规则匹配锚点,同步不覆盖)、`owner_personnel_id` FK(归属,变更不回溯,裁决 #19)、T+4 列、`remark`。`uk_customers_company_owner(company_name, owner_name)`。 +6. **`ownership_change_logs`** 归属变更留痕(**append-only**):`owner_type`(1客户 2业务)、`customer_id`/`business_id` 可空 FK、`old/new_owner_personnel_id`、`old/new_assistant_personnel_id`(冗余固化防人员删除后不可读)、`change_reason`、`operator_id`(FK→users SET NULL)、`operator_name`、`create_time`。独立表而非 JSON:可分页/join 人员/导出,与 M1 `param_values` 同一哲学。 + +### V5 七表 + +7. **`businesses`** 业务表:`business_no`(自动生成 `YW{YYYYMMDD}-{4位序号}`,即报表「合同编号」,uk)、`customer_id` FK、`business_date`、`status`(1执行中 2已完成 3已终止,终态不可变更)、`owner_personnel_id`(可空=待指定,M3 核算前置校验非空)、`assistant_personnel_id`(一期核算不使用,裁决 #15 剔除协助 25%,仅记录事实)、`is_special`/`special_note`、**五个汇总冗余列** `total_sale_amount`/`total_cost_amount`/`total_ship_amount`/`total_received_amount`/`total_freight_amount`(服务层事务内单点重算,禁手工直改;运费不计毛利润口径,裁决 #2)、T+4 列、`remark`。 +8. **`business_items`** 业务产品明细:`business_id` FK、`product_id` 可空 FK(可空=自由录入,配合「数据提取」)、`product_name`/`spec_model`/`unit` 冗余固化(防产品改名影响历史)、`product_category`(**行级折算锚点**,混合业务按产品逐条算,裁决 #14)、`quantity`、`sale_price`、`sale_amount`(服务层按 数量×单价 固化)、`cost_amount`(可空,来源待 T+ 确认)、`guided_price`(人工录入,同步不覆盖)、`remark`。**不建** `profit_amount`/`profit_rate`/`converted_profit` 落库列——派生值落库会在成本调整/参数改版后漂移,M2 以只读毛利润视图出数,M3 台账才是固化点。 +9. **`business_status_logs`** 状态流转留痕(**append-only**):`business_id` FK、`old_status`、`new_status`、`change_reason`、`operator_id`/`operator_name`、`create_time`。 +10. **`shipments`** 发货单:`shipment_no`(`SH{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`ship_date`(**回款天数基准日,发货日首日=第 1 天**,裁决 #1)、`freight_amount`(仅记录展示)、`total_amount`(明细汇总冗余)、T+4 列、`remark`。 +11. **`shipment_items`** 发货明细:`shipment_id` FK、`product_id` 可空、`product_name`/`spec_model`/`unit` 冗余、`quantity`、`sale_price`、`amount`。 +12. **`receipts`** 回款表:`receipt_batch_no`(`HK{YYYYMMDD}-{序号}`,uk)、`business_id` FK、`shipment_id` **可空** FK(**手动指定发货批次**,裁决 #18;须与 business_id 同属一个业务,服务层强校验;指定后回款天数方可计算)、`receipt_date`(M3 按此日匹配参数版本)、`amount`(≥0,一期禁负数)、`is_barter`(以物抵顶标记,M3 按 80% 折算,M2 仅标记)、`settlement_status`(0未核算 1已核算,**M3 防重锚点**,预置避免 M3 反向迁移)、T+4 列、`remark`。 +13. **`customer_rating_grades`** 评级等级配置:`grade_name` uk、`min_score`/`max_score DECIMAL(5,1)`、`sort_order`(判定=按序首个 min_score ≤ 总分,避免区间边界歧义)、`remark`。种子 4 条(迁移 + init-dev 双写):A 优秀 90-100、B 良好 75-89、C 合格 60-74、D 待改进 0-59。评分项固定 5 项 × 20 分为前端常量(#25:仅打分工具,不影响绩效金额)。 + +### 编号生成规则(`core/constants.py` 集中前缀常量) + +`{前缀}{YYYYMMDD}-{当日4位序号}`,序号按自然日重置。服务层 `SELECT MAX(no) ... FOR UPDATE` 后 +1;撞 uk(1062)兜底重试一次后仍失败返回 400(沿用 M1 code-review 修复过的并发兜底模式)。 + +### 客户分类 × M1 参数对应(M3 消费契约) + +| customer_type | 核算规则 | M1 参数 | +| --- | --- | --- | +| `personal` | `10‰ − 天数×4/360‰`,361 天起 4‰ | `salesperson.rate.*`/`days.boundary` | +| `manager_personal` | 归经理本人,12.5%+5% 结构 | `manager.share.*` | +| `enterprise` | 固定计提(2‰/3‰ 按日期) | `enterprise.rate`(与产品类别交叉) | + +## B. 接口与前端 + +- 新增 `app/api/v1/base/`(`/api/v1/base`,人员/产品/客户)与 `app/api/v1/business/`(`/api/v1/business`,业务/发货/回款/评级),与 `system/*` 平级。约 30 个端点。 +- **权限**:全部登录即可(`get_current_user`,不套 `AdminUser`)——M2 是业务操作(需规 2.5.1「普通用户」),M1 的 `AdminUser` 仅用于 system/*。写操作全部 `audit_service.audit()` 留痕。 +- 关键端点:人员 CRUD+区域/计划全量替换(**clear 后立即 flush**,`replace_roles` 先例坑);产品 CRUD+`POST /products/extract`(业务明细未入库产品按名称+规格批量建产品,幂等);客户归属变更(事务内更新+插留痕);业务 CRUD(明细行数组、汇总重算 `_recalc_totals`、状态机 1→2/3 终态 400、毛利润视图 `GET /businesses/{id}/gross-profit?asOf=`);发货/回款 CRUD(回款跨业务指定批次 400、已核算禁改禁删);评级等级 CRUD(区间不重叠校验)。 +- 关键校验:删除保护三类(被引用→400 引导离职/停用/改终止);金额 ≥0;`quantity>0`;销售额 0 防除零(`formula.profit_rate` 已内置)。 +- 前端 7 页(复用 `usePagedList`/`useConfirmAction`/`AppPagination`/`StatusTag`/`--ys-*` token,不新增基础组件):`/base/personnel`(编辑抽屉内子表编辑区域/计划)、`/base/products`(类别下拉展示当前阈值、提取产品按钮)、`/base/customers`(归属变更二次确认文案含「不回溯历史」)、`/business/businesses`(明细行编辑器、详情抽屉含状态时间线/毛利润卡片)、`/business/shipments`、`/business/receipts`(选业务级联加载该业务发货单,可清空)、`/business/rating`(5 项评分实时出等级+映射维护抽屉,标注「仅打分工具」)。菜单新增「基础数据」「业务数据」两组,均不设 `requiresAdmin`。api 封装 7 个文件(蛇形字段直出,同 `businessRoles.ts` 模式)。金额展示字符串直出,禁 float 格式化。 + +## C. 任务分解(T1–T15) + +依赖链:`T1 → (T2∥T3∥T4) → T5 → T6 → T7 → T8 → (T9∥T10) → T11 → (T12∥T13∥T14) → T15`。每任务「实现 → 测试/冒烟 → commit(`feat(scope): 中文说明`)」。 + +| # | 任务 | 要点 | +| --- | --- | --- | +| T1 | V4 迁移 + 基础数据模型 | `models/personnel.py`/`product.py`/`customer.py`;**注册进 `models/__init__.py`(漏注册会生成删表迁移)**;`test_models.py` EXPECTED_TABLES +6;autogenerate → 人工核对 → 导出 `db/migration/V4__*.sql`;同步 `init-dev.sql`(含 alembic_version 标记);`db/queries/基础数据.sql` 留档 | +| T2 | 人员管理后端 | schemas/repositories/services/api 四层;岗位存在且启用、manager 非自己且在职、删除保护→提示改离职、区域/计划全量替换 clear 后立即 flush | +| T3 | 产品管理后端 | 类别校验复用 `PRODUCT_CATEGORIES`;1062→400;删除保护;`extract` 幂等 | +| T4 | 客户管理后端 | 公司+负责人唯一;归属变更(更新+留痕同事务);留痕分页查询 | +| T5 | 基础数据集成测试(统一编写点) | `test_personnel.py`/`test_products.py`/`test_customers.py`:唯一冲突、删除保护、留痕内容、未登录 401、审计双记录、非法枚举;更新 `backend/tests/README.md` | +| T6 | V5 迁移 + 业务数据模型 | `models/business.py`/`shipment.py`/`receipt.py`/`rating.py`;EXPECTED_TABLES +7;等级种子 4 条双写;`db/migration/V5__*.sql` + `init-dev.sql` + `db/queries/业务数据.sql` | +| T7 | 业务管理后端 | 编号生成;明细全量替换+五项汇总重算;状态机终态 400;归属留痕;毛利润视图(`resolve_snapshot` + `formula.profit_rate`/`converted_profit`,asOf 参数) | +| T8 | 发货管理后端 | 金额/运费汇总回写业务冗余列;终态业务禁新发货;被回款指定禁删 | +| T9 | 回款管理后端 | 批次号;**shipment_id 同业务强校验**;已核算禁改禁删;合计回写;DTO 出 `paymentDays`(`formula.payment_days`) | +| T10 | 评级等级配置后端 | 区间不重叠/0≤min≤max≤100 校验;名称唯一 | +| T11 | 业务数据集成测试(统一编写点) | 编号格式与序号递增、终态拦截、三处汇总冗余一致性、跨业务 400、已核算禁改、毛利润视图(27 天回款天数/防除零/取参)、审计、401;**全量 pytest 回归** | +| T12 | 前端基础数据三页 | 人员/产品/客户;归属变更确认文案含「不回溯历史」;`pnpm type-check && pnpm build` | +| T13 | 前端业务/发货/回款三页 | 明细行编辑器、详情抽屉、回款级联指定批次、金额字符串直出 | +| T14 | 前端评级计算器页 | 5 项评分→实时等级;映射维护抽屉;「仅打分工具」标注 | +| T15 | e2e 与文档收尾 | `e2e/base_data.py`/`business_data.py` 全链路(建数据→业务→发货→回款→回款天数→毛利润→评级);全量回归(后端基线 406+ 新增全绿、前端构建、e2e);三份 README 更新 | + +## 验证方式 + +- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。 +- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。 +- T12–T14:`pnpm type-check && pnpm build`。 +- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。 + +## 风险与开放问题(默认处理,不阻塞开工) + +1. 毛利润成本来源待 T+:`cost_amount` 可空+人工可录先行;视图中成本缺失的行标注「无法计算」。 +2. 人员条目 T+ 边界:一期手工 CRUD 完整可用,同步以 `source_no` 幂等、不覆盖人工字段。 +3. 拓展计划粒度:自由条目容器(期间+内容+指标说明)。 +4. 业务编号前缀:一期代码常量 YW/SH/HK,如需配置走 M1 参数体系。 +5. 终态回退:一期不可变更(400);如需重开再加 2→1 规则。 +6. 负数回款/红字:一期禁负金额;冲销需求出现时 M3 前补设计。 +7. 回款未指定批次:允许保存,列表提供筛选,M3 核算前置校验非空。 +8. T+ 幂等键:`source_no` 普通索引先行,IT 定键后小迁移升级。 -- 2.27.0 From f22d14e43984af4130ff527bb3fec6a5d12cd140 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 09:49:05 +0800 Subject: [PATCH 019/100] =?UTF-8?q?feat(base):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=E5=85=AD=E8=A1=A8=E5=8F=8A?= =?UTF-8?q?=20V4=20=E8=BF=81=E7=A7=BB=EF=BC=88=E4=BA=BA=E5=91=98/=E8=BE=90?= =?UTF-8?q?=E5=B0=84=E5=8C=BA=E5=9F=9F/=E6=8B=93=E5=B1=95=E8=AE=A1?= =?UTF-8?q?=E5=88=92/=E4=BA=A7=E5=93=81/=E5=AE=A2=E6=88=B7/=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E7=95=99=E7=97=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TPlusSyncMixin 统一 T+ 同步预留四列(可空+普通索引,唯一键待 IT 定键后升级) - 留痕表实体 ID 无外键冗余固化,删除保护由服务层承担 - init-dev.sql 同步六表 DDL,alembic_version 标记更新 Co-Authored-By: Claude Code --- .../e1133a7c670a_add_base_data_tables.py | 181 ++++++++++++++++++ backend/app/core/constants.py | 13 ++ backend/app/models/__init__.py | 9 + backend/app/models/base.py | 35 +++- backend/app/models/customer.py | 121 ++++++++++++ backend/app/models/personnel.py | 123 ++++++++++++ backend/app/models/product.py | 58 ++++++ backend/tests/unit/test_models.py | 13 +- db/init/init-dev.sql | 149 +++++++++++++- db/migration/V4__add_base_data_tables.sql | 133 +++++++++++++ db/queries/基础数据.sql | 98 ++++++++++ docs/项目阶段报告_2026-10-13.md | 154 +++++++++++++++ 12 files changed, 1083 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/e1133a7c670a_add_base_data_tables.py create mode 100644 backend/app/models/customer.py create mode 100644 backend/app/models/personnel.py create mode 100644 backend/app/models/product.py create mode 100644 db/migration/V4__add_base_data_tables.sql create mode 100644 db/queries/基础数据.sql create mode 100644 docs/项目阶段报告_2026-10-13.md diff --git a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py new file mode 100644 index 0000000..2f4c3bf --- /dev/null +++ b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py @@ -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') diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 19d225a..a8c33f3 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -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 = "修改" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 826689d..be20a84 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/base.py b/backend/app/models/base.py index 59f932e..0ee13ad 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -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__` 的元组。 diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..693cb7b --- /dev/null +++ b/backend/app/models/customer.py @@ -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="创建时间" + ) diff --git a/backend/app/models/personnel.py b/backend/app/models/personnel.py new file mode 100644 index 0000000..f9256a7 --- /dev/null +++ b/backend/app/models/personnel.py @@ -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+ 原始条目号(预留)") diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..31f7aba --- /dev/null +++ b/backend/app/models/product.py @@ -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="备注") diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index d3b8069..ec0227b 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -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: diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index b713020..468e351 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -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'); diff --git a/db/migration/V4__add_base_data_tables.sql b/db/migration/V4__add_base_data_tables.sql new file mode 100644 index 0000000..6f501bc --- /dev/null +++ b/db/migration/V4__add_base_data_tables.sql @@ -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'; + diff --git a/db/queries/基础数据.sql b/db/queries/基础数据.sql new file mode 100644 index 0000000..aa38ff5 --- /dev/null +++ b/db/queries/基础数据.sql @@ -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; diff --git a/docs/项目阶段报告_2026-10-13.md b/docs/项目阶段报告_2026-10-13.md new file mode 100644 index 0000000..16f459b --- /dev/null +++ b/docs/项目阶段报告_2026-10-13.md @@ -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) -- 2.27.0 From a694570dd1f36790157af57c4a00db1497b669e9 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 10:35:02 +0800 Subject: [PATCH 020/100] =?UTF-8?q?feat(base):=20=E4=BA=BA=E5=91=98?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=B2=97=E4=BD=8D?= =?UTF-8?q?/=E5=B7=A5=E8=B5=84=E6=A0=87=E5=87=86/=E5=9B=A2=E9=98=9F/?= =?UTF-8?q?=E8=BE=90=E5=B0=84=E5=8C=BA=E5=9F=9F/=E6=8B=93=E5=B1=95?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除保护:被团队成员/客户归属引用禁删,引导改离职 - 子表全量替换采用 clear 后立即 flush 防御写法 - 工资标准字符串进出,超两位小数拒绝而非静默舍入 Co-Authored-By: Claude Code --- backend/app/api/v1/base/__init__.py | 8 + backend/app/api/v1/base/personnel.py | 141 ++++++ .../app/repositories/personnel_repository.py | 182 ++++++++ backend/app/schemas/personnel.py | 147 +++++++ backend/app/services/personnel_service.py | 407 ++++++++++++++++++ 5 files changed, 885 insertions(+) create mode 100644 backend/app/api/v1/base/__init__.py create mode 100644 backend/app/api/v1/base/personnel.py create mode 100644 backend/app/repositories/personnel_repository.py create mode 100644 backend/app/schemas/personnel.py create mode 100644 backend/app/services/personnel_service.py diff --git a/backend/app/api/v1/base/__init__.py b/backend/app/api/v1/base/__init__.py new file mode 100644 index 0000000..f64505d --- /dev/null +++ b/backend/app/api/v1/base/__init__.py @@ -0,0 +1,8 @@ +"""基础数据管理接口(需求 2.1)。 + +挂载于 `/api/v1/base/*`,含人员管理、辐射区域、拓展计划等基础数据的维护入口。 + +与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py new file mode 100644 index 0000000..9b305c2 --- /dev/null +++ b/backend/app/api/v1/base/personnel.py @@ -0,0 +1,141 @@ +"""人员管理接口(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +基础数据属业务操作,**登录即可访问**(不要求管理员,对照系统管理模块)。 +校验规则(岗位/经理合法性、删除保护、子表全量替换的防御写法)见 +`personnel_service` 模块 docstring —— 前端提示之外,后端校验是唯一防线。 + +写操作经 `RequestMetaDep` 带入请求元信息由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelQuery, + PersonnelStatusIn, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import personnel_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/personnel", tags=["人员管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[PersonnelOut]], summary="人员列表") +def list_personnel( + session: DbSession, + _: CurrentUser, + query: Annotated[PersonnelQuery, Query()], +) -> ApiResponse[PageResult[PersonnelOut]]: + """分页查询人员(含离职)。姓名模糊匹配,可按状态与岗位筛选。""" + return ok(personnel_service.list_personnel(session, query)) + + +@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员") +def create_personnel( + session: DbSession, + current_user: CurrentUser, + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """新增人员,默认在职。岗位须存在且启用;直属经理不能是自己且须在职。""" + return ok( + personnel_service.create_personnel(session, payload, operator=current_user, meta=meta), + message="人员已创建", + ) + + +@router.get("/{personnel_id}", response_model=ApiResponse[PersonnelDetailOut], summary="人员详情") +def get_personnel( + session: DbSession, + _: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[PersonnelDetailOut]: + """人员详情,附辐射区域与拓展计划,供编辑抽屉一次回显。""" + return ok(personnel_service.get_detail(session, personnel_id)) + + +@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员") +def update_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """编辑人员(全量提交,不含状态)。校验规则同新增。""" + return ok( + personnel_service.update_personnel( + session, personnel_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员") +def delete_personnel( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除人员。被团队成员或客户归属引用时禁删,应改为离职。""" + personnel_service.delete_personnel(session, personnel_id, operator=current_user, meta=meta) + return ok(message="人员已删除") + + +@router.put( + "/{personnel_id}/status", + response_model=ApiResponse[PersonnelOut], + summary="设为在职/离职", +) +def set_status( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PersonnelStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[PersonnelOut]: + """在职/离职切换。离职人员不可再被业务与客户归属引用。""" + result = personnel_service.set_status( + session, personnel_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职") + + +@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域") +def replace_regions( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: RegionReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换辐射区域,空列表即清空。""" + personnel_service.replace_regions( + session, personnel_id, payload, operator=current_user, meta=meta + ) + return ok(message="已保存") + + +@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划") +def replace_plans( + session: DbSession, + current_user: CurrentUser, + personnel_id: Annotated[int, Path(ge=1)], + payload: PlanReplaceIn, + meta: RequestMetaDep, +) -> ApiResponse[None]: + """全量替换拓展计划,空列表即清空。""" + personnel_service.replace_plans(session, personnel_id, payload, operator=current_user, meta=meta) + return ok(message="已保存") diff --git a/backend/app/repositories/personnel_repository.py b/backend/app/repositories/personnel_repository.py new file mode 100644 index 0000000..602e127 --- /dev/null +++ b/backend/app/repositories/personnel_repository.py @@ -0,0 +1,182 @@ +"""营销人员数据访问(含辐射区域与拓展计划子表)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Row, Select, func, select +from sqlalchemy import delete as sa_delete +from sqlalchemy.orm import Session, aliased + +from app.models import BusinessRole, Customer, Personnel, PersonnelPlan, PersonnelRegion +from app.repositories.query_utils import fuzzy + +# 自引用别名:manager_id 指向 personnel.id,同一张表自联取「直属经理姓名」 +_manager = aliased(Personnel) + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, personnel_id: int, *, for_update: bool = False) -> Personnel | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository 的理由)。""" + if for_update: + stmt = select(Personnel).where(Personnel.id == personnel_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Personnel, personnel_id) + + +def get_with_names(session: Session, personnel_id: int) -> Row | None: + """查单个人员并带出岗位名称与直属经理姓名(详情/编辑回显共用)。""" + stmt = _base_select().where(Personnel.id == personnel_id) + return session.execute(stmt).first() + + +def list_personnel( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> list[Row]: + """分页查询人员,行内容为 (人员, 岗位名称, 直属经理姓名)。 + + 排序按 id 倒序:人员无排序字段,新增的靠前符合列表页习惯。 + """ + stmt = _apply_filters( + _base_select(), keyword=keyword, status=status, business_role_id=business_role_id + ) + stmt = stmt.order_by(Personnel.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_personnel( + session: Session, + *, + keyword: str | None = None, + status: int | None = None, + business_role_id: int | None = None, +) -> int: + """统计符合条件的人员总数,供分页使用。 + + 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 + ``_apply_filters`` 单点保证)。 + """ + stmt = _apply_filters( + select(func.count(Personnel.id)), + keyword=keyword, + status=status, + business_role_id=business_role_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def count_team_members(session: Session, manager_ids: list[int]) -> dict[int, int]: + """按直属经理批量统计团队成员数(删除保护的依据)。 + + 一次 GROUP BY 完成,禁止调用方在列表循环内逐行查询。 + """ + stmt = ( + select(Personnel.manager_id, func.count(Personnel.id)) + .where(Personnel.manager_id.in_(manager_ids)) + .group_by(Personnel.manager_id) + ) + return {int(manager_id): int(count) for manager_id, count in session.execute(stmt).all()} + + +def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按归属人员批量统计客户数(删除保护的另一半依据)。""" + stmt = ( + select(Customer.owner_personnel_id, func.count(Customer.id)) + .where(Customer.owner_personnel_id.in_(personnel_ids)) + .group_by(Customer.owner_personnel_id) + ) + return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()} + + +def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]: + """查人员的全部辐射区域,按插入顺序返回。""" + stmt = ( + select(PersonnelRegion) + .where(PersonnelRegion.personnel_id == personnel_id) + .order_by(PersonnelRegion.id) + ) + return list(session.execute(stmt).scalars()) + + +def list_plans(session: Session, personnel_id: int) -> list[PersonnelPlan]: + """查人员的全部拓展计划,按插入顺序返回。""" + stmt = ( + select(PersonnelPlan) + .where(PersonnelPlan.personnel_id == personnel_id) + .order_by(PersonnelPlan.id) + ) + return list(session.execute(stmt).scalars()) + + +def _base_select() -> Select: + """人员主表联岗位名称与直属经理姓名。 + + 岗位外键非空,但统一用 outerjoin:岗位行被物理删除的脏数据不应让 + 整个列表查不出来(外键 RESTRICT 正常情况下不会发生)。 + """ + return ( + select(Personnel, BusinessRole.role_name, _manager.personnel_name) + .outerjoin(BusinessRole, Personnel.business_role_id == BusinessRole.id) + .outerjoin(_manager, Personnel.manager_id == _manager.id) + ) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + status: int | None, + business_role_id: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if keyword: + stmt = fuzzy(stmt, Personnel.personnel_name, keyword) + if status is not None: + stmt = stmt.where(Personnel.status == status) + if business_role_id is not None: + stmt = stmt.where(Personnel.business_role_id == business_role_id) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, personnel: Personnel) -> Personnel: + """新增人员。只 flush 以取得自增 id。""" + session.add(personnel) + session.flush() + return personnel + + +def delete(session: Session, personnel: Personnel) -> None: + """删除人员。调用方须先完成引用保护校验。""" + session.delete(personnel) + session.flush() + + +def replace_regions(session: Session, personnel_id: int, rows: list[PersonnelRegion]) -> None: + """全量替换人员的辐射区域。 + + **先删、立即 flush、再插入**:同一 flush 内 SQLAlchemy 先执行 INSERT + 后执行 DELETE,新旧行并存会在带唯一键的表上撞 1062 —— M1 的 + replace_roles 在 e2e 中踩过此坑。本表当前无唯一键,仍统一按防御写法, + 避免后续加约束时翻车。 + """ + session.execute(sa_delete(PersonnelRegion).where(PersonnelRegion.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() + + +def replace_plans(session: Session, personnel_id: int, rows: list[PersonnelPlan]) -> None: + """全量替换人员的拓展计划(防御写法同 ``replace_regions``)。""" + session.execute(sa_delete(PersonnelPlan).where(PersonnelPlan.personnel_id == personnel_id)) + session.flush() + session.add_all(rows) + session.flush() diff --git a/backend/app/schemas/personnel.py b/backend/app/schemas/personnel.py new file mode 100644 index 0000000..12032cc --- /dev/null +++ b/backend/app/schemas/personnel.py @@ -0,0 +1,147 @@ +"""营销人员的请求/响应模型(需求 2.1.1)。""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# salary_standard 列为 Numeric(12,2):整数部分最多 10 位。 +# 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float +SALARY_MAX = Decimal("9999999999.99") + + +class PersonnelRegionOut(BaseModel): + """辐射区域条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + region_name: str + remark: str | None = None + + +class PersonnelPlanOut(BaseModel): + """拓展计划条目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + plan_period: str + plan_content: str + target_desc: str | None = None + + +class PersonnelOut(BaseModel): + """人员响应(列表与写操作回包共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + personnel_name: str + business_role_id: int + role_name: str | None = None + manager_id: int | None = None + manager_name: str | None = None + phone: str | None = None + hire_date: date | None = None + is_probation: int = 0 + salary_standard: str | None = None + status: int = 1 + remark: str | None = None + + +class PersonnelDetailOut(PersonnelOut): + """人员详情:附区域与拓展计划,供编辑抽屉一次回显。""" + + regions: list[PersonnelRegionOut] = Field(default_factory=list) + plans: list[PersonnelPlanOut] = Field(default_factory=list) + + +class PersonnelCreateIn(BaseModel): + """新增人员。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + personnel_name: str = Field(min_length=1, max_length=64, description="姓名") + business_role_id: int = Field(ge=1, description="岗位角色ID") + manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无") + phone: str | None = Field(default=None, max_length=20, description="联系电话") + hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") + is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否") + salary_standard: str | None = Field(default=None, description="月工资标准(元),字符串形式的数字") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("salary_standard") + @classmethod + def _check_salary_standard(cls, value: str | None) -> str | None: + """按 Numeric(12,2) 的容量校验字符串金额。 + + 金额以字符串进出避免 float 精度损失(项目金额红线); + 小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。 + """ + if value is None: + return None + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError("工资标准须为有效数字") from None + if not amount.is_finite(): + raise ValueError("工资标准须为有效数字") + if amount < 0: + raise ValueError("工资标准不能为负数") + if amount > SALARY_MAX: + raise ValueError("工资标准超出可录入上限") + if -amount.as_tuple().exponent > 2: + raise ValueError("工资标准最多两位小数") + return value + + +class PersonnelUpdateIn(PersonnelCreateIn): + """编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。""" + + +class PersonnelStatusIn(BaseModel): + """在职/离职切换。""" + + status: int = Field(ge=0, le=1, description="1在职 0离职") + + +class RegionItemIn(BaseModel): + """辐射区域条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + region_name: str = Field(min_length=1, max_length=128, description="区域名称") + remark: str | None = Field(default=None, max_length=255) + + +class RegionReplaceIn(BaseModel): + """辐射区域全量替换。空列表即清空该人员的全部区域。""" + + items: list[RegionItemIn] = Field(default_factory=list, description="全量条目") + + +class PlanItemIn(BaseModel): + """拓展计划条目。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1") + plan_content: str = Field(min_length=1, max_length=500, description="计划内容") + target_desc: str | None = Field(default=None, max_length=255, description="指标说明") + + +class PlanReplaceIn(BaseModel): + """拓展计划全量替换。空列表即清空该人员的全部计划。""" + + items: list[PlanItemIn] = Field(default_factory=list, description="全量条目") + + +class PersonnelQuery(PageQuery): + """人员列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职") + business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选") diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py new file mode 100644 index 0000000..f58e91d --- /dev/null +++ b/backend/app/services/personnel_service.py @@ -0,0 +1,407 @@ +"""营销人员管理业务编排(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。 + +业务规则(前端会做提示,**后端校验是唯一防线**): + +- 岗位必须存在且启用 —— 岗位决定计提公式模板,挂到停用/不存在的岗位 + 会让 M3 核算找不到模板 +- 直属经理不能是自己、目标须存在且在职 —— 自引用关系是 M3 经理 12.5% + 聚合的展开锚点,环引用或悬空引用会让聚合死循环/漏算 +- 删除保护:被团队成员(personnel.manager_id)或客户归属 + (customers.owner_personnel_id)引用的人员**禁删**,应改为离职 —— + 历史核算与客户归属都要能回溯到人,物理删除会留下悬空引用 +- 金额红线:salary_standard 以字符串进出、服务层转 Decimal,全程不经 float + +辐射区域与拓展计划为整表替换维护:先删、立即 flush、再插入 +(防御同一 flush 先 INSERT 后 DELETE 撞唯一键,见 personnel_repository)。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_PERSONNEL, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User +from app.repositories import business_role_repository, personnel_repository +from app.schemas.personnel import ( + PersonnelCreateIn, + PersonnelDetailOut, + PersonnelOut, + PersonnelPlanOut, + PersonnelQuery, + PersonnelRegionOut, + PlanReplaceIn, + RegionReplaceIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 删除保护的统一文案:两种引用共享同一提示,引导管理员走「离职」而非删除 +_DELETE_BLOCKED = "存在团队成员或客户归属引用,请改为离职而非删除" + + +def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: + """分页查询人员(含离职)。""" + rows = personnel_repository.list_personnel( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + total = personnel_repository.count_personnel( + session, + keyword=query.keyword, + status=query.status, + business_role_id=query.business_role_id, + ) + return PageResult[PersonnelOut]( + records=[_to_out(personnel, role_name, manager_name) for personnel, role_name, manager_name in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, personnel_id: int) -> PersonnelDetailOut: + """人员详情(含区域与拓展计划,供编辑抽屉回显)。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + return PersonnelDetailOut( + **_to_out(personnel, role_name, manager_name).model_dump(), + regions=[ + PersonnelRegionOut.model_validate(item) + for item in personnel_repository.list_regions(session, personnel_id) + ], + plans=[ + PersonnelPlanOut.model_validate(item) + for item in personnel_repository.list_plans(session, personnel_id) + ], + ) + + +def create_personnel( + session: Session, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """新增人员,默认在职。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + role, manager_name = _validate_refs(session, payload.business_role_id, payload.manager_id, None) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_CREATE, + summary=f"新增人员 {payload.personnel_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + personnel = Personnel( + personnel_name=payload.personnel_name, + business_role_id=payload.business_role_id, + manager_id=payload.manager_id, + phone=payload.phone, + hire_date=payload.hire_date, + is_probation=payload.is_probation, + salary_standard=_parse_salary(payload.salary_standard), + status=1, + remark=payload.remark, + ) + try: + personnel_repository.create(session, personnel) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:岗位/经理行被并发删除时 FK RESTRICT 令写入失败 + session.rollback() + raise BadRequestError("岗位或直属经理引用已失效,请刷新后重试") from exc + + logger.info("新增人员 | personnel_id=%s | name=%s", personnel.id, payload.personnel_name) + return out + + +def update_personnel( + session: Session, + personnel_id: int, + payload: PersonnelCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """编辑人员(全量替换,不含状态)。 + + 经理自引用校验以**被编辑人员的 id** 为准:把经理改成自己是环引用。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"修改人员 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel = row[0] + role, manager_name = _validate_refs( + session, payload.business_role_id, payload.manager_id, personnel.id + ) + personnel.personnel_name = payload.personnel_name + personnel.business_role_id = payload.business_role_id + personnel.manager_id = payload.manager_id + personnel.phone = payload.phone + personnel.hire_date = payload.hire_date + personnel.is_probation = payload.is_probation + personnel.salary_standard = _parse_salary(payload.salary_standard) + personnel.remark = payload.remark + # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) + out = _to_out(personnel, role.role_name, manager_name) + session.commit() + logger.info("修改人员 | personnel_id=%s", personnel_id) + return out + + +def delete_personnel( + session: Session, + personnel_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除人员。有团队成员或客户归属引用时禁删,引导走「离职」。 + + 查询走 ``FOR UPDATE`` 锁行:校验与删除之间若发生并发引用,锁行使 + 并发外键写入在删除时失败(可见的 400)而非引用被静默悬空。 + + :raises NotFoundError: 人员不存在 + :raises BadRequestError: 存在团队成员或客户归属引用 + """ + personnel = personnel_repository.get_by_id(session, personnel_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_DELETE, + summary=f"删除人员 {personnel.personnel_name if personnel else f'#{personnel_id}'}", + ), + meta=meta, + ): + if personnel is None: + raise NotFoundError("人员不存在") + + ids = [personnel_id] + if ( + personnel_repository.count_team_members(session, ids) + or personnel_repository.count_customer_ownerships(session, ids) + ): + raise BadRequestError(_DELETE_BLOCKED) + + name = personnel.personnel_name # 固化:对象随删除失效 + try: + personnel_repository.delete(session, personnel) + session.commit() + except IntegrityError as exc: + # 校验与删除之间并发新增了团队成员/客户归属,FK RESTRICT 令删除失败 + session.rollback() + raise BadRequestError(_DELETE_BLOCKED) from exc + logger.info("删除人员 | personnel_id=%s | name=%s", personnel_id, name) + + +def set_status( + session: Session, + personnel_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> PersonnelOut: + """在职/离职切换。 + + 人员语义不用「启用/禁用」词表,action 记 ACTION_UPDATE、摘要写明去向。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + verb = "设为在职" if status == 1 else "设为离职" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"{verb} {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + personnel, role_name, manager_name = row + personnel.status = status + out = _to_out(personnel, role_name, manager_name) + session.commit() + logger.info("变更人员状态 | personnel_id=%s | status=%s", personnel_id, status) + return out + + +def replace_regions( + session: Session, + personnel_id: int, + payload: RegionReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换辐射区域,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护辐射区域 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelRegion(personnel_id=personnel_id, region_name=item.region_name, remark=item.remark) + for item in payload.items + ] + personnel_repository.replace_regions(session, personnel_id, rows) + session.commit() + logger.info("维护辐射区域 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def replace_plans( + session: Session, + personnel_id: int, + payload: PlanReplaceIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """全量替换拓展计划,空列表即清空。 + + :raises NotFoundError: 人员不存在 + """ + row = personnel_repository.get_with_names(session, personnel_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PERSONNEL, + action=ACTION_UPDATE, + summary=f"维护拓展计划 {row[0].personnel_name if row else f'#{personnel_id}'}", + request_data=payload.model_dump(), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("人员不存在") + rows = [ + PersonnelPlan( + personnel_id=personnel_id, + plan_period=item.plan_period, + plan_content=item.plan_content, + target_desc=item.target_desc, + ) + for item in payload.items + ] + personnel_repository.replace_plans(session, personnel_id, rows) + session.commit() + logger.info("维护拓展计划 | personnel_id=%s | 条目数=%s", personnel_id, len(rows)) + + +def _to_out(personnel: Personnel, role_name: str | None, manager_name: str | None) -> PersonnelOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return PersonnelOut( + id=personnel.id, + personnel_name=personnel.personnel_name, + business_role_id=personnel.business_role_id, + role_name=role_name, + manager_id=personnel.manager_id, + manager_name=manager_name, + phone=personnel.phone, + hire_date=personnel.hire_date, + is_probation=personnel.is_probation, + salary_standard=( + str(personnel.salary_standard) if personnel.salary_standard is not None else None + ), + status=personnel.status, + remark=personnel.remark, + ) + + +def _validate_refs( + session: Session, + business_role_id: int, + manager_id: int | None, + self_id: int | None, +) -> tuple[BusinessRole, str | None]: + """新增/编辑共用的引用校验:岗位须存在且启用;经理不能是自己且须在职。 + + ``self_id`` 编辑时传被编辑人员的 id、新增时传 None(新增不存在「自己」)。 + 返回岗位行与经理姓名供响应 DTO 复用,避免提交后再查一次。 + + :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 + """ + role = business_role_repository.get_by_id(session, business_role_id) + if role is None or role.status != 1: + raise BadRequestError("岗位不存在或已停用") + + manager_name: str | None = None + if manager_id is not None: + if manager_id == self_id: + raise BadRequestError("直属经理不能是自己") + manager = personnel_repository.get_by_id(session, manager_id) + if manager is None or manager.status != 1: + raise BadRequestError("直属经理不存在或已离职") + manager_name = manager.personnel_name + return role, manager_name + + +def _parse_salary(value: str | None) -> Decimal | None: + """入参字符串转 Decimal 存库。 + + 格式、非负与容量已在 schema 层校验,此处只做类型转换 —— + 金额红线:全程 Decimal,不经 float。 + """ + return Decimal(value) if value is not None else None -- 2.27.0 From 7eeed85c96c5b6f399a0188c1b1a19beb864bba7 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 10:35:03 +0800 Subject: [PATCH 021/100] =?UTF-8?q?feat(base):=20=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E7=B1=BB=E5=88=AB?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C/=E7=BB=84=E5=90=88=E5=94=AF=E4=B8=80/?= =?UTF-8?q?=E5=90=AF=E5=81=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/app/api/v1/base/products.py | 114 +++++++ .../app/repositories/product_repository.py | 118 +++++++ backend/app/schemas/product.py | 104 ++++++ backend/app/services/product_service.py | 306 ++++++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 backend/app/api/v1/base/products.py create mode 100644 backend/app/repositories/product_repository.py create mode 100644 backend/app/schemas/product.py create mode 100644 backend/app/services/product_service.py diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py new file mode 100644 index 0000000..7b12e8e --- /dev/null +++ b/backend/app/api/v1/base/products.py @@ -0,0 +1,114 @@ +"""产品管理接口(需求 2.1.2:产品主数据维护)。 + +产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值), +类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。 + +**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表 +(V5 迁移),待业务管理任务完成后补充。 + +基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser); +写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.product import ( + ProductCreateIn, + ProductOut, + ProductQuery, + ProductStatusIn, + ProductUpdateIn, +) +from app.services import product_service + +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/products", tags=["产品管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表") +def list_products( + session: DbSession, + _: CurrentUser, + query: Annotated[ProductQuery, Query()], +) -> ApiResponse[PageResult[ProductOut]]: + """分页查询产品(含停用)。keyword 对名称或规格模糊匹配。""" + return ok(product_service.list_products(session, query)) + + +@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品") +def create_product( + session: DbSession, + current_user: CurrentUser, + payload: ProductCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """新增产品。类别须在合法值域内;新产品默认启用。""" + return ok( + product_service.create_product(session, payload, operator=current_user, meta=meta), + message="产品已创建", + ) + + +@router.get("/{product_id}", response_model=ApiResponse[ProductOut], summary="产品详情") +def get_product( + session: DbSession, + _: CurrentUser, + product_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ProductOut]: + """按主键查询产品详情。""" + return ok(product_service.get_product(session, product_id)) + + +@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品") +def update_product( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + payload: ProductUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """编辑产品基本信息(全量替换,不含状态;启用/停用走独立端点)。""" + return ok( + product_service.update_product( + session, product_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品") +def delete_product( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """物理删除产品(仅限误录数据,常规下架用停用)。 + + 被业务明细引用的保护检查待业务数据表(V5)就位后补充。 + """ + product_service.delete_product(session, product_id, operator=current_user, meta=meta) + return ok(message="产品已删除") + + +@router.put( + "/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品" +) +def set_status( + session: DbSession, + current_user: CurrentUser, + product_id: Annotated[int, Path(ge=1)], + payload: ProductStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[ProductOut]: + """启用或停用产品(1启用 0停用)。""" + result = product_service.set_status( + session, product_id, payload.status, operator=current_user, meta=meta + ) + return ok(result, message="已启用" if payload.status == 1 else "已停用") diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py new file mode 100644 index 0000000..a574538 --- /dev/null +++ b/backend/app/repositories/product_repository.py @@ -0,0 +1,118 @@ +"""产品主数据数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session + +from app.models import Product + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> Product | None: + """按主键查询。删除校验走 ``for_update`` 锁行(同 business_role_repository + 的理由:校验与删除之间若发生并发引用,锁行使并发写入可见地失败, + 而非引用被静默悬空)。""" + if for_update: + stmt = select(Product).where(Product.id == product_id).with_for_update() + return session.execute(stmt).scalar_one_or_none() + return session.get(Product, product_id) + + +def get_by_name_spec( + session: Session, product_name: str, spec_model: str +) -> Product | None: + """按 (名称, 规格型号) 组合查询。 + + 组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示; + 并发窗口仍存在,最终由 IntegrityError 兜底(见 product_service)。 + """ + stmt = select(Product).where( + Product.product_name == product_name, + Product.spec_model == spec_model, + ) + return session.execute(stmt).scalar_one_or_none() + + +def list_products( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + product_category: str | None = None, + status: int | None = None, +) -> list[Product]: + """分页查询产品(含停用)。按 id 升序,与 M1 岗位列表的次序口径一致。""" + stmt = _apply_filters( + select(Product), + keyword=keyword, + product_category=product_category, + status=status, + ) + stmt = stmt.order_by(Product.id).offset(offset).limit(limit) + return list(session.execute(stmt).scalars()) + + +def count_products( + session: Session, + *, + keyword: str | None = None, + product_category: str | None = None, + status: int | None = None, +) -> int: + """统计符合条件的产品总数,供分页使用。""" + stmt = _apply_filters( + select(func.count(Product.id)), + keyword=keyword, + product_category=product_category, + status=status, + ) + return int(session.execute(stmt).scalar_one()) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + product_category: str | None, + status: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。 + + 关键词对名称/规格两列取 OR 命中。转义与 query_utils.fuzzy 同一机制 + (``contains(autoescape=True)`` 转义 LIKE 通配符并附加 ESCAPE 子句); + 因 fuzzy 是单列 where 语义(两次调用会叠加成 AND),双列 OR 在此直接 + 写 contains —— M1 business_role_repository 的双列关键词同款写法。 + 规格型号常含 ``_``(如 DN300_II),不转义会当单字符通配执行。 + """ + if keyword: + stmt = stmt.where( + or_( + Product.product_name.contains(keyword, autoescape=True), + Product.spec_model.contains(keyword, autoescape=True), + ) + ) + if product_category: + stmt = stmt.where(Product.product_category == product_category) + if status is not None: + stmt = stmt.where(Product.status == status) + return stmt + + +# ==================== 写入 ==================== + + +def create(session: Session, product: Product) -> Product: + """新增产品。只 flush 以取得自增 id。""" + session.add(product) + session.flush() + return product + + +def delete(session: Session, product: Product) -> None: + """物理删除产品。调用方须先完成引用保护校验(V5 就位后生效)。""" + session.delete(product) + session.flush() diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..f109341 --- /dev/null +++ b/backend/app/schemas/product.py @@ -0,0 +1,104 @@ +"""产品管理的请求/响应模型。 + +数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载: +前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门, +Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用 +蛇形直出(前端 TS 接口同构)。 +""" + +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + + +def _parse_non_negative(value: str, *, label: str) -> str: + """非负数字字符串校验:必须可被 Decimal 解析、为有限数且 ≥ 0。 + + ``NaN`` 参与比较会抛 InvalidOperation、``Infinity`` 会在后续运算溢出, + 均须在入口用 ``is_finite()`` 拦下(与 schemas/param.py 的 _parse_amount + 同一口径)。 + """ + try: + amount = Decimal(value.strip()) + except InvalidOperation as exc: + raise ValueError(f"{label}必须是有效数字:{value}") from exc + if not amount.is_finite(): + raise ValueError(f"{label}必须为有限数字:{value}") + if amount < 0: + raise ValueError(f"{label}不得为负数:{value}") + return value + + +class ProductOut(BaseModel): + """产品响应(列表与详情共用)。数量/单价按红线以字符串出参。""" + + id: int + product_name: str + spec_model: str + unit: str | None = None + stock_quantity: str | None = None + sale_price: str | None = None + product_category: str + status: int = 1 + remark: str | None = None + create_time: datetime + update_time: datetime + + +class ProductCreateIn(BaseModel): + """新增产品。 + + spec_model 允许缺省/空:service 层归一为空串(组合唯一键 + uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field(default=None, max_length=128, description="规格型号,空归一为空串") + unit: str | None = Field(default=None, max_length=16, description="计量单位") + stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串") + sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串") + product_category: str = Field(min_length=1, max_length=32, description="产品类别") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("stock_quantity") + @classmethod + def _check_stock_quantity(cls, value: str | None) -> str | None: + """空串与 None 同义(可空列),归一为 None;非空则须为非负数字。""" + if not value: + return None + return _parse_non_negative(value, label="数量") + + @field_validator("sale_price") + @classmethod + def _check_sale_price(cls, value: str | None) -> str | None: + """空串与 None 同义(可空列),归一为 None;非空则须为非负数字。""" + if not value: + return None + return _parse_non_negative(value, label="销售单价") + + +class ProductUpdateIn(ProductCreateIn): + """编辑产品。 + + 前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是 + 独立操作(走 PUT /{id}/status),避免编辑表单误触停用。 + """ + + +class ProductStatusIn(BaseModel): + """启用/停用产品。""" + + status: int = Field(ge=0, le=1, description="1启用 0停用") + + +class ProductQuery(PageQuery): + """产品列表查询条件。""" + + keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配") + product_category: str | None = Field(default=None, max_length=32, description="按类别筛选") + status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py new file mode 100644 index 0000000..2088906 --- /dev/null +++ b/backend/app/services/product_service.py @@ -0,0 +1,306 @@ +"""产品管理业务编排。 + +产品主数据的两条硬约束(违反会算错钱): + +- ``product_category`` 是**折算阈值的行级锚点**:混合业务按产品逐条计算 + (对照表 #14),阈值由 M1 参数 ``product.threshold`` 按 PRODUCT_CATEGORY + 维度承担。类别游离于合法值域之外时,核算按类别回溯参数会取不到阈值, + 故值域校验是业务规则而非格式校验 +- 数量/单价全程 ``Decimal``:schema 层已拦格式与非负(金额红线入口), + service 层负责字符串到 Decimal 的转换与落库 + +另:``spec_model`` 归一为**空串而非 NULL** —— 它参与 uk_products_name_spec +组合唯一键,MySQL 组合索引对 NULL 不去重,归 NULL 会放行重名同规格数据。 +删除为物理删除,被业务明细引用的保护检查待业务数据表(V5)就位后补充。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_DISABLE, + ACTION_ENABLE, + ACTION_UPDATE, + MODULE_PRODUCT, + PRODUCT_CATEGORIES, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Product, User +from app.repositories import product_repository +from app.schemas.product import ( + ProductCreateIn, + ProductOut, + ProductQuery, + ProductUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位) +_ERR_DUP_ENTRY = 1062 + + +def _to_out(product: Product) -> ProductOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return ProductOut( + id=product.id, + product_name=product.product_name, + spec_model=product.spec_model, + unit=product.unit, + stock_quantity=str(product.stock_quantity) if product.stock_quantity is not None else None, + sale_price=str(product.sale_price) if product.sale_price is not None else None, + product_category=product.product_category, + status=product.status, + remark=product.remark, + create_time=product.create_time, + update_time=product.update_time, + ) + + +def _to_decimal(value: str | None) -> Decimal | None: + """数字字符串转 Decimal(可空列的空串/None 透传 None)。 + + 入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float + 二进制近似,金额红线在最后一道转换处仍然成立。schema 已保证可解析, + 此处不再重复校验。 + """ + if not value: + return None + return Decimal(str(value)) + + +def _ensure_category(product_category: str) -> None: + """类别必须 ∈ PRODUCT_CATEGORIES(前端下拉仅是展示约束,后端校验是唯一防线)。""" + if product_category not in PRODUCT_CATEGORIES: + raise BadRequestError("产品类别不合法") + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。 + + 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 + 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 + """ + orig_args = getattr(exc.orig, "args", ()) + return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY + + +def _raise_if_duplicate(exc: IntegrityError) -> None: + """唯一键冲突统一转业务提示;其余完整性错误原样重抛。""" + if _is_duplicate_entry(exc): + raise BadRequestError("已存在同名称同规格的产品") from exc + raise exc + + +def list_products(session: Session, query: ProductQuery) -> PageResult[ProductOut]: + """分页查询产品(含停用)。""" + products = product_repository.list_products( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + product_category=query.product_category, + status=query.status, + ) + total = product_repository.count_products( + session, + keyword=query.keyword, + product_category=query.product_category, + status=query.status, + ) + return PageResult[ProductOut]( + records=[_to_out(product) for product in products], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_product(session: Session, product_id: int) -> ProductOut: + """产品详情。 + + :raises NotFoundError: 产品不存在 + """ + product = product_repository.get_by_id(session, product_id) + if product is None: + raise NotFoundError("产品不存在") + return _to_out(product) + + +def create_product( + session: Session, + payload: ProductCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """新增产品。类别须在合法值域内,新产品固定启用。 + + :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 + """ + _ensure_category(payload.product_category) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_CREATE, + summary=f"新增产品 {payload.product_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重 + if product_repository.get_by_name_spec(session, payload.product_name, spec_model) is not None: + raise BadRequestError("已存在同名称同规格的产品") + + product = Product( + product_name=payload.product_name, + spec_model=spec_model, + unit=payload.unit, + stock_quantity=_to_decimal(payload.stock_quantity), + sale_price=_to_decimal(payload.sale_price), + product_category=payload.product_category, + status=1, + remark=payload.remark, + ) + try: + product_repository.create(session, product) + # 响应与后置动作全部在 commit 之前完成(见 business_role_service 同处说明) + out = _to_out(product) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同名称同规格会撞 uk_products_name_spec + session.rollback() + _raise_if_duplicate(exc) + + logger.info( + "新增产品 | product_id=%s | category=%s", product.id, payload.product_category + ) + return out + + +def update_product( + session: Session, + product_id: int, + payload: ProductUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """编辑产品基本信息(全量替换,不含状态;启停走独立端点)。 + + :raises NotFoundError: 产品不存在 + :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 + """ + product = product_repository.get_by_id(session, product_id) + _ensure_category(payload.product_category) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_UPDATE, + summary=f"修改产品 {product.product_name if product else f'#{product_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if product is None: + raise NotFoundError("产品不存在") + + product.product_name = payload.product_name + product.spec_model = payload.spec_model or "" + product.unit = payload.unit + product.stock_quantity = _to_decimal(payload.stock_quantity) + product.sale_price = _to_decimal(payload.sale_price) + product.product_category = payload.product_category + product.remark = payload.remark + out = _to_out(product) + try: + session.commit() + except IntegrityError as exc: + # 改名/改规格可能撞 uk_products_name_spec(本行改值与他人既有数据重合) + session.rollback() + _raise_if_duplicate(exc) + + logger.info("修改产品 | product_id=%s | category=%s", product_id, payload.product_category) + return out + + +def set_status( + session: Session, + product_id: int, + status: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ProductOut: + """启用或停用产品。停用为主(模型约定),物理删除仅限误录数据。 + + :raises NotFoundError: 产品不存在 + """ + product = product_repository.get_by_id(session, product_id) + verb = "启用" if status == 1 else "停用" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_ENABLE if status == 1 else ACTION_DISABLE, + summary=f"{verb}产品 {product.product_name if product else f'#{product_id}'}", + request_data={"status": status}, + ), + meta=meta, + ): + if product is None: + raise NotFoundError("产品不存在") + + product.status = status + out = _to_out(product) + session.commit() + logger.info("变更产品状态 | product_id=%s | status=%s", product_id, status) + return out + + +def delete_product( + session: Session, + product_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """物理删除产品。 + + **被业务明细(发货/回款)引用的保护检查待业务数据表(V5)就位后补充**; + 当前先以 ``FOR UPDATE`` 锁行删除 —— V5 接入后,校验与删除之间的并发 + 引用会因锁可见地失败,而非引用被静默悬空。 + + :raises NotFoundError: 产品不存在 + """ + product = product_repository.get_by_id(session, product_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_PRODUCT, + action=ACTION_DELETE, + summary=f"删除产品 {product.product_name if product else f'#{product_id}'}", + ), + meta=meta, + ): + if product is None: + raise NotFoundError("产品不存在") + + product_name = product.product_name # 固化:对象随删除失效 + product_repository.delete(session, product) + session.commit() + logger.info("删除产品 | product_name=%s", product_name) -- 2.27.0 From b8ba9e46266e2f4321e6e9f71bf22c3cf7abf6a7 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 10:35:04 +0800 Subject: [PATCH 022/100] =?UTF-8?q?feat(base):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=E4=B8=8E=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E7=95=99=E7=97=95=EF=BC=88=E4=B8=8D=E5=9B=9E?= =?UTF-8?q?=E6=BA=AF=E5=8E=86=E5=8F=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 归属变更同事务更新主表+插留痕;归属调整唯一入口防旁路 Co-Authored-By: Claude Code --- backend/app/api/v1/base/customers.py | 142 ++++++ .../app/repositories/customer_repository.py | 181 ++++++++ backend/app/schemas/customer.py | 147 ++++++ backend/app/services/customer_service.py | 425 ++++++++++++++++++ 4 files changed, 895 insertions(+) create mode 100644 backend/app/api/v1/base/customers.py create mode 100644 backend/app/repositories/customer_repository.py create mode 100644 backend/app/schemas/customer.py create mode 100644 backend/app/services/customer_service.py diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py new file mode 100644 index 0000000..7827b4f --- /dev/null +++ b/backend/app/api/v1/base/customers.py @@ -0,0 +1,142 @@ +"""客户管理接口(需求 2.1.3)。 + +归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口, +编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路 +(原因详见 `schemas.customer.CustomerUpdateIn`)。 + +与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageQuery, PageResult, ok +from app.models import User +from app.schemas.customer import ( + CustomerCreateIn, + CustomerOut, + CustomerOwnershipIn, + CustomerQuery, + CustomerUpdateIn, + OwnershipChangeLogOut, +) +from app.services import customer_service + +router = APIRouter(prefix="/customers", tags=["客户管理"]) + +# 基础数据不要求管理员(见模块 docstring),登录用户即可操作 +CurrentUser = Annotated[User, Depends(get_current_user)] + + +@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表") +def list_customers( + session: DbSession, + _: CurrentUser, + query: Annotated[CustomerQuery, Query()], +) -> ApiResponse[PageResult[CustomerOut]]: + """分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。""" + return ok(customer_service.list_customers(session, query)) + + +@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户") +def create_customer( + session: DbSession, + current_user: CurrentUser, + payload: CustomerCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。""" + return ok( + customer_service.create_customer(session, payload, operator=current_user, meta=meta), + message="客户已创建", + ) + + +@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情") +def get_customer( + session: DbSession, + _: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[CustomerOut]: + """按主键查询客户详情。""" + return ok(customer_service.get_customer(session, customer_id)) + + +@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户") +def update_customer( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """编辑客户档案。 + + **不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使 + 提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。 + """ + return ok( + customer_service.update_customer( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户") +def delete_customer( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除客户(物理删除)。 + + 被业务数据引用的保护检查待业务数据表(V5)就位后补充。 + """ + customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta) + return ok(message="客户已删除") + + +@router.put( + "/{customer_id}/ownership", + response_model=ApiResponse[CustomerOut], + summary="归属变更", +) +def change_ownership( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerOwnershipIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerOut]: + """调整客户归属(唯一写归属留痕的入口)。 + + 只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null + 表示清空归属。新旧归属相同报「归属未变化」。 + """ + return ok( + customer_service.change_ownership( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="归属已调整", + ) + + +@router.get( + "/{customer_id}/ownership-logs", + response_model=ApiResponse[PageResult[OwnershipChangeLogOut]], + summary="归属变更留痕", +) +def list_ownership_logs( + session: DbSession, + _: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: + """查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。""" + return ok(customer_service.list_ownership_logs(session, customer_id, query)) diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py new file mode 100644 index 0000000..0471601 --- /dev/null +++ b/backend/app/repositories/customer_repository.py @@ -0,0 +1,181 @@ +"""客户数据访问(含归属变更留痕)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import ColumnElement, Row, Select, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.models import Customer, OwnershipChangeLog, Personnel +from app.models.customer import OWNER_TYPE_CUSTOMER +from app.repositories.query_utils import fuzzy + +# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出, +# 人员被删除后姓名为 null,行仍完整可读 +_old_owner = aliased(Personnel) +_new_owner = aliased(Personnel) + +# ==================== 读取 ==================== + + +def get_by_id(session: Session, customer_id: int) -> Customer | None: + """按主键查询客户。""" + return session.get(Customer, customer_id) + + +def get_with_owner_name(session: Session, customer_id: int) -> Row | None: + """查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" + stmt = _base_select().where(Customer.id == customer_id) + return session.execute(stmt).first() + + +def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None: + """按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。""" + stmt = select(Customer).where( + Customer.company_name == company_name, + Customer.owner_name == owner_name, + ) + return session.execute(stmt).scalar_one_or_none() + + +def list_customers( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None = None, + customer_type: str | None = None, + owner_personnel_id: int | None = None, +) -> list[Row]: + """分页查询客户,行内容为 (客户, 归属人员姓名)。 + + 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) + 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 + 排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 + """ + stmt = _apply_filters( + _base_select(), + keyword=keyword, + customer_type=customer_type, + owner_personnel_id=owner_personnel_id, + ) + stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit) + return list(session.execute(stmt).all()) + + +def count_customers( + session: Session, + *, + keyword: str | None = None, + customer_type: str | None = None, + owner_personnel_id: int | None = None, +) -> int: + """统计符合条件的客户总数,供分页使用。 + + 筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由 + ``_apply_filters`` 单点保证)。 + """ + stmt = _apply_filters( + select(func.count(Customer.id)), + keyword=keyword, + customer_type=customer_type, + owner_personnel_id=owner_personnel_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_ownership_logs( + session: Session, + *, + customer_id: int, + offset: int, + limit: int, +) -> list[Row]: + """查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。 + + 按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条 + 记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。 + """ + stmt = ( + select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name) + .outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id) + .outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id) + .where( + OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER, + OwnershipChangeLog.customer_id == customer_id, + ) + .order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc()) + .offset(offset) + .limit(limit) + ) + return list(session.execute(stmt).all()) + + +def count_ownership_logs(session: Session, *, customer_id: int) -> int: + """统计某客户的归属留痕总数,供分页使用。""" + stmt = select(func.count(OwnershipChangeLog.id)).where( + OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER, + OwnershipChangeLog.customer_id == customer_id, + ) + return int(session.execute(stmt).scalar_one()) + + +def _base_select() -> Select: + """客户主表外联归属人员姓名。""" + return select(Customer, Personnel.personnel_name).outerjoin( + Personnel, Customer.owner_personnel_id == Personnel.id + ) + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + customer_type: str | None, + owner_personnel_id: int | None, +) -> Select: + """应用通用筛选条件。列表与计数共用,避免两处口径不一致。""" + if keyword: + stmt = stmt.where(_keyword_clause(keyword)) + if customer_type: + stmt = stmt.where(Customer.customer_type == customer_type) + if owner_personnel_id is not None: + stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id) + return stmt + + +def _keyword_clause(keyword: str) -> ColumnElement[bool]: + """关键词条件:公司名称或客户负责人两列 OR 模糊。 + + 借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做 + OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有 + 一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。 + """ + probe = select(Customer.id) + return or_( + fuzzy(probe, Customer.company_name, keyword).whereclause, + fuzzy(probe, Customer.owner_name, keyword).whereclause, + ) + + +# ==================== 写入 ==================== + + +def create(session: Session, customer: Customer) -> Customer: + """新增客户。只 flush 以取得自增 id。""" + session.add(customer) + session.flush() + return customer + + +def delete(session: Session, customer: Customer) -> None: + """删除客户。调用方须先完成引用保护校验。""" + session.delete(customer) + session.flush() + + +def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: + """写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。""" + session.add(log) + session.flush() + return log diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py new file mode 100644 index 0000000..3c905d6 --- /dev/null +++ b/backend/app/schemas/customer.py @@ -0,0 +1,147 @@ +"""客户管理的请求/响应模型(需求 2.1.3)。""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.response import PageQuery + + +class CustomerOut(BaseModel): + """客户响应(列表、详情与写操作回包共用)。 + + owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的 + 脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕 + 「ID 固化、姓名随联表」同一口径。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + company_name: str + owner_name: str + contact_person: str | None = None + contact_phone: str | None = None + address: str | None = None + industry: str | None = None + customer_type: str + owner_personnel_id: int | None = None + owner_personnel_name: str | None = None + remark: str | None = None + + +class CustomerCreateIn(BaseModel): + """新增客户。 + + owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法); + 归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见 + ``CustomerUpdateIn`` 的说明)。camelCase 别名与蛇形字段名均可绑定, + 兼容前端两种拼写。 + """ + + model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True) + + company_name: str = Field(min_length=1, max_length=128, description="公司名称") + owner_name: str = Field( + min_length=1, + max_length=64, + description="客户负责人姓名(客户公司的负责人人名,非系统内营销人员)", + ) + contact_person: str | None = Field(default=None, max_length=64, description="联系人") + contact_phone: str | None = Field(default=None, max_length=20, description="联系电话") + address: str | None = Field(default=None, max_length=255, description="地址") + industry: str | None = Field(default=None, max_length=64, description="所属行业") + customer_type: str = Field( + min_length=1, + max_length=32, + description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户", + ) + owner_personnel_id: int | None = Field( + default=None, + ge=1, + alias="ownerPersonnelId", + description="归属负责营销人员ID(初始归属,可空)", + ) + remark: str | None = Field(default=None, max_length=255, description="备注") + + +class CustomerUpdateIn(BaseModel): + """编辑客户(全量替换,前端表单全量提交)。 + + **不含 owner_personnel_id** —— 归属调整统一走 ``PUT /{id}/ownership``, + 只有那条路径才写归属变更留痕。若在本端点放开该字段,会出现「改了归属 + 却无留痕」的旁路,破坏留痕的 append-only 完整性;编辑表单即使提交 + ownerPersonnelId,pydantic 默认忽略未声明字段,归属以归属端点为准。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + company_name: str = Field(min_length=1, max_length=128, description="公司名称") + owner_name: str = Field(min_length=1, max_length=64, description="客户负责人姓名") + contact_person: str | None = Field(default=None, max_length=64, description="联系人") + contact_phone: str | None = Field(default=None, max_length=20, description="联系电话") + address: str | None = Field(default=None, max_length=255, description="地址") + industry: str | None = Field(default=None, max_length=64, description="所属行业") + customer_type: str = Field( + min_length=1, + max_length=32, + description="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户", + ) + remark: str | None = Field(default=None, max_length=255, description="备注") + + +class CustomerQuery(PageQuery): + """客户列表查询条件。 + + customer_type / owner_personnel_id 同时接受蛇形(项目既有契约,见前端 + `types.ts` 的说明)与驼峰别名,两种拼写不并存即可。 + """ + + keyword: str | None = Field( + default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" + ) + customer_type: str | None = Field( + default=None, max_length=32, alias="customerType", description="客户分类筛选" + ) + owner_personnel_id: int | None = Field( + default=None, ge=1, alias="ownerPersonnelId", description="归属人员ID筛选" + ) + + +class CustomerOwnershipIn(BaseModel): + """归属变更入参。 + + new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。 + """ + + model_config = ConfigDict(populate_by_name=True) + + new_owner_personnel_id: int | None = Field( + default=None, + ge=1, + alias="newOwnerPersonnelId", + description="变更后归属人员ID,null=清空归属", + ) + reason: str | None = Field(default=None, max_length=255, description="变更原因") + + +class OwnershipChangeLogOut(BaseModel): + """归属变更留痕响应。 + + 新旧归属人员姓名由 LEFT JOIN 带出,人员被删除后为 null;固化的 + ``*_personnel_id`` 始终原样返回 —— 留痕表对人员 ID 无外键固化 + (见 `models/customer.py` 的论证),姓名可随人员消失,ID 不可。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + customer_id: int + old_owner_personnel_id: int | None = None + new_owner_personnel_id: int | None = None + old_owner_name: str | None = None + new_owner_name: str | None = None + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py new file mode 100644 index 0000000..c4c89ad --- /dev/null +++ b/backend/app/services/customer_service.py @@ -0,0 +1,425 @@ +"""客户管理业务编排(需求 2.1.3)。 + +**事务边界在本层**:repository 只 flush,commit/rollback 只写在这里。 + +- 「公司名称 + 负责人」组合唯一(uk_customers_company_owner):先查后写不是 + 原子操作,并发提交撞唯一键时捕 IntegrityError 判 1062 翻译为 400 + (同 M1 的并发兜底模式) +- **归属变更的裁决语义是「只影响变更后核算,不回溯历史」(对照表 #19)**: + 实现为同一事务内「更新 customers.owner_personnel_id + 插入一条 + ownership_change_logs」,一次 commit —— 两步拆开提交时,中途失败会出现 + 「主表已换人、留痕缺失」的静默不一致,事后追溯无据可查 +- 归属调整只有 ``change_ownership`` 一条路径;编辑端点不触归属, + 防止出现「改归属不留痕」的旁路 +""" + +import logging + +from sqlalchemy import Row +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + CUSTOMER_TYPES, + MODULE_CUSTOMER, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageQuery, PageResult +from app.models import Customer, OwnershipChangeLog, Personnel, User +from app.models.customer import OWNER_TYPE_CUSTOMER +from app.repositories import customer_repository, personnel_repository +from app.schemas.customer import ( + CustomerCreateIn, + CustomerOut, + CustomerOwnershipIn, + CustomerQuery, + CustomerUpdateIn, + OwnershipChangeLogOut, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被 +# 客户归属引用;人员侧抽出共享常量后此处替换 +_PERSONNEL_STATUS_ACTIVE = 1 + + +def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" + return CustomerOut( + id=customer.id, + company_name=customer.company_name, + owner_name=customer.owner_name, + contact_person=customer.contact_person, + contact_phone=customer.contact_phone, + address=customer.address, + industry=customer.industry, + customer_type=customer.customer_type, + owner_personnel_id=customer.owner_personnel_id, + owner_personnel_name=owner_name, + remark=customer.remark, + ) + + +def _to_log_out(row: Row) -> OwnershipChangeLogOut: + """留痕行 (留痕, 旧归属人名, 新归属人名) 转响应模型。""" + log, old_name, new_name = row + return OwnershipChangeLogOut( + id=log.id, + customer_id=log.customer_id, + old_owner_personnel_id=log.old_owner_personnel_id, + new_owner_personnel_id=log.new_owner_personnel_id, + old_owner_name=old_name, + new_owner_name=new_name, + change_reason=log.change_reason, + operator_id=log.operator_id, + operator_name=log.operator_name, + create_time=log.create_time, + ) + + +def _ensure_customer_type(customer_type: str) -> None: + """客户分类必须是注册表内的三个值之一(前端为受控下拉,后端仍须校验)。 + + customer_type 是 M3 计提规则匹配的锚点,脏值会让该客户在核算时匹配不到 + 任何计提规则而静默漏算。 + """ + if customer_type not in CUSTOMER_TYPES: + raise BadRequestError("客户分类不合法") + + +def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel: + """归属人员必须存在且在职,返回人员行。 + + 「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要 + 向调用方区分的信息(同登录防枚举的思路)。 + + :raises BadRequestError: 归属人员不存在或已离职 + """ + person = personnel_repository.get_by_id(session, personnel_id) + if person is None or person.status != _PERSONNEL_STATUS_ACTIVE: + raise BadRequestError("归属人员不存在或已离职") + return person + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。 + + customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景 + 会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏; + 非 1062 原样上抛,由全局处理器按 500 收敛。 + """ + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == 1062 + + +def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: + """分页查询客户(附归属人员姓名)。""" + rows = customer_repository.list_customers( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + customer_type=query.customer_type, + owner_personnel_id=query.owner_personnel_id, + ) + total = customer_repository.count_customers( + session, + keyword=query.keyword, + customer_type=query.customer_type, + owner_personnel_id=query.owner_personnel_id, + ) + return PageResult[CustomerOut]( + records=[_to_out(customer, owner_name) for customer, owner_name in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_customer(session: Session, customer_id: int) -> CustomerOut: + """客户详情。 + + :raises NotFoundError: 客户不存在 + """ + row = customer_repository.get_with_owner_name(session, customer_id) + if row is None: + raise NotFoundError("客户不存在") + customer, owner_name = row + return _to_out(customer, owner_name) + + +def create_customer( + session: Session, + payload: CustomerCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """新增客户。 + + :raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 / + 「公司名称+负责人」已存在(含并发兜底) + """ + _ensure_customer_type(payload.customer_type) + owner_name: str | None = None + if payload.owner_personnel_id is not None: + owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_CREATE, + summary=f"新增客户 {payload.company_name}({payload.owner_name})", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if ( + customer_repository.get_by_company_owner( + session, payload.company_name, payload.owner_name + ) + is not None + ): + raise BadRequestError("该公司名称与负责人的客户已存在") + + customer = Customer( + company_name=payload.company_name, + owner_name=payload.owner_name, + contact_person=payload.contact_person, + contact_phone=payload.contact_phone, + address=payload.address, + industry=payload.industry, + customer_type=payload.customer_type, + owner_personnel_id=payload.owner_personnel_id, + remark=payload.remark, + ) + try: + customer_repository.create(session, customer) + # 响应在 commit 之前构造(同 user_service 顶部说明) + out = _to_out(customer, owner_name) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同一「公司名称+负责人」会撞 + # uk_customers_company_owner,回滚并翻译为可读的 400,而非 500 + session.rollback() + if _is_duplicate_entry(exc): + raise BadRequestError("该公司名称与负责人的客户已存在") from exc + raise + + logger.info("新增客户 | customer_id=%s | company=%s", customer.id, payload.company_name) + return out + + +def update_customer( + session: Session, + customer_id: int, + payload: CustomerUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """编辑客户档案(全量替换)。 + + **不触归属**:owner_personnel_id 保持原值,调整统一走 + ``PUT /{id}/ownership``(唯一写留痕的路径),见 ``CustomerUpdateIn`` 说明。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底) + """ + _ensure_customer_type(payload.customer_type) + # 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录, + # 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出 + row = customer_repository.get_with_owner_name(session, customer_id) + customer = row[0] if row else None + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_UPDATE, + summary=f"修改客户 {customer.company_name if customer else f'#{customer_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("客户不存在") + customer, owner_name = row + + duplicate = customer_repository.get_by_company_owner( + session, payload.company_name, payload.owner_name + ) + if duplicate is not None and duplicate.id != customer_id: + raise BadRequestError("该公司名称与负责人的客户已存在") + + customer.company_name = payload.company_name + customer.owner_name = payload.owner_name + customer.contact_person = payload.contact_person + customer.contact_phone = payload.contact_phone + customer.address = payload.address + customer.industry = payload.industry + customer.customer_type = payload.customer_type + # owner_personnel_id 刻意不赋值 —— 归属调整只走 /ownership + + try: + # 响应在 commit 之前构造(同 user_service 顶部说明) + out = _to_out(customer, owner_name) + session.commit() + except IntegrityError as exc: + # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 + # 的并发窗口会撞唯一键,兜底同 create_customer + session.rollback() + if _is_duplicate_entry(exc): + raise BadRequestError("该公司名称与负责人的客户已存在") from exc + raise + + logger.info("修改客户 | customer_id=%s | company=%s", customer_id, payload.company_name) + return out + + +def delete_customer( + session: Session, + customer_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除客户(物理删除)。 + + **被业务数据引用的保护检查待业务数据表(V5)就位后补充**:customers + 当前仅被 ownership_change_logs 以无外键的固化 ID 引用,而留痕本就要求 + 「被引用方删除后记录仍完整可读」(见 models/customer.py 的固化论证), + 故现在删除不会产生悬挂外键。 + + :raises NotFoundError: 客户不存在 + """ + customer = customer_repository.get_by_id(session, customer_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_DELETE, + summary=f"删除客户 {customer.company_name if customer else f'#{customer_id}'}", + ), + meta=meta, + ): + if customer is None: + raise NotFoundError("客户不存在") + + company_name = customer.company_name # 固化:对象随删除失效 + customer_repository.delete(session, customer) + session.commit() + logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) + + +def change_ownership( + session: Session, + customer_id: int, + payload: CustomerOwnershipIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerOut: + """归属变更:同一事务内「改主表归属 + 插留痕」,一次 commit。 + + 裁决口径为「只影响变更后核算,不回溯历史」(对照表 #19):历史核算结果 + 已由 M3 台账固化,留痕仅承担追溯职责,故无 effective_date 时点化设计。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 + """ + # 只读预取在审计上下文之前:摘要需要客户名与新旧归属人名;校验仍在 + # 上下文内进行,失败同样要留下可追溯的记录 + row = customer_repository.get_with_owner_name(session, customer_id) + old_person = ( + personnel_repository.get_by_id(session, row[0].owner_personnel_id) + if row is not None and row[0].owner_personnel_id is not None + else None + ) + new_person = ( + personnel_repository.get_by_id(session, payload.new_owner_personnel_id) + if payload.new_owner_personnel_id is not None + else None + ) + old_label = old_person.personnel_name if old_person else "无" + new_label = new_person.personnel_name if new_person else "无" + customer_name = row[0].company_name if row else f"#{customer_id}" + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_CUSTOMER, + action=ACTION_UPDATE, + summary=f"调整客户归属 {customer_name}:{old_label} → {new_label}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("客户不存在") + customer, _ = row + + if payload.new_owner_personnel_id is not None: + _ensure_owner_personnel(session, payload.new_owner_personnel_id) + old_owner_id = customer.owner_personnel_id + if old_owner_id == payload.new_owner_personnel_id: + raise BadRequestError("归属未变化") + + # 主表更新与留痕插入同事务:只在本方法内一次 commit(见模块 docstring) + customer.owner_personnel_id = payload.new_owner_personnel_id + customer_repository.add_ownership_log( + session, + OwnershipChangeLog( + owner_type=OWNER_TYPE_CUSTOMER, + customer_id=customer.id, + old_owner_personnel_id=old_owner_id, + new_owner_personnel_id=payload.new_owner_personnel_id, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 + out = _to_out(customer, new_person.personnel_name if new_person else None) + session.commit() + logger.info( + "调整客户归属 | customer_id=%s | old=%s | new=%s", + customer_id, + old_owner_id, + payload.new_owner_personnel_id, + ) + return out + + +def list_ownership_logs( + session: Session, + customer_id: int, + query: PageQuery, +) -> PageResult[OwnershipChangeLogOut]: + """某客户的归属变更留痕(分页,按变更时间倒序)。 + + :raises NotFoundError: 客户不存在 + """ + if customer_repository.get_by_id(session, customer_id) is None: + raise NotFoundError("客户不存在") + rows = customer_repository.list_ownership_logs( + session, + customer_id=customer_id, + offset=query.offset, + limit=query.page_size, + ) + total = customer_repository.count_ownership_logs(session, customer_id=customer_id) + return PageResult[OwnershipChangeLogOut]( + records=[_to_log_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) -- 2.27.0 From 15de81dd2cdcb59d4b80d0b9edb54aec0d9babd2 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 10:35:04 +0800 Subject: [PATCH 023/100] =?UTF-8?q?feat(base):=20=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=E8=B7=AF=E7=94=B1=20/api/v?= =?UTF-8?q?1/base/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/app/api/router.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 392453d..4d94019 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,9 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.base import customers as base_customers +from app.api.v1.base import personnel as base_personnel +from app.api.v1.base import products as base_products from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import logs as system_logs from app.api.v1.system import params as system_params @@ -33,4 +36,11 @@ system_router.include_router(system_logs.operation_router) system_router.include_router(system_logs.login_router) v1_router.include_router(system_router) +# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问 +base_router = APIRouter(prefix="/base") +base_router.include_router(base_personnel.router) +base_router.include_router(base_products.router) +base_router.include_router(base_customers.router) +v1_router.include_router(base_router) + api_router.include_router(v1_router) -- 2.27.0 From ca88ee7a0b621bbb2963e85818ad0b4d498a1602 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 11:22:34 +0800 Subject: [PATCH 024/100] =?UTF-8?q?test(base):=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E9=9B=86?= =?UTF-8?q?=E6=88=90=E7=94=A8=E4=BE=8B=EF=BC=8848=20=E4=BE=8B=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E8=AE=A1=E7=9B=B2=E5=8C=BA=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录 - 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文, 校验失败(400)同样留痕(成功与失败都记的既定原则) - conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理) Co-Authored-By: Claude Code --- backend/app/api/v1/base/personnel.py | 4 +- backend/app/api/v1/base/products.py | 4 +- .../app/repositories/product_repository.py | 4 +- backend/app/schemas/personnel.py | 4 +- backend/app/schemas/product.py | 4 +- backend/app/services/customer_service.py | 13 +- backend/app/services/personnel_service.py | 23 +- backend/app/services/product_service.py | 15 +- backend/tests/integration/conftest.py | 58 ++- backend/tests/integration/test_customers.py | 352 +++++++++++++++ backend/tests/integration/test_personnel.py | 400 ++++++++++++++++++ backend/tests/integration/test_products.py | 238 +++++++++++ 12 files changed, 1090 insertions(+), 29 deletions(-) create mode 100644 backend/tests/integration/test_customers.py create mode 100644 backend/tests/integration/test_personnel.py create mode 100644 backend/tests/integration/test_products.py diff --git a/backend/app/api/v1/base/personnel.py b/backend/app/api/v1/base/personnel.py index 9b305c2..2210514 100644 --- a/backend/app/api/v1/base/personnel.py +++ b/backend/app/api/v1/base/personnel.py @@ -137,5 +137,7 @@ def replace_plans( meta: RequestMetaDep, ) -> ApiResponse[None]: """全量替换拓展计划,空列表即清空。""" - personnel_service.replace_plans(session, personnel_id, payload, operator=current_user, meta=meta) + personnel_service.replace_plans( + session, personnel_id, payload, operator=current_user, meta=meta + ) return ok(message="已保存") diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py index 7b12e8e..fce3f27 100644 --- a/backend/app/api/v1/base/products.py +++ b/backend/app/api/v1/base/products.py @@ -97,9 +97,7 @@ def delete_product( return ok(message="产品已删除") -@router.put( - "/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品" -) +@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品") def set_status( session: DbSession, current_user: CurrentUser, diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py index a574538..b399362 100644 --- a/backend/app/repositories/product_repository.py +++ b/backend/app/repositories/product_repository.py @@ -21,9 +21,7 @@ def get_by_id(session: Session, product_id: int, *, for_update: bool = False) -> return session.get(Product, product_id) -def get_by_name_spec( - session: Session, product_name: str, spec_model: str -) -> Product | None: +def get_by_name_spec(session: Session, product_name: str, spec_model: str) -> Product | None: """按 (名称, 规格型号) 组合查询。 组合是 uk_products_name_spec 唯一键的键值,先查用于给出即时业务提示; diff --git a/backend/app/schemas/personnel.py b/backend/app/schemas/personnel.py index 12032cc..b2cfa3f 100644 --- a/backend/app/schemas/personnel.py +++ b/backend/app/schemas/personnel.py @@ -70,7 +70,9 @@ class PersonnelCreateIn(BaseModel): phone: str | None = Field(default=None, max_length=20, description="联系电话") hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否") - salary_standard: str | None = Field(default=None, description="月工资标准(元),字符串形式的数字") + salary_standard: str | None = Field( + default=None, description="月工资标准(元),字符串形式的数字" + ) remark: str | None = Field(default=None, max_length=255) @field_validator("salary_standard") diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index f109341..67e6639 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -58,7 +58,9 @@ class ProductCreateIn(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) product_name: str = Field(min_length=1, max_length=128, description="产品名称") - spec_model: str | None = Field(default=None, max_length=128, description="规格型号,空归一为空串") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空归一为空串" + ) unit: str | None = Field(default=None, max_length=16, description="计量单位") stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串") sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串") diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index c4c89ad..c4da6e7 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -166,10 +166,6 @@ def create_customer( :raises BadRequestError: 客户分类不合法 / 归属人员不存在或已离职 / 「公司名称+负责人」已存在(含并发兜底) """ - _ensure_customer_type(payload.customer_type) - owner_name: str | None = None - if payload.owner_personnel_id is not None: - owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name with audit_service.audit( session, operator=operator, @@ -181,6 +177,12 @@ def create_customer( ), meta=meta, ): + # 值域与归属校验在审计上下文内:失败(400)也要留痕 —— + # 与 personnel_service._validate_refs 移入上下文的口径一致 + _ensure_customer_type(payload.customer_type) + owner_name: str | None = None + if payload.owner_personnel_id is not None: + owner_name = _ensure_owner_personnel(session, payload.owner_personnel_id).personnel_name if ( customer_repository.get_by_company_owner( session, payload.company_name, payload.owner_name @@ -233,7 +235,6 @@ def update_customer( :raises NotFoundError: 客户不存在 :raises BadRequestError: 客户分类不合法 / 「公司名称+负责人」已存在(含并发兜底) """ - _ensure_customer_type(payload.customer_type) # 只读预取在审计上下文之前:目标不存在(NotFound 失败)时也能留下可读记录, # 摘要与回包都要用客户行;归属姓名本端点不变更,一并带出 row = customer_repository.get_with_owner_name(session, customer_id) @@ -249,6 +250,8 @@ def update_customer( ), meta=meta, ): + # 值域校验在审计上下文内:分类非法的 400 也要留痕(同 create 口径) + _ensure_customer_type(payload.customer_type) if row is None: raise NotFoundError("客户不存在") customer, owner_name = row diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py index f58e91d..2a06778 100644 --- a/backend/app/services/personnel_service.py +++ b/backend/app/services/personnel_service.py @@ -67,7 +67,10 @@ def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[Person business_role_id=query.business_role_id, ) return PageResult[PersonnelOut]( - records=[_to_out(personnel, role_name, manager_name) for personnel, role_name, manager_name in rows], + records=[ + _to_out(personnel, role_name, manager_name) + for personnel, role_name, manager_name in rows + ], total=total, page_num=query.page_num, page_size=query.page_size, @@ -107,7 +110,6 @@ def create_personnel( :raises BadRequestError: 岗位不存在或已停用 / 直属经理不合法 """ - role, manager_name = _validate_refs(session, payload.business_role_id, payload.manager_id, None) with audit_service.audit( session, operator=operator, @@ -119,6 +121,12 @@ def create_personnel( ), meta=meta, ): + # 引用校验放在审计上下文内:失败同样要留痕(与 update_personnel 口径 + # 一致)——「谁在何时试图把人挂到停用岗位/离职经理上」是审计对象, + # 校验留在上下文外会让这类失败操作无据可查 + role, manager_name = _validate_refs( + session, payload.business_role_id, payload.manager_id, None + ) personnel = Personnel( personnel_name=payload.personnel_name, business_role_id=payload.business_role_id, @@ -221,10 +229,9 @@ def delete_personnel( raise NotFoundError("人员不存在") ids = [personnel_id] - if ( - personnel_repository.count_team_members(session, ids) - or personnel_repository.count_customer_ownerships(session, ids) - ): + if personnel_repository.count_team_members( + session, ids + ) or personnel_repository.count_customer_ownerships(session, ids): raise BadRequestError(_DELETE_BLOCKED) name = personnel.personnel_name # 固化:对象随删除失效 @@ -302,7 +309,9 @@ def replace_regions( if row is None: raise NotFoundError("人员不存在") rows = [ - PersonnelRegion(personnel_id=personnel_id, region_name=item.region_name, remark=item.remark) + PersonnelRegion( + personnel_id=personnel_id, region_name=item.region_name, remark=item.remark + ) for item in payload.items ] personnel_repository.replace_regions(session, personnel_id, rows) diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 2088906..fbb3cce 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -146,7 +146,6 @@ def create_product( :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 """ - _ensure_category(payload.product_category) with audit_service.audit( session, operator=operator, @@ -158,8 +157,13 @@ def create_product( ), meta=meta, ): + # 值域校验在审计上下文内:类别非法的 400 也要留痕(成功与失败都记) + _ensure_category(payload.product_category) spec_model = payload.spec_model or "" # 归一空串:组合唯一键对 NULL 不去重 - if product_repository.get_by_name_spec(session, payload.product_name, spec_model) is not None: + if ( + product_repository.get_by_name_spec(session, payload.product_name, spec_model) + is not None + ): raise BadRequestError("已存在同名称同规格的产品") product = Product( @@ -182,9 +186,7 @@ def create_product( session.rollback() _raise_if_duplicate(exc) - logger.info( - "新增产品 | product_id=%s | category=%s", product.id, payload.product_category - ) + logger.info("新增产品 | product_id=%s | category=%s", product.id, payload.product_category) return out @@ -202,7 +204,6 @@ def update_product( :raises BadRequestError: 产品类别不合法 / 已存在同名称同规格的产品 """ product = product_repository.get_by_id(session, product_id) - _ensure_category(payload.product_category) with audit_service.audit( session, operator=operator, @@ -214,6 +215,8 @@ def update_product( ), meta=meta, ): + # 值域校验在审计上下文内:类别非法的 400 也要留痕(同 create 口径) + _ensure_category(payload.product_category) if product is None: raise NotFoundError("产品不存在") diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index be9fe07..6323f94 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -7,6 +7,9 @@ - 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有 autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下, 失败中断时会残留并影响后续用例的 total 断言) +- M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀, + 兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除 + 消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言 - 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id`` 会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态 """ @@ -14,12 +17,24 @@ import uuid import pytest -from sqlalchemy import delete, select +from sqlalchemy import delete, select, update from app.core.constants import ROLE_ADMIN, ROLE_USER from app.core.db import get_session_factory from app.core.security import create_access_token, hash_password -from app.models import OperationLog, Role, User, UserRole +from app.models import ( + BusinessRole, + Customer, + OperationLog, + OwnershipChangeLog, + Personnel, + PersonnelPlan, + PersonnelRegion, + Product, + Role, + User, + UserRole, +) TEST_PASSWORD = "TestPwd@2026" # 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希 @@ -53,6 +68,45 @@ def _purge_test_data() -> None: if role_ids: session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) session.execute(delete(Role).where(Role.id.in_(role_ids))) + + # ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ---- + # 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清, + # 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品 + personnel_ids = list( + session.execute( + select(Personnel.id).where(Personnel.personnel_name.like("t\\_%")) + ).scalars() + ) + customer_ids = list( + session.execute( + select(Customer.id).where(Customer.company_name.like("t\\_%")) + ).scalars() + ) + if customer_ids: + session.execute( + delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) + ) + session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) + # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 + session.execute( + delete(OwnershipChangeLog).where(OwnershipChangeLog.operator_name.like("t\\_%")) + ) + if personnel_ids: + session.execute( + update(Personnel) + .where(Personnel.manager_id.in_(personnel_ids)) + .values(manager_id=None) + ) + session.execute( + delete(PersonnelRegion).where(PersonnelRegion.personnel_id.in_(personnel_ids)) + ) + session.execute( + delete(PersonnelPlan).where(PersonnelPlan.personnel_id.in_(personnel_ids)) + ) + session.execute(delete(Personnel).where(Personnel.id.in_(personnel_ids))) + session.execute(delete(Product).where(Product.product_name.like("t\\_%"))) + # 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删 + session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%"))) session.commit() diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py new file mode 100644 index 0000000..f290f9f --- /dev/null +++ b/backend/tests/integration/test_customers.py @@ -0,0 +1,352 @@ +"""客户管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是客户归属的一致性 —— 归属变更必须「主表更新 + 留痕新增」同事务 +完成,拆开就会出现「主表已换人、留痕缺失」的静默不一致,事后追溯无据; +编辑端点若能触归属,就会出现「改了归属却无留痕」的旁路,破坏留痕的 +append-only 完整性;「公司名称+负责人」组合唯一缺失会放行重复客户。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:公司名与归属人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + CUSTOMER_TYPE_ENTERPRISE, + CUSTOMER_TYPE_PERSONAL, + MODULE_CUSTOMER, +) +from app.core.db import get_session_factory +from app.models import BusinessRole, OperationLog, OwnershipChangeLog +from app.models.customer import OWNER_TYPE_CUSTOMER + +pytestmark = pytest.mark.integration + +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" + + +def _company() -> str: + return f"t_公司_{uuid.uuid4().hex[:8]}" + + +def _role_id() -> int: + """种子岗位(营销员):归属人员的合法岗位引用。""" + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _personnel(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。""" + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id(), + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _cpayload(company: str, **overrides: object) -> dict: + body: dict = { + "company_name": company, + "owner_name": "张老板", + "customer_type": CUSTOMER_TYPE_PERSONAL, + "address": "本溪市平山区", + } + body.update(overrides) + return body + + +def _create_customer(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_CUSTOMERS, json=_cpayload(_company(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +def _latest_log(customer_id: int) -> OwnershipChangeLog | None: + """取该客户最新一条归属留痕(按 id 倒序)。""" + factory = get_session_factory() + with factory() as session: + return ( + session.execute( + select(OwnershipChangeLog) + .where(OwnershipChangeLog.customer_id == customer_id) + .order_by(OwnershipChangeLog.id.desc()) + ) + .scalars() + .first() + ) + + +class TestAuth: + """鉴权:登录后接口,裸访问一律 401。""" + + def test_未登录访问客户列表_401(self, client: TestClient) -> None: + resp = client.get(_CUSTOMERS) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路与组合唯一 ——「公司名称+负责人」是业务唯一标识。""" + + def test_新增客户带初始归属_回显归属人员姓名(self, client: TestClient, normal: dict) -> None: + owner = _personnel(client, normal["headers"]) + data = _create_customer(client, normal["headers"], owner_personnel_id=owner["id"]) + assert data["owner_personnel_id"] == owner["id"] + assert data["owner_personnel_name"] == owner["personnel_name"] + assert data["customer_type"] == CUSTOMER_TYPE_PERSONAL + + def test_同公司名不同负责人创建成功(self, client: TestClient, normal: dict) -> None: + company = _company() + first = _create_customer(client, normal["headers"], company_name=company) + second = client.post( + _CUSTOMERS, + json=_cpayload(company, owner_name="李老板"), + headers=normal["headers"], + ) + assert second.status_code == 200, second.text + assert second.json()["data"]["id"] != first["id"] + + def test_重名同负责人_400(self, client: TestClient, normal: dict) -> None: + company = _company() + _create_customer(client, normal["headers"], company_name=company) + dup = client.post( + _CUSTOMERS, + json=_cpayload(company, owner_name="张老板"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "该公司名称与负责人的客户已存在" in dup.json()["message"] + + def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _CUSTOMERS, + json=_cpayload(_company(), customer_type="not_a_type"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "客户分类不合法" in resp.json()["message"] + + def test_编辑其他字段不触归属_塞归属字段也被忽略( + self, client: TestClient, normal: dict + ) -> None: + """归属只有 /ownership 一条写路径 —— 编辑端点防「改归属不留痕」旁路。""" + headers = normal["headers"] + owner = _personnel(client, headers) + stranger = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + + updated = client.put( + f"{_CUSTOMERS}/{customer['id']}", + json={ + "company_name": customer["company_name"], + "owner_name": customer["owner_name"], + "customer_type": CUSTOMER_TYPE_ENTERPRISE, + "address": "沈阳市沈河区", + # 试图经编辑端点改归属:schema 未声明该字段,必须被忽略 + "ownerPersonnelId": stranger["id"], + }, + headers=headers, + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["customer_type"] == CUSTOMER_TYPE_ENTERPRISE + assert body["address"] == "沈阳市沈河区" + assert body["owner_personnel_id"] == owner["id"], "编辑端点不得改动归属" + assert body["owner_personnel_name"] == owner["personnel_name"] + + detail = client.get(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert detail.json()["data"]["owner_personnel_id"] == owner["id"] + assert _latest_log(int(customer["id"])) is None, "编辑不应产生归属留痕" + + +class TestOwnership: + """归属变更:主表更新与留痕同事务;校验失败不留脏数据。""" + + def test_归属变更_主表更新且留痕新增(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + old = _personnel(client, headers) + new = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=old["id"]) + + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": new["id"], "reason": "区域调整"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] == new["id"] + assert body["owner_personnel_name"] == new["personnel_name"] + + log = _latest_log(int(customer["id"])) + assert log is not None, "归属变更未写留痕" + assert log.owner_type == OWNER_TYPE_CUSTOMER + assert log.customer_id == int(customer["id"]) + assert log.old_owner_personnel_id == int(old["id"]) + assert log.new_owner_personnel_id == int(new["id"]) + assert log.change_reason == "区域调整" + assert log.operator_name == normal["username"] + + def test_变更后归属人员已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + target = _personnel(client, headers) + client.put(f"{_PERSONNEL}/{target['id']}/status", json={"status": 0}, headers=headers) + customer = _create_customer(client, headers) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": target["id"]}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属人员不存在或已离职" in resp.json()["message"] + assert _latest_log(int(customer["id"])) is None, "失败变更不应留痕" + + def test_新旧归属相同_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + owner = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": owner["id"]}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"] + + def test_双方均为空_400(self, client: TestClient, normal: dict) -> None: + """无归属客户再清一次归属也属「未变化」——不产生空转留痕。""" + customer = _create_customer(client, normal["headers"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None}, + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"] + + def test_变更为空即清空归属_主表与留痕一致(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + owner = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=owner["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None, "reason": "暂无负责人"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] is None + assert body["owner_personnel_name"] is None + + log = _latest_log(int(customer["id"])) + assert log is not None + assert log.old_owner_personnel_id == int(owner["id"]) + assert log.new_owner_personnel_id is None + assert log.change_reason == "暂无负责人" + + +class TestOwnershipLogs: + """留痕分页:新旧归属人姓名由联表带出,倒序可追溯变更轨迹。""" + + def test_留痕分页带新旧归属人姓名并倒序(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + first = _personnel(client, headers) + second = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=first["id"]) + + r1 = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": second["id"], "reason": "第一次调整"}, + headers=headers, + ) + assert r1.status_code == 200, r1.text + r2 = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": None, "reason": "清空归属"}, + headers=headers, + ) + assert r2.status_code == 200, r2.text + + resp = client.get(f"{_CUSTOMERS}/{customer['id']}/ownership-logs", headers=headers) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["total"] == 2 + latest, earlier = body["list"][0], body["list"][1] + assert latest["change_reason"] == "清空归属" + assert latest["old_owner_personnel_id"] == int(second["id"]) + assert latest["old_owner_name"] == second["personnel_name"] + assert latest["new_owner_personnel_id"] is None + assert latest["new_owner_name"] is None + assert earlier["old_owner_name"] == first["personnel_name"] + assert earlier["new_owner_name"] == second["personnel_name"] + assert earlier["new_owner_personnel_id"] == int(second["id"]) + + +class TestAudit: + """写操作(含归属变更)留痕,摘要要能定位到客户。""" + + def test_新增客户留审计(self, client: TestClient, normal: dict) -> None: + data = _create_customer(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_CUSTOMER and log.action == ACTION_CREATE + ] + assert logs, "新增客户未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["company_name"] in latest.summary + + def test_归属变更审计摘要含客户名(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + old = _personnel(client, headers) + new = _personnel(client, headers) + customer = _create_customer(client, headers, owner_personnel_id=old["id"]) + resp = client.put( + f"{_CUSTOMERS}/{customer['id']}/ownership", + json={"new_owner_personnel_id": new["id"], "reason": "区域调整"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_CUSTOMER and log.success == 1 + ] + assert logs, "归属变更未留操作日志" + latest = logs[0] + assert customer["company_name"] in latest.summary, "摘要应含客户名" + assert "→" in latest.summary, "摘要应体现新旧归属走向" diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py new file mode 100644 index 0000000..7e2d4a2 --- /dev/null +++ b/backend/tests/integration/test_personnel.py @@ -0,0 +1,400 @@ +"""人员管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是人员档案的引用完整性 —— 岗位与直属经理是 M3 核算取公式模板与 +经理聚合展开的锚点,悬空引用或环引用会让核算取不到公式、聚合死循环; +删除保护缺失会留下悬空的 manager_id 与客户归属,历史核算无法回溯到人; +辐射区域与拓展计划若把「全量替换」做成了追加,编辑一次就翻倍一份脏数据。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员姓名一律 ``t_`` 前缀,由 conftest 兜底清理;仅「删除」 +语义的用例自验 DELETE 端点。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + MODULE_PERSONNEL, +) +from app.core.db import get_session_factory +from app.models import BusinessRole, OperationLog + +pytestmark = pytest.mark.integration + +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" + + +def _uname() -> str: + return f"t_人员_{uuid.uuid4().hex[:8]}" + + +def _role_id() -> int: + """种子岗位(营销员,内置且启用):人员归属岗位的合法引用。""" + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + + +def _payload(name: str, **overrides: object) -> dict: + body: dict = { + "personnel_name": name, + "business_role_id": _role_id(), + "salary_standard": "3500.50", + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_PERSONNEL, json=_payload(_uname(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:基础数据是业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问人员列表_401(self, client: TestClient) -> None: + resp = client.get(_PERSONNEL) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路:建 → 查(列表/详情)→ 改 → 状态切换 → 删。""" + + def test_新增人员默认在职且工资标准字符串直出(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + assert data["status"] == 1, "新增人员应默认在职" + assert data["is_probation"] == 0 + # 金额红线:str 进 str 出,不得经 float + assert data["salary_standard"] == "3500.50" + + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 200, detail.text + body = detail.json()["data"] + assert body["salary_standard"] == "3500.50" + assert body["regions"] == [] + assert body["plans"] == [] + + def test_新增人员携带直属经理_回显经理与岗位名(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + member = _create(client, headers, manager_id=manager["id"]) + assert member["manager_id"] == manager["id"] + assert member["manager_name"] == manager["personnel_name"] + assert member["role_name"] == manager["role_name"] + + def test_列表按姓名关键词可查到新增人员(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + listed = client.get( + _PERSONNEL, params={"keyword": data["personnel_name"]}, headers=normal["headers"] + ) + assert listed.status_code == 200, listed.text + rows = listed.json()["data"]["list"] + assert [r["id"] for r in rows] == [data["id"]] + + def test_编辑人员全量替换生效(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + updated = client.put( + f"{_PERSONNEL}/{data['id']}", + json=_payload(f"t_人员_改_{uuid.uuid4().hex[:6]}", phone="13900001111", is_probation=1), + headers=normal["headers"], + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["phone"] == "13900001111" + assert body["is_probation"] == 1 + + def test_在职离职双向切换(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + off = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 0}, headers=headers) + assert off.status_code == 200, off.text + assert off.json()["data"]["status"] == 0 + on = client.put(f"{_PERSONNEL}/{data['id']}/status", json={"status": 1}, headers=headers) + assert on.status_code == 200, on.text + assert on.json()["data"]["status"] == 1 + + def test_删除无引用人员成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.delete(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 404 + assert "人员不存在" in detail.json()["message"] + + +class TestRefRules: + """岗位/经理引用校验 —— 核算锚点不悬空、不环引用。""" + + def test_编辑时直属经理是自己_400(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.put( + f"{_PERSONNEL}/{data['id']}", + json=_payload(data["personnel_name"], manager_id=data["id"]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "直属经理不能是自己" in resp.json()["message"] + + def test_直属经理不存在_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "直属经理不存在或已离职" in resp.json()["message"] + + def test_直属经理已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + quit_resp = client.put( + f"{_PERSONNEL}/{manager['id']}/status", json={"status": 0}, headers=headers + ) + assert quit_resp.status_code == 200, quit_resp.text + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=manager["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "直属经理不存在或已离职" in resp.json()["message"] + + def test_岗位不存在_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), business_role_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "岗位不存在或已停用" in resp.json()["message"] + + def test_岗位已停用_400(self, client: TestClient, normal: dict) -> None: + """停用岗位不能挂人:岗位决定计提模板,挂上去核算会取不到公式。""" + code = f"t_br_{uuid.uuid4().hex[:8]}" + factory = get_session_factory() + with factory() as session: + session.add( + BusinessRole( + role_code=code, + role_name="已停用测试岗位", + calc_template="TPL_SALESPERSON_DECAY", + status=0, + ) + ) + session.commit() + role_id = int( + session.execute( + select(BusinessRole.id).where(BusinessRole.role_code == code) + ).scalar_one() + ) + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), business_role_id=role_id), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "岗位不存在或已停用" in resp.json()["message"] + + +class TestDeleteProtection: + """删除保护:被团队成员/客户归属引用的人员禁删 —— 历史要能回溯到人。""" + + def test_被团队成员引用禁删_解除后可删(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + manager = _create(client, headers) + member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 + blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + + client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers) + freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) + assert freed.status_code == 200, freed.text + + def test_被客户归属引用禁删_离职不放宽_解除后可删( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + owner = _create(client, headers) + created = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + "owner_personnel_id": owner["id"], + }, + headers=headers, + ) + assert created.status_code == 200, created.text + customer = created.json()["data"] + + blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + + # 保护与在职状态无关:离职并不能让物理删除放行 + client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers) + still = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert still.status_code == 400, still.text + + # 删客户解除引用后方可删人 + assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200 + assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200 + + +class TestSubTableReplace: + """辐射区域/拓展计划为全量替换语义 —— 做成追加会让编辑翻倍脏数据。""" + + def test_辐射区域设置后详情回显一致_再次替换生效( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + data = _create(client, headers) + resp = client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={ + "items": [ + {"region_name": "本溪市平山区", "remark": "核心区域"}, + {"region_name": "本溪市溪湖区"}, + ] + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [r["region_name"] for r in detail["regions"]] == ["本溪市平山区", "本溪市溪湖区"] + assert detail["regions"][0]["remark"] == "核心区域" + + # 再替换为 1 条:是覆盖而非追加 + resp2 = client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={"items": [{"region_name": "沈阳市沈河区"}]}, + headers=headers, + ) + assert resp2.status_code == 200, resp2.text + detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [r["region_name"] for r in detail2["regions"]] == ["沈阳市沈河区"] + + def test_辐射区域替换为空列表即清空(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + client.put( + f"{_PERSONNEL}/{data['id']}/regions", + json={"items": [{"region_name": "本溪市平山区"}]}, + headers=headers, + ) + cleared = client.put( + f"{_PERSONNEL}/{data['id']}/regions", json={"items": []}, headers=headers + ) + assert cleared.status_code == 200, cleared.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert detail["regions"] == [] + + def test_拓展计划设置后详情回显一致_再次替换生效( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + data = _create(client, headers) + resp = client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={ + "items": [ + { + "plan_period": "2026Q1", + "plan_content": "拓展本溪市政客户", + "target_desc": "新增3户", + }, + {"plan_period": "2026Q2", "plan_content": "维护存量客户"}, + ] + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [p["plan_period"] for p in detail["plans"]] == ["2026Q1", "2026Q2"] + assert detail["plans"][0]["target_desc"] == "新增3户" + + resp2 = client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={"items": [{"plan_period": "2026Q3", "plan_content": "开拓轨交市场"}]}, + headers=headers, + ) + assert resp2.status_code == 200, resp2.text + detail2 = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert [p["plan_period"] for p in detail2["plans"]] == ["2026Q3"] + + def test_拓展计划替换为空列表即清空(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + client.put( + f"{_PERSONNEL}/{data['id']}/plans", + json={"items": [{"plan_period": "2026Q1", "plan_content": "拓展本溪市政客户"}]}, + headers=headers, + ) + cleared = client.put( + f"{_PERSONNEL}/{data['id']}/plans", json={"items": []}, headers=headers + ) + assert cleared.status_code == 200, cleared.text + detail = client.get(f"{_PERSONNEL}/{data['id']}", headers=headers).json()["data"] + assert detail["plans"] == [] + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增人员成功留审计(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PERSONNEL and log.action == ACTION_CREATE + ] + assert logs, "新增人员未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["personnel_name"] in latest.summary + + def test_校验失败也留审计(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PERSONNEL, + json=_payload(_uname(), manager_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PERSONNEL and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "直属经理不存在或已离职" in (failed[0].error_message or "") diff --git a/backend/tests/integration/test_products.py b/backend/tests/integration/test_products.py new file mode 100644 index 0000000..4789fbd --- /dev/null +++ b/backend/tests/integration/test_products.py @@ -0,0 +1,238 @@ +"""产品管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是产品主数据的两条硬约束 —— product_category 是折算阈值的行级锚点, +脏类别会让核算按类别回溯参数取不到阈值;「名称+规格」组合唯一缺失会 +放行重复产品,业务数据不知道该挂在哪一条上。数量/单价以字符串进出, +断言同时验证 str 直出(金额红线:不经 float)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:产品名一律 ``t_`` 前缀,由 conftest 兜底清理;删除语义用例 +自验 DELETE 端点。 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ( + ACTION_CREATE, + MODULE_PRODUCT, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_PRODUCTS = "/api/v1/base/products" + + +def _pname() -> str: + return f"t_产品_{uuid.uuid4().hex[:8]}" + + +def _payload(name: str, **overrides: object) -> dict: + body: dict = { + "product_name": name, + "spec_model": "DN100", + "unit": "台", + "stock_quantity": "100", + "sale_price": "12.5", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, **overrides: object) -> dict: + resp = client.post(_PRODUCTS, json=_payload(_pname(), **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _logs_of(username: str) -> list[OperationLog]: + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:登录后接口,裸访问一律 401。""" + + def test_未登录访问产品列表_401(self, client: TestClient) -> None: + resp = client.get(_PRODUCTS) + assert resp.status_code == 401, resp.text + + +class TestCrudFlow: + """正向链路:建 → 查 → 改(含换类别)→ 删。""" + + def test_新增产品默认启用且数量单价字符串直出(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + assert data["status"] == 1, "新产品应默认启用" + assert data["spec_model"] == "DN100" + # 金额红线:str 进 str 出,且数值与入参精确相等(Decimal 比较,不经 float) + assert isinstance(data["sale_price"], str) + assert Decimal(data["sale_price"]) == Decimal("12.5") + assert isinstance(data["stock_quantity"], str) + assert Decimal(data["stock_quantity"]) == Decimal("100") + + def test_详情与列表均能查到新增产品(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 200, detail.text + assert detail.json()["data"]["id"] == data["id"] + + listed = client.get( + _PRODUCTS, params={"keyword": data["product_name"]}, headers=normal["headers"] + ) + assert listed.status_code == 200, listed.text + assert [r["id"] for r in listed.json()["data"]["list"]] == [data["id"]] + + def test_同名称不同规格创建成功(self, client: TestClient, normal: dict) -> None: + name = _pname() + first = _create(client, normal["headers"], product_name=name, spec_model="DN100") + second = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN200"), + headers=normal["headers"], + ) + assert second.status_code == 200, second.text + assert second.json()["data"]["id"] != first["id"] + + def test_删除产品成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + resp = client.delete(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert resp.status_code == 200, resp.text + detail = client.get(f"{_PRODUCTS}/{data['id']}", headers=normal["headers"]) + assert detail.status_code == 404 + assert "产品不存在" in detail.json()["message"] + + +class TestValidationRules: + """类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。""" + + def test_重名同规格_400(self, client: TestClient, normal: dict) -> None: + name = _pname() + _create(client, normal["headers"], product_name=name) + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN100"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "已存在同名称同规格的产品" in dup.json()["message"] + + def test_规格缺省归一空串且重名同空规格仍拦截(self, client: TestClient, normal: dict) -> None: + """组合唯一键对 NULL 不去重:缺省规格若归 NULL 会放行重名同规格数据。""" + name = _pname() + first = _create(client, normal["headers"], product_name=name, spec_model=None) + assert first["spec_model"] == "" + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model=None), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + assert "已存在同名称同规格的产品" in dup.json()["message"] + + def test_产品类别非法_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), product_category="not_a_category"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "产品类别不合法" in resp.json()["message"] + + def test_销售单价为负数_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), sale_price="-1"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _PRODUCTS, + json=_payload(_pname(), sale_price="abc"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_编辑改类别为合法值成功(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + updated = client.put( + f"{_PRODUCTS}/{data['id']}", + json=_payload( + data["product_name"], + product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, + sale_price="15.8", + ), + headers=normal["headers"], + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["product_category"] == PRODUCT_CATEGORY_RAIL_TRANSIT + assert Decimal(body["sale_price"]) == Decimal("15.8") + + +class TestStatusSwitch: + """启用/停用独立于编辑端点 —— 编辑表单不携带状态,防误触停用。""" + + def test_停用再启用切换(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + data = _create(client, headers) + off = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 0}, headers=headers) + assert off.status_code == 200, off.text + assert off.json()["message"] == "已停用" + assert off.json()["data"]["status"] == 0 + on = client.put(f"{_PRODUCTS}/{data['id']}/status", json={"status": 1}, headers=headers) + assert on.status_code == 200, on.text + assert on.json()["message"] == "已启用" + assert on.json()["data"]["status"] == 1 + + +class TestAudit: + """写操作(含失败)留痕 —— 审计是验收项。""" + + def test_新增产品成功留审计(self, client: TestClient, normal: dict) -> None: + data = _create(client, normal["headers"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PRODUCT and log.action == ACTION_CREATE + ] + assert logs, "新增产品未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert data["product_name"] in latest.summary + + def test_重名失败留审计(self, client: TestClient, normal: dict) -> None: + name = _pname() + _create(client, normal["headers"], product_name=name) + dup = client.post( + _PRODUCTS, + json=_payload(name, spec_model="DN100"), + headers=normal["headers"], + ) + assert dup.status_code == 400, dup.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_PRODUCT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "已存在同名称同规格的产品" in (failed[0].error_message or "") -- 2.27.0 From 20fcf06f5105df71906537cd1e52a9e6bf908ac3 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 11:22:34 +0800 Subject: [PATCH 025/100] =?UTF-8?q?feat(business):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE=E4=B8=83=E8=A1=A8=E5=8F=8A?= =?UTF-8?q?=20V5=20=E8=BF=81=E7=A7=BB=EF=BC=88=E4=B8=9A=E5=8A=A1/=E6=98=8E?= =?UTF-8?q?=E7=BB=86/=E7=8A=B6=E6=80=81=E7=95=99=E7=97=95/=E5=8F=91?= =?UTF-8?q?=E8=B4=A7/=E5=9B=9E=E6=AC=BE/=E8=AF=84=E7=BA=A7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - businesses 五项汇总冗余列服务层事务内维护;毛利润不落库(派生值,M3 台账固化) - 行级 product_category 折算锚点(#14);回款 settlement_status 预置为 M3 防重锚点 - 评级等级种子四条(迁移+init-dev 双写);编号前缀常量 YW/SH/HK - 双轨 SQL 同步(V4/V5 重导,schema_sync+test_models 164 例全绿) Co-Authored-By: Claude Code --- .../8efc189b131f_add_business_data_tables.py | 696 ++++++++++++++++++ .../e1133a7c670a_add_base_data_tables.py | 583 +++++++++++---- backend/app/core/constants.py | 10 + backend/app/models/__init__.py | 11 + backend/app/models/business.py | 218 ++++++ backend/app/models/rating.py | 46 ++ backend/app/models/receipt.py | 83 +++ backend/app/models/shipment.py | 115 +++ backend/tests/unit/test_models.py | 7 + db/init/init-dev.sql | 206 +++++- db/migration/V5__add_business_data_tables.sql | 186 +++++ db/queries/业务数据.sql | 115 +++ 12 files changed, 2138 insertions(+), 138 deletions(-) create mode 100644 backend/alembic/versions/8efc189b131f_add_business_data_tables.py create mode 100644 backend/app/models/business.py create mode 100644 backend/app/models/rating.py create mode 100644 backend/app/models/receipt.py create mode 100644 backend/app/models/shipment.py create mode 100644 db/migration/V5__add_business_data_tables.sql create mode 100644 db/queries/业务数据.sql diff --git a/backend/alembic/versions/8efc189b131f_add_business_data_tables.py b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py new file mode 100644 index 0000000..42c6ce1 --- /dev/null +++ b/backend/alembic/versions/8efc189b131f_add_business_data_tables.py @@ -0,0 +1,696 @@ +"""业务数据管理七表:业务/业务明细/状态留痕/发货单/发货明细/回款/评级等级配置(M2) + +- businesses:业务编号自动生成(报表 2.4.1「合同编号」),五项汇总冗余列 + 由服务层事务内维护;毛利润相关值不落库(派生值,M3 台账才是固化点) +- business_items:行级 product_category 为折算锚点(对照表 #14),指导价 + 人工录入、T+ 同步不覆盖 +- business_status_logs:append-only,创建时写首条(old=new=1) +- shipments/shipment_items:发货日为回款天数基准日(对照表 #1) +- receipts:手动指定发货批次(#18);settlement_status 为 M3 核算防重锚点 +- customer_rating_grades:等级映射可配置,种子四条随迁移初始化 + +Revision ID: 8efc189b131f +Revises: e1133a7c670a +Create Date: 2026-10-13 11:14:17.545320 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "8efc189b131f" +down_revision: str | Sequence[str] | None = "e1133a7c670a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# 评级等级种子(与 init-dev.sql 双写一致;口径见模型 docstring) +_GRADE_SEED = [ + {"grade_name": "A", "min_score": 90, "max_score": 100, "sort_order": 1, "remark": "优秀"}, + {"grade_name": "B", "min_score": 75, "max_score": 89, "sort_order": 2, "remark": "良好"}, + {"grade_name": "C", "min_score": 60, "max_score": 74, "sort_order": 3, "remark": "合格"}, + {"grade_name": "D", "min_score": 0, "max_score": 59, "sort_order": 4, "remark": "待改进"}, +] + + +def upgrade() -> None: + """应用迁移。""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "customer_rating_grades", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "grade_name", sa.String(length=32), nullable=False, comment="等级名称(如 A/优秀)" + ), + sa.Column( + "min_score", + sa.Numeric(precision=5, scale=1), + nullable=False, + comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", + ), + sa.Column( + "max_score", + sa.Numeric(precision=5, scale=1), + nullable=False, + comment="总分上限(展示用)", + ), + sa.Column( + "sort_order", + sa.Integer(), + server_default="0", + nullable=False, + comment="排序(判定顺序,升序)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="说明"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_rating_grades")), + sa.UniqueConstraint("grade_name", name=op.f("uk_customer_rating_grades_grade_name")), + comment="客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_table( + "business_status_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(固化,无外键;删除保护由服务层承担)", + ), + sa.Column( + "old_status", mysql.TINYINT(), nullable=False, comment="变更前状态(创建首条为 1)" + ), + sa.Column("new_status", mysql.TINYINT(), nullable=False, comment="变更后状态"), + sa.Column( + "change_reason", sa.String(length=255), nullable=True, comment="变更原因(终止时必填)" + ), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_business_status_logs_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_status_logs")), + comment="业务状态变更记录表(append-only)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_business_status_logs_business_id", + "business_status_logs", + ["business_id"], + unique=False, + ) + op.create_index( + "idx_business_status_logs_create_time", + "business_status_logs", + ["create_time"], + unique=False, + ) + op.create_table( + "businesses", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_no", + sa.String(length=32), + nullable=False, + comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)", + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="客户ID(customers.id)", + ), + sa.Column("business_date", sa.Date(), nullable=True, comment="业务日期(T+ 同步口径)"), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)", + ), + sa.Column( + "owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)", + ), + sa.Column( + "assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)", + ), + sa.Column( + "is_special", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="特殊业务标记:1是 0否(公司参与等)", + ), + sa.Column("special_note", sa.String(length=255), nullable=True, comment="特殊业务说明"), + sa.Column( + "total_sale_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)", + ), + sa.Column( + "total_cost_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column( + "total_ship_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column( + "total_received_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)", + ), + sa.Column( + "total_freight_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=True, + comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["assistant_personnel_id"], + ["personnel.id"], + name=op.f("fk_businesses_assistant_personnel_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["customer_id"], + ["customers.id"], + name=op.f("fk_businesses_customer_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["owner_personnel_id"], + ["personnel.id"], + name=op.f("fk_businesses_owner_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_businesses")), + sa.UniqueConstraint("business_no", name=op.f("uk_businesses_business_no")), + comment="业务表(业务编号自动生成;T+同步不覆盖业务状态)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_businesses_business_date", "businesses", ["business_date"], unique=False) + op.create_index("idx_businesses_customer_id", "businesses", ["customer_id"], unique=False) + op.create_index( + "idx_businesses_owner_personnel_id", "businesses", ["owner_personnel_id"], unique=False + ) + op.create_index(op.f("idx_businesses_source_no"), "businesses", ["source_no"], unique=False) + op.create_index("idx_businesses_status", "businesses", ["status"], unique=False) + op.create_table( + "business_items", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id)", + ), + sa.Column( + "product_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="产品ID(products.id;NULL=自由录入,未入产品库)", + ), + sa.Column( + "product_name", + sa.String(length=128), + nullable=False, + comment="产品名称(引用产品时冗余固化)", + ), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", + ), + sa.Column( + "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="数量(>0)" + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="销售单价(元;T+ 同步或人工)", + ), + sa.Column( + "sale_amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)", + ), + sa.Column( + "cost_amount", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)", + ), + sa.Column( + "guided_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_business_items_business_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + name=op.f("fk_business_items_product_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_business_items")), + comment="业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + "idx_business_items_business_id", "business_items", ["business_id"], unique=False + ) + op.create_index( + "idx_business_items_product_category", "business_items", ["product_category"], unique=False + ) + op.create_index("idx_business_items_product_id", "business_items", ["product_id"], unique=False) + op.create_table( + "shipments", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "shipment_no", + sa.String(length=32), + nullable=False, + comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id;终态业务禁止新发货)", + ), + sa.Column( + "ship_date", + sa.Date(), + nullable=False, + comment="发货日期(回款天数基准日,发货日首日=第 1 天)", + ), + sa.Column( + "freight_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=False, + comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", + ), + sa.Column( + "total_amount", + sa.Numeric(precision=14, scale=2), + server_default="0", + nullable=False, + comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_shipments_business_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_shipments")), + sa.UniqueConstraint("shipment_no", name=op.f("uk_shipments_shipment_no")), + comment="发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_shipments_business_id", "shipments", ["business_id"], unique=False) + op.create_index("idx_shipments_ship_date", "shipments", ["ship_date"], unique=False) + op.create_index(op.f("idx_shipments_source_no"), "shipments", ["source_no"], unique=False) + op.create_table( + "receipts", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "receipt_batch_no", + sa.String(length=32), + nullable=False, + comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="业务ID(businesses.id)", + ), + sa.Column( + "shipment_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", + ), + sa.Column( + "receipt_date", + sa.Date(), + nullable=False, + comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)", + ), + sa.Column( + "amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="回款金额(≥0;一期禁负数/红字)", + ), + sa.Column( + "is_barter", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", + ), + sa.Column( + "settlement_status", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_id"], + ["businesses.id"], + name=op.f("fk_receipts_business_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["shipment_id"], + ["shipments.id"], + name=op.f("fk_receipts_shipment_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_receipts")), + sa.UniqueConstraint("receipt_batch_no", name=op.f("uk_receipts_receipt_batch_no")), + comment="回款表(手动指定发货批次;已核算回款禁改禁删)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_receipts_business_id", "receipts", ["business_id"], unique=False) + op.create_index("idx_receipts_receipt_date", "receipts", ["receipt_date"], unique=False) + op.create_index( + "idx_receipts_settlement_status", "receipts", ["settlement_status"], unique=False + ) + op.create_index("idx_receipts_shipment_id", "receipts", ["shipment_id"], unique=False) + op.create_index(op.f("idx_receipts_source_no"), "receipts", ["source_no"], unique=False) + op.create_table( + "shipment_items", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "shipment_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="发货单ID(shipments.id)", + ), + sa.Column( + "product_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="产品ID(products.id;NULL=自由录入)", + ), + sa.Column( + "product_name", + sa.String(length=128), + nullable=False, + comment="产品名称(引用产品时冗余固化)", + ), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "quantity", sa.Numeric(precision=14, scale=2), nullable=False, comment="发货数量(>0)" + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)", + ), + sa.Column( + "amount", + sa.Numeric(precision=14, scale=2), + nullable=False, + comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + name=op.f("fk_shipment_items_product_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["shipment_id"], + ["shipments.id"], + name=op.f("fk_shipment_items_shipment_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_shipment_items")), + comment="发货产品明细表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index("idx_shipment_items_product_id", "shipment_items", ["product_id"], unique=False) + op.create_index( + "idx_shipment_items_shipment_id", "shipment_items", ["shipment_id"], unique=False + ) + # ### end Alembic commands ### + # 评级等级种子(等级映射可配置;双轨:init-dev.sql 同步写入) + op.bulk_insert( + sa.table( + "customer_rating_grades", + sa.column("grade_name", sa.String), + sa.column("min_score", sa.Numeric), + sa.column("max_score", sa.Numeric), + sa.column("sort_order", sa.Integer), + sa.column("remark", sa.String), + ), + _GRADE_SEED, + ) + + +def downgrade() -> None: + """回滚迁移。 + + **注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与 + `op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引, + 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 + **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 + """ + # 整表回滚只保留 drop_table(DROP TABLE 连带删除索引与外键); + # 逐条 drop_index 会先于 drop_table 摘掉外键支撑索引而报 MySQL 1553 + op.drop_table("shipment_items") + op.drop_table("receipts") + op.drop_table("shipments") + op.drop_table("business_items") + op.drop_table("businesses") + op.drop_table("business_status_logs") + op.drop_table("customer_rating_grades") diff --git a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py index 2f4c3bf..7a81083 100644 --- a/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py +++ b/backend/alembic/versions/e1133a7c670a_add_base_data_tables.py @@ -11,6 +11,7 @@ Revises: bfbda9c3191d Create Date: 2026-10-13 09:36:02.219248 """ + from collections.abc import Sequence from alembic import op @@ -18,8 +19,8 @@ import sqlalchemy as sa from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. -revision: str = 'e1133a7c670a' -down_revision: str | Sequence[str] | None = 'bfbda9c3191d' +revision: str = "e1133a7c670a" +down_revision: str | Sequence[str] | None = "bfbda9c3191d" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -27,141 +28,449 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: """应用迁移。""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('products', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('product_name', sa.String(length=128), nullable=False, comment='产品名称'), - sa.Column('spec_model', sa.String(length=128), server_default='', nullable=False, comment='规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)'), - sa.Column('unit', sa.String(length=16), nullable=True, comment='计量单位'), - sa.Column('stock_quantity', sa.Numeric(precision=14, scale=2), nullable=True, comment='数量(T+ 同步原样存储,语义待 IT 确认)'), - sa.Column('sale_price', sa.Numeric(precision=14, scale=4), nullable=True, comment='销售单价(元,T+ 同步)'), - sa.Column('product_category', sa.String(length=32), nullable=False, comment='产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)'), - sa.Column('status', mysql.TINYINT(), server_default='1', nullable=False, comment='状态:1启用 0停用'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), - sa.Column('source_no', sa.String(length=64), nullable=True, comment='T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)'), - sa.Column('t_plus_id', sa.String(length=64), nullable=True, comment='T+ 系统内部标识'), - sa.Column('sync_batch_no', sa.String(length=32), nullable=True, comment='同步批次号(同步模块写入)'), - sa.Column('synced_at', sa.DateTime(), nullable=True, comment='最近同步时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_products')), - sa.UniqueConstraint('product_name', 'spec_model', name='uk_products_name_spec'), - comment='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_table( + "products", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("product_name", sa.String(length=128), nullable=False, comment="产品名称"), + sa.Column( + "spec_model", + sa.String(length=128), + server_default="", + nullable=False, + comment="规格型号(空串而非 NULL:参与组合唯一键,MySQL 组合索引对 NULL 不去重)", + ), + sa.Column("unit", sa.String(length=16), nullable=True, comment="计量单位"), + sa.Column( + "stock_quantity", + sa.Numeric(precision=14, scale=2), + nullable=True, + comment="数量(T+ 同步原样存储,语义待 IT 确认)", + ), + sa.Column( + "sale_price", + sa.Numeric(precision=14, scale=4), + nullable=True, + comment="销售单价(元,T+ 同步)", + ), + sa.Column( + "product_category", + sa.String(length=32), + nullable=False, + comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1启用 0停用", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_products")), + sa.UniqueConstraint("product_name", "spec_model", name="uk_products_name_spec"), + comment="产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", ) - op.create_index(op.f('idx_products_source_no'), 'products', ['source_no'], unique=False) - op.create_table('ownership_change_logs', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('owner_type', mysql.TINYINT(), nullable=False, comment='归属类型:1客户归属 2业务归属'), - sa.Column('customer_id', mysql.BIGINT(unsigned=True), nullable=True, comment='客户ID(固化,owner_type=1 时非空)'), - sa.Column('business_id', mysql.BIGINT(unsigned=True), nullable=True, comment='业务ID(固化,owner_type=2 时非空)'), - sa.Column('old_owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更前负责营销人员ID(固化)'), - sa.Column('new_owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更后负责营销人员ID(固化)'), - sa.Column('old_assistant_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更前协助营销人员ID(固化,仅业务归属使用)'), - sa.Column('new_assistant_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='变更后协助营销人员ID(固化,仅业务归属使用)'), - sa.Column('change_reason', sa.String(length=255), nullable=True, comment='变更原因'), - sa.Column('operator_id', mysql.BIGINT(unsigned=True), nullable=True, comment='操作人ID(账号删除后置空)'), - sa.Column('operator_name', sa.String(length=64), nullable=False, comment='操作人用户名(冗余固化)'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.ForeignKeyConstraint(['operator_id'], ['users.id'], name=op.f('fk_ownership_change_logs_operator_id'), ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_ownership_change_logs')), - comment='归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index(op.f("idx_products_source_no"), "products", ["source_no"], unique=False) + op.create_table( + "ownership_change_logs", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "owner_type", mysql.TINYINT(), nullable=False, comment="归属类型:1客户归属 2业务归属" + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="客户ID(固化,owner_type=1 时非空)", + ), + sa.Column( + "business_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="业务ID(固化,owner_type=2 时非空)", + ), + sa.Column( + "old_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前负责营销人员ID(固化)", + ), + sa.Column( + "new_owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后负责营销人员ID(固化)", + ), + sa.Column( + "old_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更前协助营销人员ID(固化,仅业务归属使用)", + ), + sa.Column( + "new_assistant_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="变更后协助营销人员ID(固化,仅业务归属使用)", + ), + sa.Column("change_reason", sa.String(length=255), nullable=True, comment="变更原因"), + sa.Column( + "operator_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="操作人ID(账号删除后置空)", + ), + sa.Column( + "operator_name", + sa.String(length=64), + nullable=False, + comment="操作人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.ForeignKeyConstraint( + ["operator_id"], + ["users.id"], + name=op.f("fk_ownership_change_logs_operator_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ownership_change_logs")), + comment="归属变更记录表(append-only;客户/业务归属变更留痕,不回溯历史)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", ) - op.create_index(op.f('idx_ownership_change_logs_customer_id'), 'ownership_change_logs', ['customer_id'], unique=False) - op.create_index(op.f('idx_ownership_change_logs_business_id'), 'ownership_change_logs', ['business_id'], unique=False) - op.create_index(op.f('idx_ownership_change_logs_create_time'), 'ownership_change_logs', ['create_time'], unique=False) - op.create_table('personnel', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_name', sa.String(length=64), nullable=False, comment='姓名'), - sa.Column('business_role_id', mysql.BIGINT(unsigned=True), nullable=False, comment='岗位角色ID(决定计提公式模板;T+同步不覆盖)'), - sa.Column('manager_id', mysql.BIGINT(unsigned=True), nullable=True, comment='直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)'), - sa.Column('phone', sa.String(length=20), nullable=True, comment='联系电话'), - sa.Column('hire_date', sa.Date(), nullable=True, comment='入职日期'), - sa.Column('is_probation', mysql.TINYINT(), server_default='0', nullable=False, comment='是否试用期:1是 0否(试用期绩效照算不发放)'), - sa.Column('salary_standard', sa.Numeric(precision=12, scale=2), nullable=True, comment='月工资标准(元;T+同步不覆盖)'), - sa.Column('status', mysql.TINYINT(), server_default='1', nullable=False, comment='状态:1在职 0离职(离职人员不可再被业务/客户归属引用)'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), - sa.Column('source_no', sa.String(length=64), nullable=True, comment='T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)'), - sa.Column('t_plus_id', sa.String(length=64), nullable=True, comment='T+ 系统内部标识'), - sa.Column('sync_batch_no', sa.String(length=32), nullable=True, comment='同步批次号(同步模块写入)'), - sa.Column('synced_at', sa.DateTime(), nullable=True, comment='最近同步时间'), - sa.ForeignKeyConstraint(['business_role_id'], ['business_roles.id'], name=op.f('fk_personnel_business_role_id'), ondelete='RESTRICT'), - sa.ForeignKeyConstraint(['manager_id'], ['personnel.id'], name=op.f('fk_personnel_manager_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel')), - comment='营销人员表(T+同步不覆盖岗位与工资标准)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_customer_id"), + "ownership_change_logs", + ["customer_id"], + unique=False, ) - op.create_index(op.f('idx_personnel_business_role_id'), 'personnel', ['business_role_id'], unique=False) - op.create_index(op.f('idx_personnel_manager_id'), 'personnel', ['manager_id'], unique=False) - op.create_index(op.f('idx_personnel_source_no'), 'personnel', ['source_no'], unique=False) - op.create_table('customers', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('company_name', sa.String(length=128), nullable=False, comment='公司名称'), - sa.Column('owner_name', sa.String(length=64), nullable=False, comment='客户负责人姓名(客户公司负责人的人名,非系统人员)'), - sa.Column('contact_person', sa.String(length=64), nullable=True, comment='联系人'), - sa.Column('contact_phone', sa.String(length=20), nullable=True, comment='联系电话'), - sa.Column('address', sa.String(length=255), nullable=True, comment='地址'), - sa.Column('industry', sa.String(length=64), nullable=True, comment='所属行业(自由文本)'), - sa.Column('customer_type', sa.String(length=32), server_default='personal', nullable=False, comment='客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)'), - sa.Column('owner_personnel_id', mysql.BIGINT(unsigned=True), nullable=True, comment='归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), - sa.Column('source_no', sa.String(length=64), nullable=True, comment='T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)'), - sa.Column('t_plus_id', sa.String(length=64), nullable=True, comment='T+ 系统内部标识'), - sa.Column('sync_batch_no', sa.String(length=32), nullable=True, comment='同步批次号(同步模块写入)'), - sa.Column('synced_at', sa.DateTime(), nullable=True, comment='最近同步时间'), - sa.ForeignKeyConstraint(['owner_personnel_id'], ['personnel.id'], name=op.f('fk_customers_owner_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_customers')), - sa.UniqueConstraint('company_name', 'owner_name', name='uk_customers_company_owner'), - comment='客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_business_id"), + "ownership_change_logs", + ["business_id"], + unique=False, ) - op.create_index(op.f('idx_customers_customer_type'), 'customers', ['customer_type'], unique=False) - op.create_index(op.f('idx_customers_owner_personnel_id'), 'customers', ['owner_personnel_id'], unique=False) - op.create_index(op.f('idx_customers_source_no'), 'customers', ['source_no'], unique=False) - op.create_table('personnel_plans', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_id', mysql.BIGINT(unsigned=True), nullable=False, comment='人员ID(personnel.id)'), - sa.Column('plan_period', sa.String(length=32), nullable=False, comment='计划期间(如 2026Q1、2026-05)'), - sa.Column('plan_content', sa.String(length=500), nullable=False, comment='计划内容'), - sa.Column('target_desc', sa.String(length=255), nullable=True, comment='指标说明(文字描述)'), - sa.Column('source_no', sa.String(length=64), nullable=True, comment='T+ 原始条目号(预留)'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), - sa.ForeignKeyConstraint(['personnel_id'], ['personnel.id'], name=op.f('fk_personnel_plans_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel_plans')), - comment='市场拓展计划表', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_index( + op.f("idx_ownership_change_logs_create_time"), + "ownership_change_logs", + ["create_time"], + unique=False, ) - op.create_index(op.f('idx_personnel_plans_personnel_id'), 'personnel_plans', ['personnel_id'], unique=False) - op.create_table('personnel_regions', - sa.Column('id', mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('personnel_id', mysql.BIGINT(unsigned=True), nullable=False, comment='人员ID(personnel.id)'), - sa.Column('region_name', sa.String(length=128), nullable=False, comment='区域名称(产品辐射地理范围)'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.Column('create_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False, comment='创建时间'), - sa.Column('update_time', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), nullable=False, comment='更新时间'), - sa.ForeignKeyConstraint(['personnel_id'], ['personnel.id'], name=op.f('fk_personnel_regions_personnel_id'), ondelete='RESTRICT'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_personnel_regions')), - comment='产品辐射区域表', - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci', - mysql_engine='InnoDB' + op.create_table( + "personnel", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("personnel_name", sa.String(length=64), nullable=False, comment="姓名"), + sa.Column( + "business_role_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="岗位角色ID(决定计提公式模板;T+同步不覆盖)", + ), + sa.Column( + "manager_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="直属营销经理ID(NULL=经理本人或未分配;团队成员关系锚点)", + ), + sa.Column("phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("hire_date", sa.Date(), nullable=True, comment="入职日期"), + sa.Column( + "is_probation", + mysql.TINYINT(), + server_default="0", + nullable=False, + comment="是否试用期:1是 0否(试用期绩效照算不发放)", + ), + sa.Column( + "salary_standard", + sa.Numeric(precision=12, scale=2), + nullable=True, + comment="月工资标准(元;T+同步不覆盖)", + ), + sa.Column( + "status", + mysql.TINYINT(), + server_default="1", + nullable=False, + comment="状态:1在职 0离职(离职人员不可再被业务/客户归属引用)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["business_role_id"], + ["business_roles.id"], + name=op.f("fk_personnel_business_role_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["manager_id"], + ["personnel.id"], + name=op.f("fk_personnel_manager_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel")), + comment="营销人员表(T+同步不覆盖岗位与工资标准)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_business_role_id"), "personnel", ["business_role_id"], unique=False + ) + op.create_index(op.f("idx_personnel_manager_id"), "personnel", ["manager_id"], unique=False) + op.create_index(op.f("idx_personnel_source_no"), "personnel", ["source_no"], unique=False) + op.create_table( + "customers", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column("company_name", sa.String(length=128), nullable=False, comment="公司名称"), + sa.Column( + "owner_name", + sa.String(length=64), + nullable=False, + comment="客户负责人姓名(客户公司负责人的人名,非系统人员)", + ), + sa.Column("contact_person", sa.String(length=64), nullable=True, comment="联系人"), + sa.Column("contact_phone", sa.String(length=20), nullable=True, comment="联系电话"), + sa.Column("address", sa.String(length=255), nullable=True, comment="地址"), + sa.Column("industry", sa.String(length=64), nullable=True, comment="所属行业(自由文本)"), + sa.Column( + "customer_type", + sa.String(length=32), + server_default="personal", + nullable=False, + comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", + ), + sa.Column( + "owner_personnel_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="归属负责营销人员ID(手动修改不回溯历史,变更留痕见 ownership_change_logs)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.Column( + "source_no", + sa.String(length=64), + nullable=True, + comment="T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)", + ), + sa.Column("t_plus_id", sa.String(length=64), nullable=True, comment="T+ 系统内部标识"), + sa.Column( + "sync_batch_no", + sa.String(length=32), + nullable=True, + comment="同步批次号(同步模块写入)", + ), + sa.Column("synced_at", sa.DateTime(), nullable=True, comment="最近同步时间"), + sa.ForeignKeyConstraint( + ["owner_personnel_id"], + ["personnel.id"], + name=op.f("fk_customers_owner_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customers")), + sa.UniqueConstraint("company_name", "owner_name", name="uk_customers_company_owner"), + comment="客户表(公司名称+负责人为业务唯一标识;T+同步不覆盖客户分类)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_customers_customer_type"), "customers", ["customer_type"], unique=False + ) + op.create_index( + op.f("idx_customers_owner_personnel_id"), "customers", ["owner_personnel_id"], unique=False + ) + op.create_index(op.f("idx_customers_source_no"), "customers", ["source_no"], unique=False) + op.create_table( + "personnel_plans", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "plan_period", + sa.String(length=32), + nullable=False, + comment="计划期间(如 2026Q1、2026-05)", + ), + sa.Column("plan_content", sa.String(length=500), nullable=False, comment="计划内容"), + sa.Column( + "target_desc", sa.String(length=255), nullable=True, comment="指标说明(文字描述)" + ), + sa.Column( + "source_no", sa.String(length=64), nullable=True, comment="T+ 原始条目号(预留)" + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_plans_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_plans")), + comment="市场拓展计划表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_plans_personnel_id"), "personnel_plans", ["personnel_id"], unique=False + ) + op.create_table( + "personnel_regions", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "personnel_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="人员ID(personnel.id)", + ), + sa.Column( + "region_name", + sa.String(length=128), + nullable=False, + comment="区域名称(产品辐射地理范围)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["personnel_id"], + ["personnel.id"], + name=op.f("fk_personnel_regions_personnel_id"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_personnel_regions")), + comment="产品辐射区域表", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.create_index( + op.f("idx_personnel_regions_personnel_id"), + "personnel_regions", + ["personnel_id"], + unique=False, ) - op.create_index(op.f('idx_personnel_regions_personnel_id'), 'personnel_regions', ['personnel_id'], unique=False) # ### end Alembic commands ### @@ -173,9 +482,9 @@ def downgrade() -> None: 单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。 **整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。 """ - op.drop_table('personnel_regions') - op.drop_table('personnel_plans') - op.drop_table('customers') - op.drop_table('personnel') - op.drop_table('ownership_change_logs') - op.drop_table('products') + op.drop_table("personnel_regions") + op.drop_table("personnel_plans") + op.drop_table("customers") + op.drop_table("personnel") + op.drop_table("ownership_change_logs") + op.drop_table("products") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index a8c33f3..8566e60 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -66,6 +66,16 @@ MODULE_PARAM = "计提参数" MODULE_PERSONNEL = "人员管理" MODULE_PRODUCT = "产品管理" MODULE_CUSTOMER = "客户管理" +MODULE_BUSINESS = "业务管理" +MODULE_SHIPMENT = "发货管理" +MODULE_RECEIPT = "回款管理" +MODULE_RATING = "客户评级" + +# ===== 单据编号前缀({前缀}{YYYYMMDD}-{当日4位序号},序号按自然日重置)===== +# 一期为代码常量(业务未提出配置诉求);如需可配置,走 M1 参数体系新增参数组 +NO_PREFIX_BUSINESS = "YW" +NO_PREFIX_SHIPMENT = "SH" +NO_PREFIX_RECEIPT = "HK" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index be20a84..c8b1493 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ """ from app.models.base import Base, TimestampMixin +from app.models.business import Business, BusinessItem, BusinessStatusLog from app.models.business_role import BusinessRole from app.models.customer import Customer, OwnershipChangeLog from app.models.login_log import LoginLog @@ -17,13 +18,20 @@ from app.models.operation_log import OperationLog from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion from app.models.product import Product +from app.models.rating import CustomerRatingGrade +from app.models.receipt import Receipt from app.models.role import Permission, Role, RolePermission +from app.models.shipment import Shipment, ShipmentItem from app.models.user import User, UserRole __all__ = [ "Base", + "Business", + "BusinessItem", "BusinessRole", + "BusinessStatusLog", "Customer", + "CustomerRatingGrade", "LoginLog", "OperationLog", "OwnershipChangeLog", @@ -35,8 +43,11 @@ __all__ = [ "PersonnelPlan", "PersonnelRegion", "Product", + "Receipt", "Role", "RolePermission", + "Shipment", + "ShipmentItem", "TimestampMixin", "User", "UserRole", diff --git a/backend/app/models/business.py b/backend/app/models/business.py new file mode 100644 index 0000000..518b254 --- /dev/null +++ b/backend/app/models/business.py @@ -0,0 +1,218 @@ +"""业务数据模型:业务单、产品明细与状态流转留痕。 + +- 汇总冗余列(total_* 五列)由服务层在明细/发货/回款增删改时**事务内单点 + 重算**,禁手工直改 —— 列表页与 M3 起付判定直接读冗余列,避免每次 JOIN + 聚合;代价是所有写路径必须重算(business_service 统一收口) +- 毛利润相关值(毛利润额/毛利润率/折算毛利润额)**不落库**:它们是 + sale_amount/cost_amount + 时点化阈值参数的派生值,落库会在成本调整或 + 参数改版后漂移出「假快照」;M2 以只读毛利润视图实时计算(复用 + formula.profit_rate/converted_profit),M3 核算台账才是法定固化点 +- product_category 存在明细行级:混合业务按产品逐条计算(对照表 #14) +- 运费仅记录展示,不计入毛利润口径(对照表 #2);协助营销人员一期核算 + 不使用(对照表 #15 剔除协助 25% 分成),字段保留仅记录事实(需规 2.2.1) +""" + +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 业务状态(businesses.status,流转规则见 business_service:终态不可变更) +BUSINESS_STATUS_ACTIVE = 1 +BUSINESS_STATUS_DONE = 2 +BUSINESS_STATUS_TERMINATED = 3 + + +class Business(Base, TimestampMixin, TPlusSyncMixin): + """业务表(需求 2.2.1):业务编号自动生成,即报表 2.4.1 的「合同编号」。""" + + __tablename__ = "businesses" + __table_args__ = mysql_table_args( + "业务表(业务编号自动生成;T+同步不覆盖业务状态)", + Index("idx_businesses_customer_id", "customer_id"), + Index("idx_businesses_owner_personnel_id", "owner_personnel_id"), + Index("idx_businesses_status", "status"), + Index("idx_businesses_business_date", "business_date"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)", + ) + customer_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("customers.id", ondelete="RESTRICT"), + nullable=False, + comment="客户ID(customers.id)", + ) + business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)") + status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)", + ) + owner_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + comment="负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)", + ) + assistant_personnel_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("personnel.id", ondelete="RESTRICT"), + comment="协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)", + ) + is_special: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="特殊业务标记:1是 0否(公司参与等)", + ) + special_note: Mapped[str | None] = mapped_column(String(255), comment="特殊业务说明") + total_sale_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)", + ) + total_cost_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ) + total_ship_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)", + ) + total_received_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)", + ) + total_freight_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + server_default="0", + comment="运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class BusinessItem(Base, TimestampMixin): + """业务产品明细表(需求 2.2.1/2.2.4):毛利润的行级源数据。 + + product_id 可空=自由录入产品(配合「数据提取」一键入产品库); + 产品名称/规格冗余固化,防产品改名影响历史业务。 + sale_amount 由服务层按 数量×单价 计算固化,不信任前端传值。 + cost_amount 来源待 T+ 对接确认(可空,人工可录先行)。 + """ + + __tablename__ = "business_items" + __table_args__ = mysql_table_args( + "业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)", + Index("idx_business_items_business_id", "business_id"), + Index("idx_business_items_product_id", "product_id"), + Index("idx_business_items_product_category", "product_category"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id)", + ) + product_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("products.id", ondelete="RESTRICT"), + comment="产品ID(products.id;NULL=自由录入,未入产品库)", + ) + product_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="产品名称(引用产品时冗余固化)" + ) + spec_model: Mapped[str] = mapped_column( + String(128), + nullable=False, + server_default="''", + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") + product_category: Mapped[str] = mapped_column( + String(32), + nullable=False, + comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", + ) + quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)") + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="销售单价(元;T+ 同步或人工)" + ) + sale_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)", + ) + cost_amount: Mapped[Decimal | None] = mapped_column( + Numeric(14, 2), + comment="成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)", + ) + guided_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class BusinessStatusLog(Base): + """业务状态变更记录表(append-only,需求 2.2.1「支持状态变更记录」)。 + + 不继承 TimestampMixin(留痕无更新语义);business_id 为无外键的固化 ID + (businesses 建表在本表之后的迁移批次内 —— 实为同批,但留痕统一口径: + 被引用方删除后记录仍完整可读,删除保护由服务层检查承担)。 + 首条记录在业务创建时写入(old=new=1 执行中),详情页时间线从创建起展示。 + """ + + __tablename__ = "business_status_logs" + __table_args__ = mysql_table_args( + "业务状态变更记录表(append-only)", + Index("idx_business_status_logs_business_id", "business_id"), + Index("idx_business_status_logs_create_time", "create_time"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + comment="业务ID(固化,无外键;删除保护由服务层承担)", + ) + old_status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, comment="变更前状态(创建首条为 1)" + ) + new_status: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, comment="变更后状态") + change_reason: Mapped[str | None] = mapped_column(String(255), comment="变更原因(终止时必填)") + operator_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="操作人ID(账号删除后置空)", + ) + operator_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="操作人用户名(冗余固化)" + ) + create_time: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间" + ) diff --git a/backend/app/models/rating.py b/backend/app/models/rating.py new file mode 100644 index 0000000..f0310c3 --- /dev/null +++ b/backend/app/models/rating.py @@ -0,0 +1,46 @@ +"""客户评级等级配置模型(需求 2.2.5)。 + +评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 +不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 +第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 +「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 +""" + +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class CustomerRatingGrade(Base, TimestampMixin): + """客户评级等级配置表(需求 2.2.5「等级对应关系可配置」)。 + + 种子四条随迁移初始化:A 优秀 90-100 / B 良好 75-89 / C 合格 60-74 / + D 待改进 0-59。区间不重叠与 0≤min≤max≤100 由应用层校验。 + """ + + __tablename__ = "customer_rating_grades" + __table_args__ = mysql_table_args( + "客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)" + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + grade_name: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, comment="等级名称(如 A/优秀)" + ) + min_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), + nullable=False, + comment="总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分", + ) + max_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, comment="总分上限(展示用)" + ) + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", comment="排序(判定顺序,升序)" + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="说明") diff --git a/backend/app/models/receipt.py b/backend/app/models/receipt.py new file mode 100644 index 0000000..5930083 --- /dev/null +++ b/backend/app/models/receipt.py @@ -0,0 +1,83 @@ +"""回款模型:回款记录与批次追溯。 + +- shipment_id 为**手动指定**的发货批次(对照表 #18):可空=暂未指定; + 必须与 business_id 同属一个业务(服务层强校验,无数据库级约束可表达 + 跨表同属关系);指定后回款天数方可由 formula.payment_days 派生 +- settlement_status 是 **M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算一次, + 台账记录核销状态」):预置于本表避免 M3 反向迁移改 M2 表;M3 台账建立后 + 由核算服务写入,M2 仅维护 0(未核算) +- receipt_date 是 M3 参数版本匹配日(对照表 #11:按回款到账日取时点参数) +- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + TINYINT_TYPE, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + +# 绩效核算状态(receipts.settlement_status) +SETTLEMENT_PENDING = 0 +SETTLEMENT_DONE = 1 + + +class Receipt(Base, TimestampMixin, TPlusSyncMixin): + """回款表(需求 2.2.3):按业务登记到账款项,批次号系统内唯一。""" + + __tablename__ = "receipts" + __table_args__ = mysql_table_args( + "回款表(手动指定发货批次;已核算回款禁改禁删)", + Index("idx_receipts_business_id", "business_id"), + Index("idx_receipts_receipt_date", "receipt_date"), + Index("idx_receipts_settlement_status", "settlement_status"), + Index("idx_receipts_shipment_id", "shipment_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + receipt_batch_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id)", + ) + shipment_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + comment="发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)", + ) + receipt_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="回款到账日(M3 按此日匹配参数版本,对照表 #11)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="回款金额(≥0;一期禁负数/红字)" + ) + is_barter: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)", + ) + settlement_status: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="0", + comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/shipment.py b/backend/app/models/shipment.py new file mode 100644 index 0000000..89c466c --- /dev/null +++ b/backend/app/models/shipment.py @@ -0,0 +1,115 @@ +"""发货模型:发货单与发货产品明细。 + +- ship_date 是**回款天数计算基准日**(对照表 #1:发货日首日计第 1 天), + 回款指定本发货单后由 formula.payment_days 派生回款天数,不在本表落库 +- 运费仅记录展示,不计入毛利润口径(对照表 #2) +- total_amount 为明细行汇总冗余,服务层事务内维护 +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import ( + BIGINT_UNSIGNED, + Base, + TimestampMixin, + TPlusSyncMixin, + mysql_table_args, +) + + +class Shipment(Base, TimestampMixin, TPlusSyncMixin): + """发货单表(需求 2.2.2):每笔发货对应一个业务,支持分批发货。""" + + __tablename__ = "shipments" + __table_args__ = mysql_table_args( + "发货单表(发货日为回款天数基准日,发货日首日计第 1 天)", + Index("idx_shipments_business_id", "business_id"), + Index("idx_shipments_ship_date", "ship_date"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_no: Mapped[str] = mapped_column( + String(32), + nullable=False, + unique=True, + comment="发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)", + ) + business_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("businesses.id", ondelete="RESTRICT"), + nullable=False, + comment="业务ID(businesses.id;终态业务禁止新发货)", + ) + ship_date: Mapped[date] = mapped_column( + Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)" + ) + freight_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", + ) + total_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + server_default="0", + comment="发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + + +class ShipmentItem(Base, TimestampMixin): + """发货产品明细表(需求 2.2.2):记录发货的产品名称、规格、数量等。 + + 价格口径两可(T+ 供价或人工录入,待 IT 确认):sale_price 可空人工补, + amount 由服务层按 数量×单价 计算固化。 + """ + + __tablename__ = "shipment_items" + __table_args__ = mysql_table_args( + "发货产品明细表", + Index("idx_shipment_items_shipment_id", "shipment_id"), + Index("idx_shipment_items_product_id", "product_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + shipment_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("shipments.id", ondelete="RESTRICT"), + nullable=False, + comment="发货单ID(shipments.id)", + ) + product_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("products.id", ondelete="RESTRICT"), + comment="产品ID(products.id;NULL=自由录入)", + ) + product_name: Mapped[str] = mapped_column( + String(128), nullable=False, comment="产品名称(引用产品时冗余固化)" + ) + spec_model: Mapped[str] = mapped_column( + String(128), + nullable=False, + server_default="''", + comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", + ) + unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") + quantity: Mapped[Decimal] = mapped_column( + Numeric(14, 2), nullable=False, comment="发货数量(>0)" + ) + sale_price: Mapped[Decimal | None] = mapped_column( + Numeric(14, 4), comment="发货单价(元;T+ 同步或人工,口径待 IT 确认)" + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + nullable=False, + comment="行金额 = 数量×单价(服务层计算固化,不信任前端传值)", + ) diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index ec0227b..6b4b7fd 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -29,6 +29,13 @@ EXPECTED_TABLES = { "products", "customers", "ownership_change_logs", + "businesses", + "business_items", + "business_status_logs", + "shipments", + "shipment_items", + "receipts", + "customer_rating_grades", } # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 468e351..885769f 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -459,6 +459,210 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t (18, 15, '', '2026-01-01', '18000', 1), (19, 16, '', '2026-01-01', '3000', 1); +-- --------------------------------------------------------------------------- +-- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) +-- --------------------------------------------------------------------------- +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount DECIMAL(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount DECIMAL(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount DECIMAL(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount DECIMAL(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount DECIMAL(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT uk_businesses_business_no UNIQUE (business_no), + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +-- --------------------------------------------------------------------------- +-- 业务产品明细表(V5 迁移;行级类别为折算锚点,指导价 T+ 同步不覆盖) +-- --------------------------------------------------------------------------- +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity DECIMAL(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount DECIMAL(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount DECIMAL(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price DECIMAL(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +-- --------------------------------------------------------------------------- +-- 业务状态变更记录表(V5 迁移,append-only;创建时写首条 old=new=1) +-- --------------------------------------------------------------------------- +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +-- --------------------------------------------------------------------------- +-- 发货单表(V5 迁移;发货日为回款天数基准日,发货日首日计第 1 天) +-- --------------------------------------------------------------------------- +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount DECIMAL(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount DECIMAL(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +-- --------------------------------------------------------------------------- +-- 发货产品明细表(V5 迁移) +-- --------------------------------------------------------------------------- +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity DECIMAL(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price DECIMAL(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount DECIMAL(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +-- --------------------------------------------------------------------------- +-- 回款表(V5 迁移;手动指定发货批次,已核算回款禁改禁删) +-- --------------------------------------------------------------------------- +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount DECIMAL(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +-- --------------------------------------------------------------------------- +-- 客户评级等级配置表(V5 迁移;判定按 sort_order 升序首个 min_score≤总分) +-- --------------------------------------------------------------------------- +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score DECIMAL(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score DECIMAL(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INT NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +-- --------------------------------------------------------------------------- +-- 字典数据:评级等级(与 V5 迁移种子一致;评分项固定 5 项×20 分为前端常量,对照表 #25) +-- --------------------------------------------------------------------------- +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES +('A', 90, 100, 1, '优秀'), +('B', 75, 89, 2, '良好'), +('C', 60, 74, 3, '合格'), +('D', 0, 59, 4, '待改进'); + -- --------------------------------------------------------------------------- -- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作 -- --------------------------------------------------------------------------- @@ -467,4 +671,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('e1133a7c670a'); +INSERT INTO alembic_version (version_num) VALUES ('8efc189b131f'); diff --git a/db/migration/V5__add_business_data_tables.sql b/db/migration/V5__add_business_data_tables.sql new file mode 100644 index 0000000..5fe52f1 --- /dev/null +++ b/db/migration/V5__add_business_data_tables.sql @@ -0,0 +1,186 @@ +-- Running upgrade e1133a7c670a -> 8efc189b131f + +CREATE TABLE customer_rating_grades ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + grade_name VARCHAR(32) NOT NULL COMMENT '等级名称(如 A/优秀)', + min_score NUMERIC(5, 1) NOT NULL COMMENT '总分下限(含);判定=按 sort_order 升序首个 min_score ≤ 总分', + max_score NUMERIC(5, 1) NOT NULL COMMENT '总分上限(展示用)', + sort_order INTEGER NOT NULL COMMENT '排序(判定顺序,升序)' DEFAULT '0', + remark VARCHAR(255) COMMENT '说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_customer_rating_grades PRIMARY KEY (id), + CONSTRAINT uk_customer_rating_grades_grade_name UNIQUE (grade_name) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级等级配置表(等级映射可配置;判定按 sort_order 升序首个 min_score≤总分)' COLLATE utf8mb4_general_ci; + +CREATE TABLE business_status_logs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(固化,无外键;删除保护由服务层承担)', + old_status TINYINT NOT NULL COMMENT '变更前状态(创建首条为 1)', + new_status TINYINT NOT NULL COMMENT '变更后状态', + change_reason VARCHAR(255) COMMENT '变更原因(终止时必填)', + operator_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)', + operator_name VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余固化)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_business_status_logs PRIMARY KEY (id), + CONSTRAINT fk_business_status_logs_operator_id FOREIGN KEY(operator_id) REFERENCES users (id) ON DELETE SET NULL +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务状态变更记录表(append-only)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_status_logs_business_id ON business_status_logs (business_id); + +CREATE INDEX idx_business_status_logs_create_time ON business_status_logs (create_time); + +CREATE TABLE businesses ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_no VARCHAR(32) NOT NULL COMMENT '业务编号,自动生成 YW{YYYYMMDD}-{4位当日序号}(报表合同编号)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(customers.id)', + business_date DATE COMMENT '业务日期(T+ 同步口径)', + status TINYINT NOT NULL COMMENT '业务状态:1执行中 2已完成 3已终止(终态不可变更;T+同步不覆盖)' DEFAULT '1', + owner_personnel_id BIGINT UNSIGNED COMMENT '负责营销人员ID(核算归属锚点;NULL=待指定,M3 核算前置校验非空;变更留痕不回溯)', + assistant_personnel_id BIGINT UNSIGNED COMMENT '协助营销人员ID(一期核算不使用,对照表 #15;仅记录事实)', + is_special TINYINT NOT NULL COMMENT '特殊业务标记:1是 0否(公司参与等)' DEFAULT '0', + special_note VARCHAR(255) COMMENT '特殊业务说明', + total_sale_amount NUMERIC(14, 2) COMMENT '销售额合计(明细行汇总冗余,服务层事务内维护,禁手工直改;不含运费口径)' DEFAULT '0', + total_cost_amount NUMERIC(14, 2) COMMENT '成本合计(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_ship_amount NUMERIC(14, 2) COMMENT '发货金额合计(发货明细汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + total_received_amount NUMERIC(14, 2) COMMENT '回款合计(回款记录汇总冗余,服务层事务内维护;M3 起付判定直接消费)' DEFAULT '0', + total_freight_amount NUMERIC(14, 2) COMMENT '运费合计(发货单汇总冗余,仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_businesses PRIMARY KEY (id), + CONSTRAINT fk_businesses_assistant_personnel_id FOREIGN KEY(assistant_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_businesses_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT, + CONSTRAINT uk_businesses_business_no UNIQUE (business_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务表(业务编号自动生成;T+同步不覆盖业务状态)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_businesses_business_date ON businesses (business_date); + +CREATE INDEX idx_businesses_customer_id ON businesses (customer_id); + +CREATE INDEX idx_businesses_owner_personnel_id ON businesses (owner_personnel_id); + +CREATE INDEX idx_businesses_source_no ON businesses (source_no); + +CREATE INDEX idx_businesses_status ON businesses (status); + +CREATE TABLE business_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入,未入产品库)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + product_category VARCHAR(32) NOT NULL COMMENT '产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)', + quantity NUMERIC(14, 2) NOT NULL COMMENT '数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '销售单价(元;T+ 同步或人工)', + sale_amount NUMERIC(14, 2) NOT NULL COMMENT '销售额 = 数量×销售单价(服务层计算固化;不含运费口径,毛利润率分母)', + cost_amount NUMERIC(14, 2) COMMENT '成本额(T+ 同步或人工录入,来源待 T+ 确认;毛利润额 = 销售额 − 成本额)', + guided_price NUMERIC(14, 4) COMMENT '指导价(人工录入;T+同步不覆盖,需规 2.2.1/2.2.4)', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_business_items PRIMARY KEY (id), + CONSTRAINT fk_business_items_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_business_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务产品明细表(毛利润行级源数据;指导价 T+同步不覆盖)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_business_items_business_id ON business_items (business_id); + +CREATE INDEX idx_business_items_product_category ON business_items (product_category); + +CREATE INDEX idx_business_items_product_id ON business_items (product_id); + +CREATE TABLE shipments ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_no VARCHAR(32) NOT NULL COMMENT '发货单号,自动生成 SH{YYYYMMDD}-{4位当日序号}(内部追溯用)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;终态业务禁止新发货)', + ship_date DATE NOT NULL COMMENT '发货日期(回款天数基准日,发货日首日=第 1 天)', + freight_amount NUMERIC(14, 2) NOT NULL COMMENT '运费金额(仅记录展示;不计入毛利润口径,对照表 #2)' DEFAULT '0', + total_amount NUMERIC(14, 2) NOT NULL COMMENT '发货总金额(明细行汇总冗余,服务层事务内维护,禁手工直改)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_shipments PRIMARY KEY (id), + CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货单表(发货日为回款天数基准日,发货日首日计第 1 天)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipments_business_id ON shipments (business_id); + +CREATE INDEX idx_shipments_ship_date ON shipments (ship_date); + +CREATE INDEX idx_shipments_source_no ON shipments (source_no); + +CREATE TABLE receipts ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + receipt_batch_no VARCHAR(32) NOT NULL COMMENT '回款批次号,自动生成 HK{YYYYMMDD}-{4位当日序号}(系统内唯一;T+ 同步回款以 source_no 幂等)', + business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id)', + shipment_id BIGINT UNSIGNED COMMENT '发货单ID(shipments.id;手动指定的回款对应批次,须与本行业务一致;NULL=暂未指定)', + receipt_date DATE NOT NULL COMMENT '回款到账日(M3 按此日匹配参数版本,对照表 #11)', + amount NUMERIC(14, 2) NOT NULL COMMENT '回款金额(≥0;一期禁负数/红字)', + is_barter TINYINT NOT NULL COMMENT '以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)' DEFAULT '0', + settlement_status TINYINT NOT NULL COMMENT '绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)' DEFAULT '0', + remark VARCHAR(255) COMMENT '备注', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + source_no VARCHAR(64) COMMENT 'T+ 原始单据号(同步幂等去重锚点;唯一键待 IT 定键后升级)', + t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识', + sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)', + synced_at DATETIME COMMENT '最近同步时间', + CONSTRAINT pk_receipts PRIMARY KEY (id), + CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT, + CONSTRAINT fk_receipts_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT, + CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='回款表(手动指定发货批次;已核算回款禁改禁删)' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_receipts_business_id ON receipts (business_id); + +CREATE INDEX idx_receipts_receipt_date ON receipts (receipt_date); + +CREATE INDEX idx_receipts_settlement_status ON receipts (settlement_status); + +CREATE INDEX idx_receipts_shipment_id ON receipts (shipment_id); + +CREATE INDEX idx_receipts_source_no ON receipts (source_no); + +CREATE TABLE shipment_items ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + shipment_id BIGINT UNSIGNED NOT NULL COMMENT '发货单ID(shipments.id)', + product_id BIGINT UNSIGNED COMMENT '产品ID(products.id;NULL=自由录入)', + product_name VARCHAR(128) NOT NULL COMMENT '产品名称(引用产品时冗余固化)', + spec_model VARCHAR(128) NOT NULL COMMENT '规格型号(冗余固化;空串对齐 products 组合唯一键口径)' DEFAULT '', + unit VARCHAR(16) COMMENT '计量单位', + quantity NUMERIC(14, 2) NOT NULL COMMENT '发货数量(>0)', + sale_price NUMERIC(14, 4) COMMENT '发货单价(元;T+ 同步或人工,口径待 IT 确认)', + amount NUMERIC(14, 2) NOT NULL COMMENT '行金额 = 数量×单价(服务层计算固化,不信任前端传值)', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_shipment_items PRIMARY KEY (id), + CONSTRAINT fk_shipment_items_product_id FOREIGN KEY(product_id) REFERENCES products (id) ON DELETE RESTRICT, + CONSTRAINT fk_shipment_items_shipment_id FOREIGN KEY(shipment_id) REFERENCES shipments (id) ON DELETE RESTRICT +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='发货产品明细表' COLLATE utf8mb4_general_ci; + +CREATE INDEX idx_shipment_items_product_id ON shipment_items (product_id); + +CREATE INDEX idx_shipment_items_shipment_id ON shipment_items (shipment_id); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('A', 90, 100, 1, '优秀'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('B', 75, 89, 2, '良好'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('C', 60, 74, 3, '合格'); + +INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order, remark) VALUES ('D', 0, 59, 4, '待改进'); + +UPDATE alembic_version SET version_num='8efc189b131f' WHERE alembic_version.version_num = 'e1133a7c670a'; + diff --git a/db/queries/业务数据.sql b/db/queries/业务数据.sql new file mode 100644 index 0000000..306402a --- /dev/null +++ b/db/queries/业务数据.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- 业务数据查询留档(M2 业务数据管理) +-- +-- 设计要点: +-- - businesses 的 total_* 五列为服务层维护的汇总冗余,查询直接读冗余列 +-- 而非实时 JOIN 聚合;本文件同时给出「对账用」的实时聚合口径 +-- - 回款天数 = formula.payment_days(ship_date, receipt_date),即 +-- DATEDIFF(receipt_date, ship_date) + 1(发货日首日计第 1 天,对照表 #1), +-- 应用层以纯函数为准,SQL 版仅供人工对账 +-- - 毛利润率/折算毛利润额不落库:视图按行实时计算(阈值取 M1 参数快照) +-- ============================================================================ + +-- 1. 业务台账(列表页;冗余列直读) +SELECT b.id, + b.business_no, + c.company_name, + c.customer_type, + b.status, + b.business_date, + p.personnel_name AS owner_name, + pa.personnel_name AS assistant_name, + b.total_sale_amount, + b.total_cost_amount, + b.total_ship_amount, + b.total_received_amount, + b.total_freight_amount +FROM businesses b +JOIN customers c ON c.id = b.customer_id +LEFT JOIN personnel p ON p.id = b.owner_personnel_id +LEFT JOIN personnel pa ON pa.id = b.assistant_personnel_id +WHERE (:keyword IS NULL OR b.business_no LIKE CONCAT('%', :keyword, '%') + OR c.company_name LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR b.status = :status) + AND (:customer_id IS NULL OR b.customer_id = :customer_id) + AND (:owner_personnel_id IS NULL OR b.owner_personnel_id = :owner_personnel_id) +ORDER BY b.id DESC +LIMIT :limit OFFSET :offset; + +-- 2. 汇总冗余对账(五列与明细/发货/回款实时聚合的一致性校验,T11 集成用例同口径) +SELECT b.id, + b.total_sale_amount, + COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) AS live_sale, + b.total_ship_amount, + COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) AS live_ship, + b.total_received_amount, + COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) AS live_received, + b.total_freight_amount, + COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0) AS live_freight +FROM businesses b +WHERE b.total_sale_amount <> COALESCE((SELECT SUM(i.sale_amount) FROM business_items i WHERE i.business_id = b.id), 0) + OR b.total_ship_amount <> COALESCE((SELECT SUM(si.amount) FROM shipments s JOIN shipment_items si ON si.shipment_id = s.id + WHERE s.business_id = b.id), 0) + OR b.total_received_amount <> COALESCE((SELECT SUM(r.amount) FROM receipts r WHERE r.business_id = b.id), 0) + OR b.total_freight_amount <> COALESCE((SELECT SUM(s.freight_amount) FROM shipments s WHERE s.business_id = b.id), 0); + +-- 3. 未指定发货批次的回款(M3 核算前置校验名单;列表页筛选项) +SELECT r.id, r.receipt_batch_no, b.business_no, r.receipt_date, r.amount, r.is_barter +FROM receipts r +JOIN businesses b ON b.id = r.business_id +WHERE r.shipment_id IS NULL + AND r.settlement_status = 0 +ORDER BY r.receipt_date; + +-- 4. 回款天数对账视图(SQL 版仅供人工核对;应用层以 formula.payment_days 为准) +SELECT r.id, + r.receipt_batch_no, + b.business_no, + s.shipment_no, + s.ship_date, + r.receipt_date, + DATEDIFF(r.receipt_date, s.ship_date) + 1 AS payment_days +FROM receipts r +JOIN businesses b ON b.id = r.business_id +LEFT JOIN shipments s ON s.id = r.shipment_id +WHERE r.shipment_id IS NOT NULL +ORDER BY r.receipt_date DESC; + +-- 5. 按产品类别的毛利润汇总(毛利润视图的类别汇总口径;阈值需按 M1 参数快照逐日匹配, +-- 此处仅聚合源数据,折算在应用层以 formula.converted_profit 计算) +SELECT i.product_category, + SUM(i.sale_amount) AS sale_total, + SUM(i.cost_amount) AS cost_total, + SUM(i.sale_amount - COALESCE(i.cost_amount, 0)) AS profit_total +FROM business_items i +WHERE i.business_id = :business_id +GROUP BY i.product_category; + +-- 6. 业务状态流转时间线(详情页;创建首条 old=new=1) +SELECT l.old_status, l.new_status, l.change_reason, l.operator_name, l.create_time +FROM business_status_logs l +WHERE l.business_id = :business_id +ORDER BY l.create_time, l.id; + +-- 7. 发货单与明细(列表 + 行展开) +SELECT s.id, s.shipment_no, b.business_no, s.ship_date, s.freight_amount, s.total_amount +FROM shipments s +JOIN businesses b ON b.id = s.business_id +WHERE (:business_id IS NULL OR s.business_id = :business_id) + AND (:ship_date_from IS NULL OR s.ship_date >= :ship_date_from) + AND (:ship_date_to IS NULL OR s.ship_date <= :ship_date_to) +ORDER BY s.id DESC +LIMIT :limit OFFSET :offset; + +SELECT si.product_name, si.spec_model, si.unit, si.quantity, si.sale_price, si.amount +FROM shipment_items si +WHERE si.shipment_id = :shipment_id +ORDER BY si.id; + +-- 8. 评级等级判定(前端按 sort_order 升序首个 min_score ≤ 总分取等级;SQL 版供后端复用) +SELECT grade_name, min_score, max_score +FROM customer_rating_grades +WHERE min_score <= :total_score +ORDER BY sort_order +LIMIT 1; -- 2.27.0 From 899bf3afa5ad9687e0c1d87c1afa6b5dcab6b50e Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 11:24:29 +0800 Subject: [PATCH 026/100] =?UTF-8?q?docs(test):=20=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9D=97=E6=B5=8B=E8=AF=95=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=EF=BC=8848=20=E4=BE=8B/=E5=AE=A1=E8=AE=A1=E7=9B=B2?= =?UTF-8?q?=E5=8C=BA=E7=BC=BA=E9=99=B7/=E5=BE=85=E8=A1=A5=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=9B=B4=E6=96=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/tests/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/tests/README.md b/backend/tests/README.md index 284e212..8cbd5d3 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -92,6 +92,16 @@ uv run pytest tests/integration -q # 仅集成 运行:`uv run pytest -q` → **406 passed**(2026-10-13 全量含回归;T5 冒烟另验证提交/生效/撤回/对比/导出/预览全链路)。 +### 基础数据管理(2026-10-13 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_personnel.py` | 未登录 401、新增默认在职、工资标准 str 直出、经理自引用/不存在或已离职/岗位停用 400、删除保护(团队成员与客户归属引用→引导改离职)、辐射区域与拓展计划全量替换(含清空与详情回显)、在职/离职切换、审计成功与失败双记录 | 20 | +| `tests/integration/test_products.py` | 未登录 401、类别值域 400、名称+规格组合唯一(同名称不同规格放行)、单价负数/非数字 400、启停切换、改类别、审计留痕 | 14 | +| `tests/integration/test_customers.py` | 未登录 401、客户分类值域 400、「公司名称+负责人」组合唯一(同公司不同负责人放行)、归属变更(主表更新+留痕字段断言)、归属人员离职 400、新旧相同含双方为空 400、留痕分页 join 姓名、编辑端点不触归属、审计留痕 | 14 | + +运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。 + ## 三、运行记录 | 日期 | 范围 | 用例数 | 结果 | 备注 | @@ -101,6 +111,7 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 | | 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 | | 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) | +| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) | ## 四、测试发现并修复的缺陷 @@ -120,13 +131,14 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 | | 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 | | 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 | +| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) | ## 五、待补用例 | 模块 | 状态 | 说明 | | --- | --- | --- | -| 基础数据管理 | 未开始 | 需求已澄清(2026-10-12);依赖 T+ 对接方式确认 | -| 业务数据管理 | 未开始 | 需求已澄清(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) | +| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护在业务数据表就位后于 T11 补测 | +| 业务数据管理 | 进行中 | 集成用例在 T11 统一编写(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) | | 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) | | 报表统计 | 未开始 | 依赖报表口径确认 | -- 2.27.0 From 1625907380348a51c10a128f13487bb7a9178bf8 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:01:47 +0800 Subject: [PATCH 027/100] =?UTF-8?q?feat(business):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E8=AF=84=E7=BA=A7=E7=AD=89=E7=BA=A7=E6=98=A0=E5=B0=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=8C=BA=E9=97=B4=E9=87=8D?= =?UTF-8?q?=E5=8F=A0=E5=88=A4=E5=AE=9A/=E5=90=8D=E7=A7=B0=E5=94=AF?= =?UTF-8?q?=E4=B8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 判定=按 sort_order 升序首个 min_score≤总分,边界相邻不算重叠 - 新增同样执行重叠判定(判定表语义下插出重叠即脏配置) - 等级配置无下游引用(评级为纯计算器,对照表 #25),允许清空 Co-Authored-By: Claude Code --- backend/app/api/v1/business/__init__.py | 8 + backend/app/api/v1/business/ratings.py | 74 +++++ backend/app/repositories/rating_repository.py | 58 ++++ backend/app/schemas/rating.py | 79 +++++ backend/app/services/rating_service.py | 279 ++++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 backend/app/api/v1/business/__init__.py create mode 100644 backend/app/api/v1/business/ratings.py create mode 100644 backend/app/repositories/rating_repository.py create mode 100644 backend/app/schemas/rating.py create mode 100644 backend/app/services/rating_service.py diff --git a/backend/app/api/v1/business/__init__.py b/backend/app/api/v1/business/__init__.py new file mode 100644 index 0000000..91b1dc7 --- /dev/null +++ b/backend/app/api/v1/business/__init__.py @@ -0,0 +1,8 @@ +"""业务数据管理接口(需求 2.2)。 + +挂载于 `/api/v1/business/*`,含业务单据维护、客户评级等级配置等业务数据入口。 + +与 `api/v1/system` 的权限分工:业务数据是**业务操作**,登录即可访问 +(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息, +由 service 层写入操作日志。 +""" diff --git a/backend/app/api/v1/business/ratings.py b/backend/app/api/v1/business/ratings.py new file mode 100644 index 0000000..540ab4f --- /dev/null +++ b/backend/app/api/v1/business/ratings.py @@ -0,0 +1,74 @@ +"""客户评级等级配置接口(需求 2.2.5)。 + +评级计算器的等级映射判定表:判定按 sort_order 升序取首个 min_score ≤ 总分 +的等级,max_score 仅展示。区间合法(0≤min≤max≤100)与互不重叠由后端校验 +—— 前端表单只是展示约束,后端校验是唯一防线。 + +列表不分页全量返回:等级映射是判定表,数量个位数,分页无意义。 +业务数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser); +写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, ok +from app.models import User +from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn +from app.services import rating_service + +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/rating-grades", tags=["客户评级"]) + + +@router.get("", response_model=ApiResponse[list[RatingGradeOut]], summary="评级等级列表") +def list_grades( + session: DbSession, + _: CurrentUser, +) -> ApiResponse[list[RatingGradeOut]]: + """全量等级列表,按判定顺序(sort_order 升序)返回,不分页。""" + return ok(rating_service.list_grades(session)) + + +@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级") +def create_grade( + session: DbSession, + current_user: CurrentUser, + payload: RatingGradeCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[RatingGradeOut]: + """新增评级等级。区间须 0≤min≤max≤100 且与既有等级不重叠。""" + return ok( + rating_service.create_grade(session, payload, operator=current_user, meta=meta), + message="评级等级已创建", + ) + + +@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级") +def update_grade( + session: DbSession, + current_user: CurrentUser, + grade_id: Annotated[int, Path(ge=1)], + payload: RatingGradeUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[RatingGradeOut]: + """编辑评级等级(全量替换)。区间须合法且与其他等级不重叠。""" + return ok( + rating_service.update_grade(session, grade_id, payload, operator=current_user, meta=meta), + message="已保存", + ) + + +@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级") +def delete_grade( + session: DbSession, + current_user: CurrentUser, + grade_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """物理删除评级等级(等级表无下游引用,允许清空,理由见 service 说明)。""" + rating_service.delete_grade(session, grade_id, operator=current_user, meta=meta) + return ok(message="评级等级已删除") diff --git a/backend/app/repositories/rating_repository.py b/backend/app/repositories/rating_repository.py new file mode 100644 index 0000000..ec0d427 --- /dev/null +++ b/backend/app/repositories/rating_repository.py @@ -0,0 +1,58 @@ +"""客户评级等级配置数据访问。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import CustomerRatingGrade + +# ==================== 读取 ==================== + + +def list_all(session: Session) -> list[CustomerRatingGrade]: + """全量等级,按 sort_order 升序、id 兜底(同序次序稳定)。 + + 不分页:等级映射是判定表,数量个位数,分页无意义。 + """ + stmt = select(CustomerRatingGrade).order_by( + CustomerRatingGrade.sort_order, CustomerRatingGrade.id + ) + return list(session.execute(stmt).scalars()) + + +def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None: + """按主键查询。 + + 等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update`` + 锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。 + """ + return session.get(CustomerRatingGrade, grade_id) + + +def get_by_name(session: Session, grade_name: str) -> CustomerRatingGrade | None: + """按等级名称查询。 + + grade_name 是 uk_customer_rating_grades_grade_name 唯一键的键值,先查 + 用于给出即时业务提示;并发窗口仍存在,最终由 IntegrityError 兜底 + (见 rating_service)。 + """ + stmt = select(CustomerRatingGrade).where(CustomerRatingGrade.grade_name == grade_name) + return session.execute(stmt).scalar_one_or_none() + + +# ==================== 写入 ==================== + + +def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade: + """新增等级。只 flush 以取得自增 id。""" + session.add(grade) + session.flush() + return grade + + +def delete(session: Session, grade: CustomerRatingGrade) -> None: + """物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" + session.delete(grade) + session.flush() diff --git a/backend/app/schemas/rating.py b/backend/app/schemas/rating.py new file mode 100644 index 0000000..18fe979 --- /dev/null +++ b/backend/app/schemas/rating.py @@ -0,0 +1,79 @@ +"""客户评级等级配置的请求/响应模型。 + +分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number +无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一 +收敛在 service 层(与 schemas/product.py 同一口径)。 + +注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠 +是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 —— +值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。 +""" + +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _parse_score(value: str, *, label: str) -> str: + """分值字符串格式校验:必须可被 Decimal 解析且为有限数。 + + ``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在 + 入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative + 同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。 + """ + try: + score = Decimal(value.strip()) + except InvalidOperation as exc: + raise ValueError(f"{label}必须是有效数字:{value}") from exc + if not score.is_finite(): + raise ValueError(f"{label}必须为有限数字:{value}") + return value + + +class RatingGradeOut(BaseModel): + """评级等级响应(列表直出)。分值按红线以字符串出参。""" + + id: int + grade_name: str + min_score: str + max_score: str + sort_order: int + remark: str | None = None + create_time: datetime + update_time: datetime + + +class RatingGradeCreateIn(BaseModel): + """新增评级等级。 + + min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。 + sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)") + min_score: str = Field(description="总分下限(含),0-100 数字字符串") + max_score: str = Field(description="总分上限(展示用),0-100 数字字符串") + sort_order: int = Field(default=0, ge=0, description="判定顺序,升序") + remark: str | None = Field(default=None, max_length=255, description="说明") + + @field_validator("min_score") + @classmethod + def _check_min_score(cls, value: str) -> str: + """总分下限须为有限数字(值域 0-100 由 service 层判定)。""" + return _parse_score(value, label="总分下限") + + @field_validator("max_score") + @classmethod + def _check_max_score(cls, value: str) -> str: + """总分上限须为有限数字(值域 0-100 由 service 层判定)。""" + return _parse_score(value, label="总分上限") + + +class RatingGradeUpdateIn(RatingGradeCreateIn): + """编辑评级等级。 + + 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 + """ diff --git a/backend/app/services/rating_service.py b/backend/app/services/rating_service.py new file mode 100644 index 0000000..7569443 --- /dev/null +++ b/backend/app/services/rating_service.py @@ -0,0 +1,279 @@ +"""客户评级等级配置业务编排。 + +评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库 +——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个 +min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级): + +- 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串 + 到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内** + 完成,失败的 400 同样留痕(2026-10-13 定稿口径) +- 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表 + 失效。判定为**闭区间两两相交**:任一其他等级满足 + ``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。 + 边界相邻不算重叠(前段 max=89、后段 min=90)—— 种子 A/B/C/D 即按 + 相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order + 靠前的等级,相邻无歧义 + +删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不 +回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出 +等级(前端已对应提示),故不强制保留任何一条。 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_RATING, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.models import CustomerRatingGrade, User +from app.repositories import rating_repository +from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta + +logger = logging.getLogger(__name__) + +# 分值域上下界(Decimal 常量,禁用 float 参与比较) +_SCORE_FLOOR = Decimal("0") +_SCORE_CEIL = Decimal("100") + +# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位) +_ERR_DUP_ENTRY = 1062 + + +def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: + """ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。""" + return RatingGradeOut( + id=grade.id, + grade_name=grade.grade_name, + min_score=str(grade.min_score), + max_score=str(grade.max_score), + sort_order=grade.sort_order, + remark=grade.remark, + create_time=grade.create_time, + update_time=grade.update_time, + ) + + +def _to_decimal(value: str) -> Decimal: + """数字字符串转 Decimal。 + + 入参已 str 化再进 Decimal:即使上游口径变化传入数字类型,也不经 float + 二进制近似,分值红线在最后一道转换处仍然成立。schema 已保证可解析, + 此处不再重复校验。 + """ + return Decimal(str(value)) + + +def _ensure_score_range(min_score: Decimal, max_score: Decimal) -> None: + """区间合法性:0 ≤ min_score ≤ max_score ≤ 100。 + + 超界/倒挂任一不满足即「分值区间不合法」——统一一条消息不区分具体 + 越界方式,前端据此整体提示。须在审计上下文内调用,失败也要留痕。 + """ + if not (_SCORE_FLOOR <= min_score <= max_score <= _SCORE_CEIL): + raise BadRequestError("分值区间不合法") + + +def _ensure_no_overlap( + grades: list[CustomerRatingGrade], + new_min: Decimal, + new_max: Decimal, + *, + exclude_id: int | None = None, +) -> None: + """新区间与既有等级**不得重叠**(闭区间两两相交判定)。 + + 任一其他等级满足 ``other.min_score <= new_max 且 new_min <= other.max_score`` + 即重叠,400 并点名冲突等级,便于前端定位到具体行。边界相邻不算重叠 + (见模块 docstring)。exclude_id 供编辑时排除自身。 + + :raises BadRequestError: 分值区间与等级 X 重叠 + """ + for other in grades: + if exclude_id is not None and other.id == exclude_id: + continue + if other.min_score <= new_max and new_min <= other.max_score: + raise BadRequestError(f"分值区间与等级 {other.grade_name} 重叠") + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。 + + 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 + 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 + """ + orig_args = getattr(exc.orig, "args", ()) + return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY + + +def _raise_if_duplicate(exc: IntegrityError) -> None: + """唯一键冲突统一转业务提示;其余完整性错误原样重抛。""" + if _is_duplicate_entry(exc): + raise BadRequestError("该等级名称已存在") from exc + raise exc + + +def list_grades(session: Session) -> list[RatingGradeOut]: + """全量等级列表,按判定顺序(sort_order 升序)直出。 + + 不分页:等级映射是判定表,数量个位数,分页无意义。 + """ + return [_to_out(grade) for grade in rating_repository.list_all(session)] + + +def create_grade( + session: Session, + payload: RatingGradeCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RatingGradeOut: + """新增评级等级。 + + 区间须 0≤min≤max≤100 且与既有等级不重叠(新增同样判重叠:判定表 + 语义下重叠即脏配置,没有「先停用旧区间」之类的两阶段配置手段)。 + + :raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_CREATE, + summary=f"新增评级等级 {payload.grade_name}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:分值非法的 400 也要留痕(成功与失败都记) + new_min = _to_decimal(payload.min_score) + new_max = _to_decimal(payload.max_score) + _ensure_score_range(new_min, new_max) + _ensure_no_overlap(rating_repository.list_all(session), new_min, new_max) + if rating_repository.get_by_name(session, payload.grade_name) is not None: + raise BadRequestError("该等级名称已存在") + + grade = CustomerRatingGrade( + grade_name=payload.grade_name, + min_score=new_min, + max_score=new_max, + sort_order=payload.sort_order, + remark=payload.remark, + ) + try: + rating_repository.create(session, grade) + # 响应与后置动作全部在 commit 之前完成(见 product_service 同处说明) + out = _to_out(grade) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发提交同名等级会撞 uk_customer_rating_grades_grade_name + session.rollback() + _raise_if_duplicate(exc) + + logger.info("新增评级等级 | grade_id=%s | name=%s", grade.id, payload.grade_name) + return out + + +def update_grade( + session: Session, + grade_id: int, + payload: RatingGradeUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> RatingGradeOut: + """编辑评级等级(全量替换)。 + + 区间与其他等级不得重叠 —— 重叠判定以**修改后的区间**对全部既有等级 + (排除自身)两两相交,命中即 400 并点名冲突等级。 + + :raises NotFoundError: 评级等级不存在 + :raises BadRequestError: 分值区间不合法 / 分值区间与等级 X 重叠 / 该等级名称已存在 + """ + grade = rating_repository.get_by_id(session, grade_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_UPDATE, + summary=f"修改评级等级 {grade.grade_name if grade else f'#{grade_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内(同 create 口径) + new_min = _to_decimal(payload.min_score) + new_max = _to_decimal(payload.max_score) + _ensure_score_range(new_min, new_max) + if grade is None: + raise NotFoundError("评级等级不存在") + + _ensure_no_overlap( + rating_repository.list_all(session), new_min, new_max, exclude_id=grade_id + ) + existed = rating_repository.get_by_name(session, payload.grade_name) + if existed is not None and existed.id != grade_id: + raise BadRequestError("该等级名称已存在") + + grade.grade_name = payload.grade_name + grade.min_score = new_min + grade.max_score = new_max + grade.sort_order = payload.sort_order + grade.remark = payload.remark + out = _to_out(grade) + try: + session.commit() + except IntegrityError as exc: + # 改名可能撞 uk_customer_rating_grades_grade_name(本行改值与他人既有数据重合) + session.rollback() + _raise_if_duplicate(exc) + + logger.info("修改评级等级 | grade_id=%s | name=%s", grade_id, payload.grade_name) + return out + + +def delete_grade( + session: Session, + grade_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """物理删除评级等级。 + + 等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定 + 只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。 + + **允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示), + 不强制保留任何一条。 + + :raises NotFoundError: 评级等级不存在 + """ + grade = rating_repository.get_by_id(session, grade_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_DELETE, + summary=f"删除评级等级 {grade.grade_name if grade else f'#{grade_id}'}", + ), + meta=meta, + ): + if grade is None: + raise NotFoundError("评级等级不存在") + + grade_name = grade.grade_name # 固化:对象随删除失效 + rating_repository.delete(session, grade) + session.commit() + logger.info("删除评级等级 | grade_name=%s", grade_name) -- 2.27.0 From 0cdc6246c46805231769dbcf6b85122409d2603a Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:01:48 +0800 Subject: [PATCH 028/100] =?UTF-8?q?feat(business):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E7=94=9F=E6=88=90/=E6=98=8E=E7=BB=86/=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA/=E5=BD=92=E5=B1=9E=E7=95=99=E7=97=95/=E6=AF=9B?= =?UTF-8?q?=E5=88=A9=E6=B6=A6=E8=A7=86=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编号 YW{日期}-{序号}:MAX+FOR UPDATE 并发安全 + 1062 兜底重试(no_generator 三单据共用) - 明细全量替换重算五项汇总冗余;销售额服务层按数量×单价固化不信任前端 - 状态机终态拦截、终止必填原因、创建写首条留痕;归属变更同事务留痕防旁路 - 毛利润视图按行实时计算(不含运费 #2),阈值按 asOf 取 M1 快照,率输出 Fraction 分数字符串同 M1 预览口径;成本缺失行标注无法计算 - 入参契约统一为蛇形直出(撤回 customer/business 的驼峰 alias,与前端 businessRoles.ts 模式对齐) Co-Authored-By: Claude Code --- backend/app/api/router.py | 8 + backend/app/api/v1/business/businesses.py | 177 +++++ .../app/repositories/business_repository.py | 257 ++++++ backend/app/schemas/business.py | 324 ++++++++ backend/app/schemas/customer.py | 24 +- backend/app/services/business_service.py | 747 ++++++++++++++++++ backend/app/services/no_generator.py | 46 ++ 7 files changed, 1565 insertions(+), 18 deletions(-) create mode 100644 backend/app/api/v1/business/businesses.py create mode 100644 backend/app/repositories/business_repository.py create mode 100644 backend/app/schemas/business.py create mode 100644 backend/app/services/business_service.py create mode 100644 backend/app/services/no_generator.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 4d94019..f8c155f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -11,6 +11,8 @@ from app.api.v1 import auth from app.api.v1.base import customers as base_customers from app.api.v1.base import personnel as base_personnel from app.api.v1.base import products as base_products +from app.api.v1.business import businesses as business_businesses +from app.api.v1.business import ratings as business_ratings from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import logs as system_logs from app.api.v1.system import params as system_params @@ -43,4 +45,10 @@ base_router.include_router(base_products.router) base_router.include_router(base_customers.router) v1_router.include_router(base_router) +# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问 +business_router = APIRouter(prefix="/business") +business_router.include_router(business_businesses.router) +business_router.include_router(business_ratings.router) +v1_router.include_router(business_router) + api_router.include_router(v1_router) diff --git a/backend/app/api/v1/business/businesses.py b/backend/app/api/v1/business/businesses.py new file mode 100644 index 0000000..440babd --- /dev/null +++ b/backend/app/api/v1/business/businesses.py @@ -0,0 +1,177 @@ +"""业务管理接口(需求 2.2.1/2.2.4)。 + +校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service` +模块 docstring;基础数据属业务操作,登录即可访问。 +""" + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageQuery, PageResult, ok +from app.models import User +from app.schemas.business import ( + BusinessCreateIn, + BusinessDetailOut, + BusinessOut, + BusinessOwnershipIn, + BusinessQuery, + BusinessStatusIn, + BusinessStatusLogOut, + BusinessUpdateIn, + GrossProfitOut, + OwnershipChangeLogOut, +) +from app.services import business_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/businesses", tags=["业务管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表") +def list_businesses( + session: DbSession, + _: CurrentUser, + query: Annotated[BusinessQuery, Query()], +) -> ApiResponse[PageResult[BusinessOut]]: + """分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。""" + return ok(business_service.list_businesses(session, query)) + + +@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务") +def create_business( + session: DbSession, + current_user: CurrentUser, + payload: BusinessCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。""" + return ok( + business_service.create_business(session, payload, operator=current_user, meta=meta), + message="业务已创建", + ) + + +@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情") +def get_business( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[BusinessDetailOut]: + """业务详情,附产品明细行,供编辑抽屉一次回显。""" + return ok(business_service.get_detail(session, business_id)) + + +@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务") +def update_business( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。""" + return ok( + business_service.update_business( + session, business_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务") +def delete_business( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除业务。有发货或回款记录时禁删,应改为已终止。""" + business_service.delete_business(session, business_id, operator=current_user, meta=meta) + return ok(message="业务已删除") + + +@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转") +def change_status( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessStatusIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。""" + return ok( + business_service.change_status( + session, business_id, payload, operator=current_user, meta=meta + ), + message="状态已变更", + ) + + +@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属") +def change_ownership( + session: DbSession, + current_user: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + payload: BusinessOwnershipIn, + meta: RequestMetaDep, +) -> ApiResponse[BusinessOut]: + """调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。""" + return ok( + business_service.change_ownership( + session, business_id, payload, operator=current_user, meta=meta + ), + message="归属已调整", + ) + + +@router.get( + "/{business_id}/ownership-logs", + response_model=ApiResponse[PageResult[OwnershipChangeLogOut]], + summary="归属变更记录", +) +def list_ownership_logs( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: + """某业务的归属变更留痕(分页,按时间倒序)。""" + return ok(business_service.list_ownership_logs(session, business_id, query)) + + +@router.get( + "/{business_id}/status-logs", + response_model=ApiResponse[PageResult[BusinessStatusLogOut]], + summary="状态流转记录", +) +def list_status_logs( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()], +) -> ApiResponse[PageResult[BusinessStatusLogOut]]: + """某业务的状态流转时间线(分页,按时间正序,从创建起展示)。""" + return ok(business_service.list_status_logs(session, business_id, query)) + + +@router.get( + "/{business_id}/gross-profit", + response_model=ApiResponse[GrossProfitOut], + summary="毛利润数据视图", +) +def gross_profit( + session: DbSession, + _: CurrentUser, + business_id: Annotated[int, Path(ge=1)], + as_of: Annotated[date | None, Query()] = None, +) -> ApiResponse[GrossProfitOut]: + """按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。 + + 阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。 + """ + return ok(business_service.gross_profit(session, business_id, as_of)) diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py new file mode 100644 index 0000000..50950db --- /dev/null +++ b/backend/app/repositories/business_repository.py @@ -0,0 +1,257 @@ +"""业务数据仓储:业务单、明细、状态留痕与归属留痕。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 +归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在 +models/customer.py 定义)。 +""" + +from datetime import date + +from sqlalchemy import Row, Select, delete, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.models import ( + Business, + BusinessItem, + BusinessStatusLog, + Customer, + OwnershipChangeLog, + Personnel, +) +from app.models.customer import OWNER_TYPE_BUSINESS + + +def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: + """按主键取业务行;for_update 供删除保护与状态机使用。""" + stmt = select(Business).where(Business.id == business_id) + if for_update: + stmt = stmt.with_for_update() + return session.execute(stmt).scalars().first() + + +def create(session: Session, business: Business) -> None: + """新增业务行(不 commit,flush 让自增 id 就位)。""" + session.add(business) + session.flush() + + +def get_with_names( + session: Session, business_id: int +) -> tuple[Business, str, str | None, str | None] | None: + """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。""" + owner = aliased(Personnel) + assistant = aliased(Personnel) + row = session.execute( + select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) + .join(Customer, Customer.id == Business.customer_id) + .outerjoin(owner, owner.id == Business.owner_personnel_id) + .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) + .where(Business.id == business_id) + ).first() + return row + + +def _apply_filters( + stmt: Select, + *, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> Select: + """业务列表的筛选条件(调用方须已 join Customer)。""" + if keyword: + # 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy + # 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合) + stmt = stmt.where( + or_( + Business.business_no.contains(keyword, autoescape=True), + Customer.company_name.contains(keyword, autoescape=True), + ) + ) + if status is not None: + stmt = stmt.where(Business.status == status) + if customer_id is not None: + stmt = stmt.where(Business.customer_id == customer_id) + if owner_personnel_id is not None: + stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id) + if date_from is not None: + stmt = stmt.where(Business.business_date >= date_from) + if date_to is not None: + stmt = stmt.where(Business.business_date <= date_to) + return stmt + + +def list_businesses( + session: Session, + *, + offset: int, + limit: int, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> list[tuple[Business, str, str | None, str | None]]: + """分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。""" + owner = aliased(Personnel) + assistant = aliased(Personnel) + stmt = select( + Business, Customer.company_name, owner.personnel_name, assistant.personnel_name + ).join(Customer, Customer.id == Business.customer_id) + stmt = _apply_filters( + stmt, + keyword=keyword, + status=status, + customer_id=customer_id, + owner_personnel_id=owner_personnel_id, + date_from=date_from, + date_to=date_to, + ) + stmt = ( + stmt.outerjoin(owner, owner.id == Business.owner_personnel_id) + .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) + .order_by(Business.id.desc()) + .offset(offset) + .limit(limit) + ) + return list(session.execute(stmt).all()) + + +def count_businesses( + session: Session, + *, + keyword: str | None, + status: int | None, + customer_id: int | None, + owner_personnel_id: int | None, + date_from: date | None, + date_to: date | None, +) -> int: + stmt = ( + select(func.count()) + .select_from(Business) + .join(Customer, Customer.id == Business.customer_id) + ) + stmt = _apply_filters( + stmt, + keyword=keyword, + status=status, + customer_id=customer_id, + owner_personnel_id=owner_personnel_id, + date_from=date_from, + date_to=date_to, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_items(session: Session, business_id: int) -> list[BusinessItem]: + """业务明细行(编辑回显与汇总重算共用)。""" + return list( + session.execute( + select(BusinessItem) + .where(BusinessItem.business_id == business_id) + .order_by(BusinessItem.id) + ).scalars() + ) + + +def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None: + """全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。 + + 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 + 会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。 + """ + session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) + session.flush() + for row in rows: + row.business_id = business_id + session.add(row) + session.flush() + + +def count_items(session: Session, business_ids: list[int]) -> dict[int, int]: + """业务明细行数(删除保护备用)。""" + if not business_ids: + return {} + rows = session.execute( + select(BusinessItem.business_id, func.count()) + .where(BusinessItem.business_id.in_(business_ids)) + .group_by(BusinessItem.business_id) + ).all() + return dict(rows) + + +def add_status_log(session: Session, log: BusinessStatusLog) -> None: + """追加状态留痕(不 commit)。""" + session.add(log) + session.flush() + + +def list_status_logs( + session: Session, business_id: int, *, offset: int, limit: int +) -> list[BusinessStatusLog]: + """状态流转时间线(详情页),按时间正序(时间线从创建起展示)。""" + return list( + session.execute( + select(BusinessStatusLog) + .where(BusinessStatusLog.business_id == business_id) + .order_by(BusinessStatusLog.create_time, BusinessStatusLog.id) + .offset(offset) + .limit(limit) + ).scalars() + ) + + +def count_status_logs(session: Session, business_id: int) -> int: + return int( + session.execute( + select(func.count()) + .select_from(BusinessStatusLog) + .where(BusinessStatusLog.business_id == business_id) + ).scalar_one() + ) + + +def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: + """追加归属变更留痕(不 commit)。""" + session.add(log) + session.flush() + + +def list_ownership_logs( + session: Session, business_id: int, *, offset: int, limit: int +) -> list[Row]: + """某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。""" + old_p = aliased(Personnel) + new_p = aliased(Personnel) + return list( + session.execute( + select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name) + .outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id) + .outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id) + .where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id == business_id, + ) + .order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc()) + .offset(offset) + .limit(limit) + ).all() + ) + + +def count_ownership_logs(session: Session, business_id: int) -> int: + return int( + session.execute( + select(func.count()) + .select_from(OwnershipChangeLog) + .where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id == business_id, + ) + ).scalar_one() + ) diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py new file mode 100644 index 0000000..cfdf5b8 --- /dev/null +++ b/backend/app/schemas/business.py @@ -0,0 +1,324 @@ +"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。 + +金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float); +派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有 +源数据,派生值由毛利润视图实时计算。 +""" + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.core.constants import ( + PRODUCT_CATEGORIES, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.response import PageQuery + +# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径) +AMOUNT_MAX = Decimal("999999999999.99") +# DECIMAL(14,4) 容量上限:整数部分 10 位 +PRICE_MAX = Decimal("9999999999.9999") + + +def _check_amount( + value: str | None, label: str, cap: Decimal, allow_positive_only: bool = False +) -> str | None: + """字符串金额的通用校验:数字/有限/非负/容量/两位小数。 + + 小数位超过两位直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + """ + if value is None: + return None + try: + amount = Decimal(value) + except InvalidOperation: + raise ValueError(f"{label}须为有效数字") from None + if not amount.is_finite(): + raise ValueError(f"{label}须为有效数字") + if allow_positive_only: + if amount <= 0: + raise ValueError(f"{label}必须大于 0") + elif amount < 0: + raise ValueError(f"{label}不能为负数") + if amount > cap: + raise ValueError(f"{label}超出可录入上限") + if -amount.as_tuple().exponent > 2: + raise ValueError(f"{label}最多两位小数") + return value + + +class BusinessItemIn(BaseModel): + """业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。 + + product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是 + 蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入") + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空=空串归一" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + product_category: str = Field( + description="产品类别:municipal_water/rail_transit(行级折算锚点)" + ) + quantity: str = Field(description="数量,>0") + sale_price: str | None = Field(default=None, description="销售单价(元)") + cost_amount: str | None = Field( + default=None, description="成本额(元);毛利润额 = 销售额 − 成本额" + ) + guided_price: str | None = Field(default=None, description="指导价(元)") + remark: str | None = Field(default=None, max_length=255) + + @field_validator("product_category") + @classmethod + def _check_category(cls, value: str) -> str: + if value not in PRODUCT_CATEGORIES: + raise ValueError("产品类别不合法") + return value + + @field_validator("quantity") + @classmethod + def _check_quantity(cls, value: str) -> str: + return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value + + @field_validator("sale_price", "cost_amount", "guided_price") + @classmethod + def _check_prices(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", PRICE_MAX) + + +class BusinessItemOut(BaseModel): + """业务产品明细行响应。金额/数量一律 str。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int | None = None + product_name: str + spec_model: str | None = None + unit: str | None = None + product_category: str + quantity: str + sale_price: str | None = None + sale_amount: str + cost_amount: str | None = None + guided_price: str | None = None + remark: str | None = None + + +class BusinessOut(BaseModel): + """业务响应(列表与写操作回包共用)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + business_no: str + customer_id: int + customer_name: str | None = None + business_date: date | None = None + status: int = 1 + owner_personnel_id: int | None = None + owner_personnel_name: str | None = None + assistant_personnel_id: int | None = None + assistant_personnel_name: str | None = None + is_special: int = 0 + special_note: str | None = None + total_sale_amount: str | None = None + total_cost_amount: str | None = None + total_ship_amount: str | None = None + total_received_amount: str | None = None + total_freight_amount: str | None = None + remark: str | None = None + + +class BusinessDetailOut(BusinessOut): + """业务详情:附产品明细行。""" + + items: list[BusinessItemOut] = Field(default_factory=list) + + +class BusinessCreateIn(BaseModel): + """创建业务:主表信息 + 明细行数组(明细 ≥1 行)。 + + 字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应, + 仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + customer_id: int = Field(ge=1, description="客户ID") + business_date: date | None = Field(default=None, description="业务日期,ISO 格式") + owner_personnel_id: int | None = Field( + default=None, ge=1, description="负责营销人员ID,可空=待指定" + ) + assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID") + is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") + special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") + items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行") + remark: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def _check_special_note(self) -> "BusinessCreateIn": + """特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。""" + if self.is_special == 1 and not self.special_note: + raise ValueError("特殊业务必须填写说明") + return self + + +class BusinessUpdateIn(BaseModel): + """编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + customer_id: int = Field(ge=1, description="客户ID") + business_date: date | None = Field(default=None, description="业务日期,ISO 格式") + owner_personnel_id: int | None = Field( + default=None, + ge=1, + description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)", + ) + assistant_personnel_id: int | None = Field( + default=None, + ge=1, + description="协助营销人员ID(编辑端点忽略)", + ) + is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否") + special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明") + items: list[BusinessItemIn] = Field( + min_length=1, description="产品明细行,至少 1 行(全量替换)" + ) + remark: str | None = Field(default=None, max_length=255) + + @model_validator(mode="after") + def _check_special_note(self) -> "BusinessUpdateIn": + if self.is_special == 1 and not self.special_note: + raise ValueError("特殊业务必须填写说明") + return self + + +class BusinessStatusIn(BaseModel): + """状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止") + reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)") + + @model_validator(mode="after") + def _check_reason(self) -> "BusinessStatusIn": + if self.status == 3 and not self.reason: + raise ValueError("终止业务必须填写原因") + return self + + +class BusinessOwnershipIn(BaseModel): + """业务归属变更(负责/协助;前后值写入留痕)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + new_owner_personnel_id: int | None = Field(default=None, ge=1) + new_assistant_personnel_id: int | None = Field(default=None, ge=1) + reason: str | None = Field(default=None, max_length=255) + + +class BusinessQuery(PageQuery): + """业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。""" + + keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊") + status: int | None = Field(default=None, ge=1, le=3, description="状态筛选") + customer_id: int | None = Field(default=None, ge=1, description="客户筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选") + date_from: date | None = Field(default=None, description="业务日期起(含)") + date_to: date | None = Field(default=None, description="业务日期止(含)") + + +class GrossProfitRowOut(BaseModel): + """毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。""" + + item_id: int + product_category: str + product_name: str + spec_model: str | None = None + quantity: str + sale_amount: str + cost_amount: str | None = None + """成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason""" + profit_amount: str | None = None + profit_rate: str | None = None + threshold: str + converted_profit: str | None = None + missing_reason: str | None = None + + +class GrossProfitCategoryOut(BaseModel): + """按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。""" + + product_category: str + sale_amount: str + cost_amount: str + profit_amount: str + profit_rate: str + threshold: str + converted_profit: str + + +class GrossProfitOut(BaseModel): + """毛利润数据视图(需求 2.2.4):行级 + 类别汇总。 + + 口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费** + (对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算 + 实际按回款到账日取参,本视图默认今日仅供录入期核对。 + """ + + business_id: int + as_of: date + rows: list[GrossProfitRowOut] = Field(default_factory=list) + categories: list[GrossProfitCategoryOut] = Field(default_factory=list) + missing_count: int = 0 + note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本" + + +class OwnershipChangeLogOut(BaseModel): + """业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。""" + + id: int + business_id: int + old_owner_personnel_id: int | None = None + new_owner_personnel_id: int | None = None + old_assistant_personnel_id: int | None = None + new_assistant_personnel_id: int | None = None + old_owner_name: str | None = None + new_owner_name: str | None = None + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime + + +class BusinessStatusLogOut(BaseModel): + """业务状态流转记录(详情页时间线;创建首条 old=new=1)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + business_id: int + old_status: int + new_status: int + change_reason: str | None = None + operator_id: int | None = None + operator_name: str + create_time: datetime + + +# 供 schema 外部引用的两类别常量(类别汇总展示用) +CATEGORY_LABELS = { + PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利", + PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通", +} diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index 3c905d6..fde79e3 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -35,11 +35,11 @@ class CustomerCreateIn(BaseModel): owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法); 归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见 - ``CustomerUpdateIn`` 的说明)。camelCase 别名与蛇形字段名均可绑定, - 兼容前端两种拼写。 + ``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装 + 与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。 """ - model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True) + model_config = ConfigDict(str_strip_whitespace=True) company_name: str = Field(min_length=1, max_length=128, description="公司名称") owner_name: str = Field( @@ -59,7 +59,6 @@ class CustomerCreateIn(BaseModel): owner_personnel_id: int | None = Field( default=None, ge=1, - alias="ownerPersonnelId", description="归属负责营销人员ID(初始归属,可空)", ) remark: str | None = Field(default=None, max_length=255, description="备注") @@ -91,21 +90,13 @@ class CustomerUpdateIn(BaseModel): class CustomerQuery(PageQuery): - """客户列表查询条件。 - - customer_type / owner_personnel_id 同时接受蛇形(项目既有契约,见前端 - `types.ts` 的说明)与驼峰别名,两种拼写不并存即可。 - """ + """客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。""" keyword: str | None = Field( default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" ) - customer_type: str | None = Field( - default=None, max_length=32, alias="customerType", description="客户分类筛选" - ) - owner_personnel_id: int | None = Field( - default=None, ge=1, alias="ownerPersonnelId", description="归属人员ID筛选" - ) + customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") + owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") class CustomerOwnershipIn(BaseModel): @@ -114,12 +105,9 @@ class CustomerOwnershipIn(BaseModel): new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。 """ - model_config = ConfigDict(populate_by_name=True) - new_owner_personnel_id: int | None = Field( default=None, ge=1, - alias="newOwnerPersonnelId", description="变更后归属人员ID,null=清空归属", ) reason: str | None = Field(default=None, max_length=255, description="变更原因") diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py new file mode 100644 index 0000000..0c92e1d --- /dev/null +++ b/backend/app/services/business_service.py @@ -0,0 +1,747 @@ +"""业务管理业务编排(需求 2.2.1/2.2.4)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间, + 残余竞态以唯一键(1062)冲突重试一次兜底 +- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals`` + 单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写 +- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8); + 终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1) +- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次 + commit;只影响变更后核算,不回溯历史(对照表 #19) +- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit), + 阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点 +""" + +import logging +from datetime import date +from decimal import Decimal +from fractions import Fraction + +from sqlalchemy import Row, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_BUSINESS, + NO_PREFIX_BUSINESS, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageQuery, PageResult +from app.models import ( + Business, + BusinessItem, + BusinessStatusLog, + Customer, + OwnershipChangeLog, + Personnel, + Receipt, + Shipment, + User, +) +from app.models.business import ( + BUSINESS_STATUS_ACTIVE, + BUSINESS_STATUS_DONE, +) +from app.models.customer import OWNER_TYPE_BUSINESS +from app.repositories import business_repository, customer_repository, personnel_repository +from app.schemas.business import ( + BusinessCreateIn, + BusinessDetailOut, + BusinessItemIn, + BusinessItemOut, + BusinessOut, + BusinessOwnershipIn, + BusinessQuery, + BusinessStatusIn, + BusinessStatusLogOut, + BusinessUpdateIn, + GrossProfitCategoryOut, + GrossProfitOut, + GrossProfitRowOut, + OwnershipChangeLogOut, +) +from app.services import audit_service, param_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.formula import converted_profit, profit_rate +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + +# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态, +# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷 +_NO_RETRY = 1 + +# personnel.status 的在职值(与 customer_service 同口径,注释见彼处) +_PERSONNEL_STATUS_ACTIVE = 1 + + +def _to_out(row: tuple) -> BusinessOut: + """(business, company_name, owner_name, assistant_name) → 响应模型。 + + 金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效, + 存量行可能为 NULL),统一转 "0" 保持契约稳定。 + """ + business, company_name, owner_name, assistant_name = row + return BusinessOut( + id=business.id, + business_no=business.business_no, + customer_id=business.customer_id, + customer_name=company_name, + business_date=business.business_date, + status=business.status, + owner_personnel_id=business.owner_personnel_id, + owner_personnel_name=owner_name, + assistant_personnel_id=business.assistant_personnel_id, + assistant_personnel_name=assistant_name, + is_special=business.is_special, + special_note=business.special_note, + total_sale_amount=_str_or_zero(business.total_sale_amount), + total_cost_amount=_str_or_zero(business.total_cost_amount), + total_ship_amount=_str_or_zero(business.total_ship_amount), + total_received_amount=_str_or_zero(business.total_received_amount), + total_freight_amount=_str_or_zero(business.total_freight_amount), + remark=business.remark, + ) + + +def _str_or_zero(value: Decimal | None) -> str: + return str(value) if value is not None else "0" + + +def _item_to_out(item: BusinessItem) -> BusinessItemOut: + return BusinessItemOut( + id=item.id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model, + unit=item.unit, + product_category=item.product_category, + quantity=str(item.quantity), + sale_price=str(item.sale_price) if item.sale_price is not None else None, + sale_amount=str(item.sale_amount), + cost_amount=str(item.cost_amount) if item.cost_amount is not None else None, + guided_price=str(item.guided_price) if item.guided_price is not None else None, + remark=item.remark, + ) + + +def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]: + """入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。 + + 不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0, + 由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。 + """ + rows: list[BusinessItem] = [] + for item in items: + quantity = Decimal(item.quantity) + sale_price = Decimal(item.sale_price) if item.sale_price is not None else None + sale_amount = (quantity * sale_price) if sale_price is not None else Decimal("0") + rows.append( + BusinessItem( + business_id=business_id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model or "", + unit=item.unit, + product_category=item.product_category, + quantity=quantity, + sale_price=sale_price, + sale_amount=sale_amount, + cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None, + guided_price=Decimal(item.guided_price) if item.guided_price is not None else None, + remark=item.remark, + ) + ) + return rows + + +def _recalc_totals(session: Session, business: Business) -> None: + """重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。 + + 五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级 + 为单业务数行,不值得为省查询做单大 JOIN。 + """ + sale = session.execute( + _sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id) + ).scalar_one() + cost = session.execute( + _sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id) + ).scalar_one() + ship = session.execute(_sum_ship_items(business.id)).scalar_one() + freight = session.execute( + _sum(Shipment.freight_amount, Shipment.business_id, business.id) + ).scalar_one() + received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one() + business.total_sale_amount = sale or Decimal("0") + business.total_cost_amount = cost or Decimal("0") + business.total_ship_amount = ship or Decimal("0") + business.total_freight_amount = freight or Decimal("0") + business.total_received_amount = received or Decimal("0") + + +def _sum(column, match_column, value) -> object: + return select(func.sum(column)).where(match_column == value) + + +def _sum_ship_items(business_id: int): + """发货明细行金额合计(经发货单关联到业务)。""" + from app.models import ShipmentItem + + return ( + select(func.sum(ShipmentItem.amount)) + .select_from(ShipmentItem) + .join(Shipment, Shipment.id == ShipmentItem.shipment_id) + .where(Shipment.business_id == business_id) + ) + + +def _generate_no(session: Session) -> str: + """生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。""" + return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS) + + +def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel: + """归属人员须存在且在职(人员侧复用校验文案口径)。""" + person = personnel_repository.get_by_id(session, personnel_id) + if person is None or person.status != _PERSONNEL_STATUS_ACTIVE: + raise BadRequestError(f"{label}人员不存在或已离职") + return person + + +def _ensure_customer(session: Session, customer_id: int) -> Customer: + customer = customer_repository.get_by_id(session, customer_id) + if customer is None: + raise NotFoundError("客户不存在") + return customer + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == 1062 + + +def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: + """分页查询业务(附客户名与归属人名)。""" + rows = business_repository.list_businesses( + session, + offset=query.offset, + limit=query.page_size, + keyword=query.keyword, + status=query.status, + customer_id=query.customer_id, + owner_personnel_id=query.owner_personnel_id, + date_from=query.date_from, + date_to=query.date_to, + ) + total = business_repository.count_businesses( + session, + keyword=query.keyword, + status=query.status, + customer_id=query.customer_id, + owner_personnel_id=query.owner_personnel_id, + date_from=query.date_from, + date_to=query.date_to, + ) + return PageResult[BusinessOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, business_id: int) -> BusinessDetailOut: + """业务详情(附明细行,供编辑抽屉回显)。 + + :raises NotFoundError: 业务不存在 + """ + row = business_repository.get_with_names(session, business_id) + if row is None: + raise NotFoundError("业务不存在") + out = BusinessDetailOut(**_to_out(row).model_dump()) + out.items = [ + _item_to_out(item) for item in business_repository.list_items(session, business_id) + ] + return out + + +def create_business( + session: Session, + payload: BusinessCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """创建业务 + 明细行 + 首条状态留痕,一个事务。 + + 创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过 + """ + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_CREATE, + summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + _ensure_customer(session, payload.customer_id) + if payload.owner_personnel_id is not None: + _ensure_personnel_active(session, payload.owner_personnel_id, "负责") + if payload.assistant_personnel_id is not None: + _ensure_personnel_active(session, payload.assistant_personnel_id, "协助") + + try: + business = Business( + business_no=_generate_no(session), + customer_id=payload.customer_id, + business_date=payload.business_date, + status=BUSINESS_STATUS_ACTIVE, + owner_personnel_id=payload.owner_personnel_id, + assistant_personnel_id=payload.assistant_personnel_id, + is_special=payload.is_special, + special_note=payload.special_note, + remark=payload.remark, + ) + business_repository.create(session, business) + business_repository.replace_items( + session, business.id, _build_item_rows(business.id, payload.items) + ) + _recalc_totals(session, business) + business_repository.add_status_log( + session, + BusinessStatusLog( + business_id=business.id, + old_status=BUSINESS_STATUS_ACTIVE, + new_status=BUSINESS_STATUS_ACTIVE, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + customer = customer_repository.get_by_id(session, business.customer_id) + out = _to_out((business, customer.company_name if customer else None, None, None)) + session.commit() + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc): + # 编号生成的残余竞态:唯一键兜底重试一次(防无限重试掩盖死锁) + return _retry_create(session, payload, operator=operator, meta=meta) + raise + + logger.info("创建业务 | business_id=%s | no=%s", business.id, business.business_no) + return out + + +def _retry_create( + session: Session, + payload: BusinessCreateIn, + *, + operator: User, + meta: RequestMeta | None, +) -> BusinessOut: + """编号撞唯一键后的单次重试(防无限重试掩盖死锁类缺陷)。""" + return create_business(session, payload, operator=operator, meta=meta) + + +def update_business( + session: Session, + business_id: int, + payload: BusinessUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。 + + 客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让 + 发货/回款语义断裂(明细同理随业务走)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 归属人员不合法 / 有发货或回款时换客户 + """ + row = business_repository.get_with_names(session, business_id) + business = row[0] if row else None + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=f"修改业务 {business.business_no if business else f'#{business_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, _, _ = row + + if payload.customer_id != business.customer_id: + has_docs = session.query(Shipment.id).filter( + Shipment.business_id == business_id + ).first() is not None or ( + session.query(Receipt.id).filter(Receipt.business_id == business_id).first() + is not None + ) + if has_docs: + raise BadRequestError("业务已有发货或回款记录,不能更换客户") + _ensure_customer(session, payload.customer_id) + if payload.owner_personnel_id is not None: + _ensure_personnel_active(session, payload.owner_personnel_id, "负责") + if payload.assistant_personnel_id is not None: + _ensure_personnel_active(session, payload.assistant_personnel_id, "协助") + + business.customer_id = payload.customer_id + business.business_date = payload.business_date + business.is_special = payload.is_special + business.special_note = payload.special_note + business.remark = payload.remark + # 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成 + # 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径) + business_repository.replace_items( + session, business_id, _build_item_rows(business_id, payload.items) + ) + _recalc_totals(session, business) + out = _to_out(business_repository.get_with_names(session, business_id)) + session.commit() + logger.info("修改业务 | business_id=%s", business_id) + return out + + +def delete_business( + session: Session, + business_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。 + + 无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕 + 与业务维度的归属留痕(无外键固化表,须手动清)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 存在发货或回款记录 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_DELETE, + summary=f"删除业务 {business.business_no if business else f'#{business_id}'}", + ), + meta=meta, + ): + if business is None: + raise NotFoundError("业务不存在") + if ( + session.query(Shipment.id).filter(Shipment.business_id == business_id).first() + is not None + or session.query(Receipt.id).filter(Receipt.business_id == business_id).first() + is not None + ): + raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") + + business_no = business.business_no + _hard_delete(session, business_id) + session.commit() + logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no) + + +def _hard_delete(session: Session, business_id: int) -> None: + """按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。""" + from sqlalchemy import delete + + session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) + session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id)) + session.execute( + delete(OwnershipChangeLog).where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id == business_id, + ) + ) + session.execute(delete(Business).where(Business.id == business_id)) + + +def change_status( + session: Session, + business_id: int, + payload: BusinessStatusIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 非执行中状态流转 / 目标与当前相同 + """ + row = business_repository.get_with_names(session, business_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=( + f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → " + f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, _, _ = row + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("已完成或已终止的业务不可再变更状态") + if business.status == payload.status: + raise BadRequestError("状态未变化") + + business.status = payload.status + business_repository.add_status_log( + session, + BusinessStatusLog( + business_id=business_id, + old_status=business.status, + new_status=payload.status, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + out = _to_out(business_repository.get_with_names(session, business_id)) + session.commit() + logger.info( + "业务状态变更 | business_id=%s | %s -> %s", + business_id, + business.status, + payload.status, + ) + return out + + +def change_ownership( + session: Session, + business_id: int, + payload: BusinessOwnershipIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> BusinessOut: + """归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。 + + 负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化 + → 400「归属未变化」。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 + """ + row = business_repository.get_with_names(session, business_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_BUSINESS, + action=ACTION_UPDATE, + summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if row is None: + raise NotFoundError("业务不存在") + business, _, _, _ = row + if payload.new_owner_personnel_id is not None: + _ensure_personnel_active(session, payload.new_owner_personnel_id, "负责") + if payload.new_assistant_personnel_id is not None: + _ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助") + + owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id + assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id + if not owner_changed and not assistant_changed: + raise BadRequestError("归属未变化") + + old_owner_id = business.owner_personnel_id + old_assistant_id = business.assistant_personnel_id + business.owner_personnel_id = payload.new_owner_personnel_id + business.assistant_personnel_id = payload.new_assistant_personnel_id + business_repository.add_ownership_log( + session, + OwnershipChangeLog( + owner_type=OWNER_TYPE_BUSINESS, + business_id=business_id, + old_owner_personnel_id=old_owner_id, + new_owner_personnel_id=payload.new_owner_personnel_id, + old_assistant_personnel_id=old_assistant_id, + new_assistant_personnel_id=payload.new_assistant_personnel_id, + change_reason=payload.reason, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + out = _to_out(business_repository.get_with_names(session, business_id)) + session.commit() + logger.info( + "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", + business_id, + old_owner_id, + payload.new_owner_personnel_id, + old_assistant_id, + payload.new_assistant_personnel_id, + ) + return out + + +def list_ownership_logs( + session: Session, business_id: int, query: PageQuery +) -> PageResult[OwnershipChangeLogOut]: + """某业务的归属变更留痕(分页)。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + rows = business_repository.list_ownership_logs( + session, business_id, offset=query.offset, limit=query.page_size + ) + total = business_repository.count_ownership_logs(session, business_id=business_id) + return PageResult[OwnershipChangeLogOut]( + records=[_log_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def _log_to_out(row: Row) -> OwnershipChangeLogOut: + log, old_owner_name, new_owner_name = row + return OwnershipChangeLogOut( + id=log.id, + business_id=log.business_id, + old_owner_personnel_id=log.old_owner_personnel_id, + new_owner_personnel_id=log.new_owner_personnel_id, + old_assistant_personnel_id=log.old_assistant_personnel_id, + new_assistant_personnel_id=log.new_assistant_personnel_id, + old_owner_name=old_owner_name, + new_owner_name=new_owner_name, + change_reason=log.change_reason, + operator_id=log.operator_id, + operator_name=log.operator_name, + create_time=log.create_time, + ) + + +def list_status_logs( + session: Session, business_id: int, query: PageQuery +) -> PageResult[BusinessStatusLogOut]: + """某业务的状态流转时间线(分页,时间正序)。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + rows = business_repository.list_status_logs( + session, business_id, offset=query.offset, limit=query.page_size + ) + total = business_repository.count_status_logs(session, business_id=business_id) + return PageResult[BusinessStatusLogOut]( + records=[BusinessStatusLogOut.model_validate(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut: + """毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。 + + 阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度, + 存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一 + 出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为 + None 并标注原因;汇总只聚合可计算行。 + + :raises NotFoundError: 业务不存在 + """ + if business_repository.get_by_id(session, business_id) is None: + raise NotFoundError("业务不存在") + as_of = as_of or date.today() + snapshot = param_service.resolve_snapshot(session, as_of) + + def threshold_of(category: str) -> Fraction: + return snapshot.fraction("product.threshold", product_category=category) + + rows_out: list[GrossProfitRowOut] = [] + missing_count = 0 + # 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合 + agg: dict[str, list[Decimal]] = {} + for item in business_repository.list_items(session, business_id): + threshold = threshold_of(item.product_category) + row_kwargs: dict = { + "item_id": item.id, + "product_category": item.product_category, + "product_name": item.product_name, + "spec_model": item.spec_model, + "quantity": str(item.quantity), + "sale_amount": str(item.sale_amount), + "threshold": str(threshold), + } + if item.cost_amount is None: + missing_count += 1 + row_kwargs.update( + cost_amount=None, + profit_amount=None, + profit_rate=None, + converted_profit=None, + missing_reason="成本缺失,无法计算毛利润", + ) + else: + profit = item.sale_amount - item.cost_amount + rate = profit_rate(profit, item.sale_amount) + conv = converted_profit(item.sale_amount, rate, threshold) + row_kwargs.update( + cost_amount=str(item.cost_amount), + profit_amount=str(profit), + profit_rate=str(rate), + converted_profit=str(conv), + missing_reason=None, + ) + bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")]) + bucket[0] += item.sale_amount + bucket[1] += item.cost_amount + rows_out.append(GrossProfitRowOut(**row_kwargs)) + + categories = [ + GrossProfitCategoryOut( + product_category=category, + sale_amount=str(sale), + cost_amount=str(cost), + profit_amount=str(sale - cost), + profit_rate=str(profit_rate(sale - cost, sale)), + threshold=str(threshold_of(category)), + converted_profit=str( + converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category)) + ), + ) + for category, [sale, cost] in agg.items() + ] + return GrossProfitOut( + business_id=business_id, + as_of=as_of, + rows=rows_out, + categories=categories, + missing_count=missing_count, + ) diff --git a/backend/app/services/no_generator.py b/backend/app/services/no_generator.py new file mode 100644 index 0000000..447f227 --- /dev/null +++ b/backend/app/services/no_generator.py @@ -0,0 +1,46 @@ +"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。 + +规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。 +并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的 +事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时 +等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。 +""" + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + + +def today_prefix(prefix: str) -> str: + """当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。""" + return f"{prefix}{datetime.now().strftime('%Y%m%d')}-" + + +def next_no( + session: Session, + *, + model: type, + column_name: str, + prefix: str, + seq_width: int = 4, +) -> str: + """生成下一个编号。 + + :param model: 编号所在的 ORM 模型(businesses/shipments/receipts) + :param column_name: 编号列名(business_no/shipment_no/receipt_batch_no) + :param prefix: 单据前缀(NO_PREFIX_* 常量) + :param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出, + VARCHAR(32) 余量充足、唯一性不受影响 + """ + like_prefix = today_prefix(prefix) + column = getattr(model, column_name) + max_no = session.execute( + select(column) + .where(column.like(f"{like_prefix}%")) + .order_by(column.desc()) + .limit(1) + .with_for_update() + ).scalar() + seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1 + return f"{like_prefix}{seq:0{seq_width}d}" -- 2.27.0 From 4aea2be1031a5582b41d2f8d95b12f14bdef6f6b Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:25:33 +0800 Subject: [PATCH 029/100] =?UTF-8?q?feat(business):=20=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E5=8D=95/=E6=98=8E=E7=BB=86/=E8=BF=90=E8=B4=B9/=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E5=9B=9E=E5=86=99/=E5=88=A0=E9=99=A4=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FOR UPDATE 锁业务行防发货/回款并发丢失更新;终态业务禁新发货 - 行金额服务层按数量×单价固化;明细行 remark 按设计定稿移除(冒烟修复) Co-Authored-By: Claude Code --- backend/app/api/v1/business/shipments.py | 91 +++++ .../app/repositories/shipment_repository.py | 145 +++++++ backend/app/schemas/shipment.py | 125 ++++++ backend/app/services/shipment_service.py | 362 ++++++++++++++++++ 4 files changed, 723 insertions(+) create mode 100644 backend/app/api/v1/business/shipments.py create mode 100644 backend/app/repositories/shipment_repository.py create mode 100644 backend/app/schemas/shipment.py create mode 100644 backend/app/services/shipment_service.py diff --git a/backend/app/api/v1/business/shipments.py b/backend/app/api/v1/business/shipments.py new file mode 100644 index 0000000..ff62dee --- /dev/null +++ b/backend/app/api/v1/business/shipments.py @@ -0,0 +1,91 @@ +"""发货管理接口(需求 2.2.2)。 + +校验规则(状态门槛、行金额固化、双重汇总回写、删除保护)见 +`shipment_service` 模块 docstring;业务数据属业务操作,登录即可访问。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.shipment import ( + ShipmentCreateIn, + ShipmentDetailOut, + ShipmentOut, + ShipmentQuery, + ShipmentUpdateIn, +) +from app.services import shipment_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/shipments", tags=["发货管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ShipmentOut]], summary="发货单列表") +def list_shipments( + session: DbSession, + _: CurrentUser, + query: Annotated[ShipmentQuery, Query()], +) -> ApiResponse[PageResult[ShipmentOut]]: + """分页查询发货单(附业务编号),可按业务/发货日期范围筛选。""" + return ok(shipment_service.list_shipments(session, query)) + + +@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单") +def create_shipment( + session: DbSession, + current_user: CurrentUser, + payload: ShipmentCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """创建发货单(明细 ≥1)。业务须处于执行中;锁业务行后生成 SH 编号。""" + return ok( + shipment_service.create_shipment(session, payload, operator=current_user, meta=meta), + message="发货单已创建", + ) + + +@router.get( + "/{shipment_id}", response_model=ApiResponse[ShipmentDetailOut], summary="发货单详情" +) +def get_shipment( + session: DbSession, + _: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ShipmentDetailOut]: + """发货单详情,附产品明细行,供编辑抽屉一次回显。""" + return ok(shipment_service.get_detail(session, shipment_id)) + + +@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单") +def update_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + payload: ShipmentUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ShipmentOut]: + """编辑发货单(明细全量替换并重算汇总)。终态业务的发货单禁改。""" + return ok( + shipment_service.update_shipment( + session, shipment_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单") +def delete_shipment( + session: DbSession, + current_user: CurrentUser, + shipment_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除发货单(连带明细行并回写业务汇总)。被回款批次指定时禁删。""" + shipment_service.delete_shipment(session, shipment_id, operator=current_user, meta=meta) + return ok(message="发货单已删除") diff --git a/backend/app/repositories/shipment_repository.py b/backend/app/repositories/shipment_repository.py new file mode 100644 index 0000000..971f357 --- /dev/null +++ b/backend/app/repositories/shipment_repository.py @@ -0,0 +1,145 @@ +"""发货数据访问:发货单行、明细行与业务号关联引用。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 + +- 列表与详情统一 join 出 ``business_no``(业务主表 inner join,FK NOT + NULL,业务与发货单同存亡) +- 明细行汇总(sum_item_amount)是发货单 total_amount 重算的唯一数据源, + 业务级汇总仍收口 business_service._recalc_totals +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Select, delete, func, select +from sqlalchemy.orm import Session + +from app.models import Business, Receipt, Shipment, ShipmentItem + + +def get_by_id(session: Session, shipment_id: int) -> Shipment | None: + """按主键取发货单行(供摘要预取与编辑赋值;不锁行,写保护锚在业务行)。""" + return session.get(Shipment, shipment_id) + + +def get_with_business_no(session: Session, shipment_id: int) -> tuple[Shipment, str] | None: + """发货单行 + 业务号(DTO 与审计摘要共用)。""" + return session.execute( + select(Shipment, Business.business_no) + .join(Business, Business.id == Shipment.business_id) + .where(Shipment.id == shipment_id) + ).first() + + +def _apply_filters( + stmt: Select, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> Select: + """发货列表的筛选条件(business_id 空=全量,一期按业务维度查看为主)。""" + if business_id is not None: + stmt = stmt.where(Shipment.business_id == business_id) + if ship_date_from is not None: + stmt = stmt.where(Shipment.ship_date >= ship_date_from) + if ship_date_to is not None: + stmt = stmt.where(Shipment.ship_date <= ship_date_to) + return stmt + + +def list_shipments( + session: Session, + *, + offset: int, + limit: int, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> list[tuple[Shipment, str]]: + """分页查询发货单(附业务号),按 id 倒序。""" + stmt = _apply_filters( + select(Shipment, Business.business_no).join( + Business, Business.id == Shipment.business_id + ), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return list( + session.execute(stmt.order_by(Shipment.id.desc()).offset(offset).limit(limit)).all() + ) + + +def count_shipments( + session: Session, + *, + business_id: int | None, + ship_date_from: date | None, + ship_date_to: date | None, +) -> int: + """发货单总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" + stmt = _apply_filters( + select(func.count()) + .select_from(Shipment) + .join(Business, Business.id == Shipment.business_id), + business_id=business_id, + ship_date_from=ship_date_from, + ship_date_to=ship_date_to, + ) + return int(session.execute(stmt).scalar_one()) + + +def list_items(session: Session, shipment_id: int) -> list[ShipmentItem]: + """发货明细行(详情回显与总金额重算共用)。""" + return list( + session.execute( + select(ShipmentItem) + .where(ShipmentItem.shipment_id == shipment_id) + .order_by(ShipmentItem.id) + ).scalars() + ) + + +def sum_item_amount(session: Session, shipment_id: int) -> Decimal | None: + """明细行金额合计(发货单 total_amount 重算的唯一数据源)。""" + return session.execute( + select(func.sum(ShipmentItem.amount)).where(ShipmentItem.shipment_id == shipment_id) + ).scalar_one() + + +def count_receipt_refs(session: Session, shipment_id: int) -> int: + """被回款批次指定的引用数(删除保护依据,receipts.shipment_id)。""" + return int( + session.execute( + select(func.count()).select_from(Receipt).where(Receipt.shipment_id == shipment_id) + ).scalar_one() + ) + + +def create(session: Session, shipment: Shipment) -> Shipment: + """新增发货单行。只 flush 以取得自增 id,单号唯一键冲突由 service 兜底。""" + session.add(shipment) + session.flush() + return shipment + + +def replace_items(session: Session, shipment_id: int, rows: list[ShipmentItem]) -> None: + """全量替换明细:先删、立即 flush、再插入(business_repository 同款防御写法)。 + + 同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库 + 会在有唯一键的表上撞键;shipment_items 虽无唯一键,统一按此写法防御。 + """ + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.flush() + for row in rows: + row.shipment_id = shipment_id + session.add(row) + session.flush() + + +def delete_with_items(session: Session, shipment_id: int) -> None: + """删除发货单及明细行(按依赖序:明细 → 主表,FK RESTRICT)。""" + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id == shipment_id)) + session.execute(delete(Shipment).where(Shipment.id == shipment_id)) + session.flush() diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py new file mode 100644 index 0000000..61253f1 --- /dev/null +++ b/backend/app/schemas/shipment.py @@ -0,0 +1,125 @@ +"""发货管理的请求/响应模型(需求 2.2.2)。 + +金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与 +小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移); +字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。 + +amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按 +数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery +from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount + + +class ShipmentItemIn(BaseModel): + """发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。""" + + model_config = ConfigDict(str_strip_whitespace=True) + + product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入") + product_name: str = Field(min_length=1, max_length=128, description="产品名称") + spec_model: str | None = Field( + default=None, max_length=128, description="规格型号,空=空串归一" + ) + unit: str | None = Field(default=None, max_length=16, description="计量单位") + quantity: str = Field(description="数量,>0") + sale_price: str | None = Field( + default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0" + ) + + @field_validator("quantity") + @classmethod + def _check_quantity(cls, value: str) -> str: + return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value + + @field_validator("sale_price") + @classmethod + def _check_price(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", PRICE_MAX) + + +class ShipmentItemOut(BaseModel): + """发货产品明细行响应。金额/数量一律 str。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int | None = None + product_name: str + spec_model: str | None = None + unit: str | None = None + quantity: str + sale_price: str | None = None + amount: str + + +class ShipmentOut(BaseModel): + """发货单响应(列表与写操作回包共用);business_no 为 join 附带列。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + shipment_no: str + business_id: int + business_no: str | None = None + ship_date: date + freight_amount: str + total_amount: str + remark: str | None = None + + +class ShipmentDetailOut(ShipmentOut): + """发货单详情:附产品明细行。""" + + items: list[ShipmentItemOut] = Field(default_factory=list) + + +class ShipmentCreateIn(BaseModel): + """创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。 + + 字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + business_id: int = Field(ge=1, description="业务ID") + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field( + default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径" + ) + items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行") + remark: str | None = Field(default=None, max_length=255) + + +class ShipmentUpdateIn(BaseModel): + """编辑发货单(明细全量替换)。 + + business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 + 已指定本发货单的回款语义断裂。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") + freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0") + items: list[ShipmentItemIn] = Field( + min_length=1, description="发货明细行,至少 1 行(全量替换)" + ) + remark: str | None = Field(default=None, max_length=255) + + +class ShipmentQuery(PageQuery): + """发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 + + business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可 + 独立检索,不强制挂业务条件。 + """ + + business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部") + ship_date_from: date | None = Field(default=None, description="发货日期起(含)") + ship_date_to: date | None = Field(default=None, description="发货日期止(含)") diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py new file mode 100644 index 0000000..7ed085c --- /dev/null +++ b/backend/app/services/shipment_service.py @@ -0,0 +1,362 @@ +"""发货管理业务编排(需求 2.2.2)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- 行金额(amount)与发货单总金额(total_amount)服务层按 数量×单价 计算 + 固化,不信任前端传值;单价可空时行金额记 0(对齐业务明细口径) +- 业务级汇总(发货/运费合计)回写统一收口 ``business_service._recalc_totals``, + 本模块不另写聚合逻辑;发货单 total_amount 由 repository 行 SUM 重算 +- **写操作前 FOR UPDATE 锁业务行**(receipt_service 同款写准入):发货与 + 回款并发写同一业务时相互串行化,防 ``_recalc_totals`` 重算互相覆盖 + (丢失更新);锁序统一「业务行 → 单据编号区间」,与业务/回款同构 +- 业务状态门槛:仅执行中(1)业务可写发货单 —— 终态业务的发货单禁改禁删 + (一期保守口径:编辑不影响已核算回款、核算未发生,但会改业务级汇总 + 基线,设计未定项待业务确认后可放开终态编辑) +- 删除保护:被回款批次指定(receipts.shipment_id)的发货单禁删,先解除 + 回款的批次指定;删除连带明细行并重算业务级汇总 +- 编号 SH 生成复用 no_generator.next_no;残余竞态以唯一键(1062)冲突 + 重试一次兜底,模式同 business_service.create_business +- 审计:锁行与编号生成放在进入审计前(不产生未提交变更),状态校验在 + 审计上下文内,失败也留痕 —— 失败回滚释放已取序号,当日序号空洞无害 + (唯一性不依赖连续);ship_date 是回款天数基准日,编辑即时生效(M3 按 + 编辑后基准日实时派生,无固化快照可漂移) +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_SHIPMENT, + NO_PREFIX_SHIPMENT, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Business, Shipment, ShipmentItem, User +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.repositories import business_repository, shipment_repository +from app.schemas.shipment import ( + ShipmentCreateIn, + ShipmentDetailOut, + ShipmentItemIn, + ShipmentItemOut, + ShipmentOut, + ShipmentQuery, + ShipmentUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.business_service import _recalc_totals +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + + +def _to_out(row: tuple[Shipment, str]) -> ShipmentOut: + """(shipment, business_no) → 响应模型;金额出参 str。""" + shipment, business_no = row + return ShipmentOut( + id=shipment.id, + shipment_no=shipment.shipment_no, + business_id=shipment.business_id, + business_no=business_no, + ship_date=shipment.ship_date, + freight_amount=str(shipment.freight_amount), + total_amount=str(shipment.total_amount), + remark=shipment.remark, + ) + + +def _item_to_out(item: ShipmentItem) -> ShipmentItemOut: + return ShipmentItemOut( + id=item.id, + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model, + unit=item.unit, + quantity=str(item.quantity), + sale_price=str(item.sale_price) if item.sale_price is not None else None, + amount=str(item.amount), + ) + + +def _amount_or_zero(value: str | None) -> Decimal: + """可空金额入参转 Decimal,空记 0(运费可缺省的统一归一)。""" + return Decimal(value) if value is not None else Decimal("0") + + +def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]: + """入参明细行转 ORM 行:amount 服务层按 数量×单价 计算固化。 + + 不信任前端传值(schema 亦无 amount 入参)——单价可空时行金额记 0, + 由用户在编辑中补价后重算;shipment_id 由 replace_items 统一回填。 + """ + rows: list[ShipmentItem] = [] + for item in items: + quantity = Decimal(item.quantity) + sale_price = Decimal(item.sale_price) if item.sale_price is not None else None + amount = (quantity * sale_price) if sale_price is not None else Decimal("0") + rows.append( + ShipmentItem( + product_id=item.product_id, + product_name=item.product_name, + spec_model=item.spec_model or "", + unit=item.unit, + quantity=quantity, + sale_price=sale_price, + amount=amount, + ) + ) + return rows + + +def _generate_no(session: Session) -> str: + """生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" + return next_no( + session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT + ) + + +def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business: + """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 + + 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 + 同事务提交,并发写不会丢失更新。与 receipt_service._ensure_business_writable + 同构 —— 文案单据名落到发货单(私有函数各自保留,不为一个字符串上提 + 公共层)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError(f"业务已完成或已终止,禁止{action}") + return business + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。""" + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == 1062 + + +def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: + """分页查询发货单(附业务号;business_id 可空=全量,一期按业务维度查看为主)。""" + rows = shipment_repository.list_shipments( + session, + offset=query.offset, + limit=query.page_size, + business_id=query.business_id, + ship_date_from=query.ship_date_from, + ship_date_to=query.ship_date_to, + ) + total = shipment_repository.count_shipments( + session, + business_id=query.business_id, + ship_date_from=query.ship_date_from, + ship_date_to=query.ship_date_to, + ) + return PageResult[ShipmentOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, shipment_id: int) -> ShipmentDetailOut: + """发货单详情(附明细行,供编辑抽屉回显)。 + + :raises NotFoundError: 发货单不存在 + """ + row = shipment_repository.get_with_business_no(session, shipment_id) + if row is None: + raise NotFoundError("发货单不存在") + out = ShipmentDetailOut(**_to_out(row).model_dump()) + out.items = [ + _item_to_out(item) for item in shipment_repository.list_items(session, shipment_id) + ] + return out + + +def create_shipment( + session: Session, + payload: ShipmentCreateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ShipmentOut: + """新增发货单:锁业务行 → 校验 → 建单 + 明细 + 双重汇总回写,一个事务。 + + 摘要两号(发货单号/业务号)在进入审计前就绪:业务号取自锁行结果、 + 发货单号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更);状态 + 校验在审计上下文内,失败也留痕 —— 失败路径回滚释放已取序号,当日 + 序号出现空洞无害(唯一性不依赖连续)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止,禁止新发货 + """ + business = business_repository.get_by_id(session, payload.business_id, for_update=True) + shipment_no = _generate_no(session) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_CREATE, + summary=( + f"新增发货单 {shipment_no}" + f"(业务 {business.business_no if business else f'#{payload.business_id}'}," + f"明细 {len(payload.items)} 行)" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新发货") + + try: + shipment = Shipment( + shipment_no=shipment_no, + business_id=payload.business_id, + ship_date=payload.ship_date, + freight_amount=_amount_or_zero(payload.freight_amount), + total_amount=Decimal("0"), + remark=payload.remark, + ) + shipment_repository.create(session, shipment) + shipment_repository.replace_items( + session, shipment.id, _build_item_rows(payload.items) + ) + total = shipment_repository.sum_item_amount(session, shipment.id) + shipment.total_amount = total if total is not None else Decimal("0") + _recalc_totals(session, business) + out = _to_out((shipment, business.business_no)) + new_id = shipment.id + session.commit() + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc): + # 单号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁) + return _retry_create(session, payload, operator=operator, meta=meta) + raise + + logger.info( + "新增发货单 | shipment_id=%s | no=%s | business_id=%s", + new_id, + shipment_no, + payload.business_id, + ) + return out + + +def _retry_create( + session: Session, + payload: ShipmentCreateIn, + *, + operator: User, + meta: RequestMeta | None, +) -> ShipmentOut: + """单号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。""" + return create_shipment(session, payload, operator=operator, meta=meta) + + +def update_shipment( + session: Session, + shipment_id: int, + payload: ShipmentUpdateIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> ShipmentOut: + """编辑发货单(明细全量替换)+ 双重汇总回写,一个事务。 + + business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 + 已指定本发货单的回款语义断裂。终态业务的发货单禁改(与回款写准入同 + 口径;一期保守处理,设计未定项待业务确认后可放开——发货编辑不影响 + 已核算回款,核算未发生,无固化快照可漂移)。 + + :raises NotFoundError: 发货单不存在 / 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + shipment = shipment_repository.get_by_id(session, shipment_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_UPDATE, + summary=f"修改发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if shipment is None: + raise NotFoundError("发货单不存在") + business = _ensure_business_writable(session, shipment.business_id, "编辑发货单") + + shipment.ship_date = payload.ship_date + shipment.freight_amount = _amount_or_zero(payload.freight_amount) + shipment.remark = payload.remark + shipment_repository.replace_items(session, shipment_id, _build_item_rows(payload.items)) + total = shipment_repository.sum_item_amount(session, shipment_id) + shipment.total_amount = total if total is not None else Decimal("0") + _recalc_totals(session, business) + out = _to_out(shipment_repository.get_with_business_no(session, shipment_id)) + shipment_no = shipment.shipment_no + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 + logger.info("修改发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no) + return out + + +def delete_shipment( + session: Session, + shipment_id: int, + *, + operator: User, + meta: RequestMeta | None = None, +) -> None: + """删除发货单(物理删除,连带明细行)+ 业务级汇总回写,一个事务。 + + 被回款批次指定(receipts.shipment_id 引用)→ 400 引导先解除回款的 + 批次指定;业务须状态=1 执行中(终态业务的发货单禁删,与回款写准入 + 同口径)—— 发货单的运费/金额是业务级汇总与 M3 核算的源数据,终态后 + 不得变动。 + + :raises NotFoundError: 发货单不存在 / 业务不存在 + :raises BadRequestError: 业务已完成或已终止 / 已被回款批次指定 + """ + shipment = shipment_repository.get_by_id(session, shipment_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_SHIPMENT, + action=ACTION_DELETE, + summary=f"删除发货单 {shipment.shipment_no if shipment else f'#{shipment_id}'}", + ), + meta=meta, + ): + if shipment is None: + raise NotFoundError("发货单不存在") + business = _ensure_business_writable(session, shipment.business_id, "删除发货单") + if shipment_repository.count_receipt_refs(session, shipment_id): + raise BadRequestError("已被回款批次指定,请先解除回款的批次指定") + + shipment_no = shipment.shipment_no + shipment_repository.delete_with_items(session, shipment_id) + _recalc_totals(session, business) + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):单号已固化 + logger.info("删除发货单 | shipment_id=%s | no=%s", shipment_id, shipment_no) -- 2.27.0 From a02b6c3ec894ea04567071dbb73eeeac18d6e11c Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:25:34 +0800 Subject: [PATCH 030/100] =?UTF-8?q?feat(business):=20=E5=9B=9E=E6=AC=BE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=88=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E5=8F=B7/=E6=89=8B=E5=8A=A8=E6=8C=87=E5=AE=9A=E6=89=B9?= =?UTF-8?q?=E6=AC=A1/=E5=B7=B2=E6=A0=B8=E7=AE=97=E7=A6=81=E6=94=B9/?= =?UTF-8?q?=E5=90=88=E8=AE=A1=E5=9B=9E=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批次 HK 生成;shipment_id 同业务强校验(统一失败面防探测) - 已核算回款禁改禁删(M3 防重锚点);payment_days 派生(发货日首日=第 1 天) Co-Authored-By: Claude Code --- backend/app/api/v1/business/receipts.py | 89 +++++ .../app/repositories/receipt_repository.py | 127 +++++++ backend/app/schemas/receipt.py | 104 ++++++ backend/app/services/receipt_service.py | 349 ++++++++++++++++++ 4 files changed, 669 insertions(+) create mode 100644 backend/app/api/v1/business/receipts.py create mode 100644 backend/app/repositories/receipt_repository.py create mode 100644 backend/app/schemas/receipt.py create mode 100644 backend/app/services/receipt_service.py diff --git a/backend/app/api/v1/business/receipts.py b/backend/app/api/v1/business/receipts.py new file mode 100644 index 0000000..7b5dc71 --- /dev/null +++ b/backend/app/api/v1/business/receipts.py @@ -0,0 +1,89 @@ +"""回款管理接口(需求 2.2.3)。 + +回款按业务登记到账款项,批次号自动生成(HK 前缀)。校验规则(发货 +批次同业务强校验、已核算禁改禁删、汇总重算)见 `receipt_service` 模块 +docstring;基础数据属业务操作,登录即可访问。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query + +from app.api.deps import DbSession, RequestMetaDep, get_current_user +from app.core.response import ApiResponse, PageResult, ok +from app.models import User +from app.schemas.receipt import ( + ReceiptCreateIn, + ReceiptOut, + ReceiptQuery, + ReceiptUpdateIn, +) +from app.services import receipt_service + +# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) +CurrentUser = Annotated[User, Depends(get_current_user)] + +router = APIRouter(prefix="/receipts", tags=["回款管理"]) + + +@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表") +def list_receipts( + session: DbSession, + _: CurrentUser, + query: Annotated[ReceiptQuery, Query()], +) -> ApiResponse[PageResult[ReceiptOut]]: + """分页查询回款。附业务号/发货单号与回款天数派生值,可按业务/日期/抵顶/核算状态筛选。""" + return ok(receipt_service.list_receipts(session, query)) + + +@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款") +def create_receipt( + session: DbSession, + current_user: CurrentUser, + payload: ReceiptCreateIn, + meta: RequestMetaDep, +) -> ApiResponse[ReceiptOut]: + """新增回款。批次号自动生成;发货批次须与本回款同属一个业务;业务须执行中。""" + return ok( + receipt_service.create_receipt(session, payload, operator=current_user, meta=meta), + message="回款已登记", + ) + + +@router.get("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="回款详情") +def get_receipt( + session: DbSession, + _: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[ReceiptOut]: + """回款详情(含回款天数派生值,未指定发货批次时为空)。""" + return ok(receipt_service.get_detail(session, receipt_id)) + + +@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款") +def update_receipt( + session: DbSession, + current_user: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], + payload: ReceiptUpdateIn, + meta: RequestMetaDep, +) -> ApiResponse[ReceiptOut]: + """编辑回款(全量替换并重算合计)。已核算回款禁止修改;发货批次重新校验同业务。""" + return ok( + receipt_service.update_receipt( + session, receipt_id, payload, operator=current_user, meta=meta + ), + message="已保存", + ) + + +@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款") +def delete_receipt( + session: DbSession, + current_user: CurrentUser, + receipt_id: Annotated[int, Path(ge=1)], + meta: RequestMetaDep, +) -> ApiResponse[None]: + """删除回款。已核算回款禁止删除;业务已完成或已终止时禁删。""" + receipt_service.delete_receipt(session, receipt_id, operator=current_user, meta=meta) + return ok(message="回款已删除") diff --git a/backend/app/repositories/receipt_repository.py b/backend/app/repositories/receipt_repository.py new file mode 100644 index 0000000..03a141b --- /dev/null +++ b/backend/app/repositories/receipt_repository.py @@ -0,0 +1,127 @@ +"""回款数据访问:回款行与关联引用(业务号/发货单号/发货日)。 + +只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。 + +- 列表与详情统一 join 出 ``business_no / shipment_no / ship_date``, + ship_date 供 service 按 ``formula.payment_days`` 派生回款天数 —— 关联 + 引用经本层查询一次供给,service 不再对发货单二次查询 +- shipment_id 是手动指定(可空),发货单关联一律 outerjoin +""" + +from datetime import date + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.models import Business, Receipt, Shipment + + +def get_by_id(session: Session, receipt_id: int) -> Receipt | None: + """按主键取回款行(供摘要预取与状态判定;不锁行,写保护锚在业务行)。""" + return session.get(Receipt, receipt_id) + + +def get_with_refs( + session: Session, receipt_id: int +) -> tuple[Receipt, str, str | None, date | None] | None: + """回款行 + 业务号 + 发货单号 + 发货日(DTO 与 payment_days 派生共用)。 + + business 主表为 inner join(FK NOT NULL,业务与回款同存亡);发货单 + 可未指定,outerjoin 后 shipment_no / ship_date 可能为 None。 + """ + row = session.execute( + select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) + .join(Business, Business.id == Receipt.business_id) + .outerjoin(Shipment, Shipment.id == Receipt.shipment_id) + .where(Receipt.id == receipt_id) + ).first() + return row + + +def get_shipment(session: Session, shipment_id: int) -> Shipment | None: + """按主键取发货单(同业务强校验的只读预查;FK RESTRICT 为最终防线)。""" + return session.get(Shipment, shipment_id) + + +def _apply_filters( + stmt: Select, + *, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> Select: + """回款列表的筛选条件(全部落在 Receipt 自身列上)。""" + if business_id is not None: + stmt = stmt.where(Receipt.business_id == business_id) + if date_from is not None: + stmt = stmt.where(Receipt.receipt_date >= date_from) + if date_to is not None: + stmt = stmt.where(Receipt.receipt_date <= date_to) + if is_barter is not None: + stmt = stmt.where(Receipt.is_barter == is_barter) + if settlement_status is not None: + stmt = stmt.where(Receipt.settlement_status == settlement_status) + return stmt + + +def list_receipts( + session: Session, + *, + offset: int, + limit: int, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> list[tuple[Receipt, str, str | None, date | None]]: + """分页查询回款(附业务号/发货单号/发货日),按 id 倒序。""" + stmt = _apply_filters( + select(Receipt, Business.business_no, Shipment.shipment_no, Shipment.ship_date) + .join(Business, Business.id == Receipt.business_id) + .outerjoin(Shipment, Shipment.id == Receipt.shipment_id), + business_id=business_id, + date_from=date_from, + date_to=date_to, + is_barter=is_barter, + settlement_status=settlement_status, + ) + return list( + session.execute(stmt.order_by(Receipt.id.desc()).offset(offset).limit(limit)).all() + ) + + +def count_receipts( + session: Session, + *, + business_id: int | None, + date_from: date | None, + date_to: date | None, + is_barter: int | None, + settlement_status: int | None, +) -> int: + """回款总数(筛选条件与列表共用同一 apply_filters,防两侧口径漂移)。""" + stmt = _apply_filters( + select(func.count()).select_from(Receipt), + business_id=business_id, + date_from=date_from, + date_to=date_to, + is_barter=is_barter, + settlement_status=settlement_status, + ) + return int(session.execute(stmt).scalar_one()) + + +def create(session: Session, receipt: Receipt) -> Receipt: + """新增回款行。只 flush 以取得自增 id,编号唯一键冲突由 service 兜底。""" + session.add(receipt) + session.flush() + return receipt + + +def delete(session: Session, receipt: Receipt) -> None: + """物理删除回款行(已核算与否的准入判定在 service 层)。""" + session.delete(receipt) + session.flush() diff --git a/backend/app/schemas/receipt.py b/backend/app/schemas/receipt.py new file mode 100644 index 0000000..a1404cf --- /dev/null +++ b/backend/app/schemas/receipt.py @@ -0,0 +1,104 @@ +"""回款管理的请求/响应模型(需求 2.2.3)。 + +金额入参出参一律 str(服务层 Decimal,全程禁 float);字段契约**纯蛇形 +直出**(2026-10-13 定稿,无驼峰 alias)。 + +payment_days 是派生值(发货日 → 回款日的天数,对照表 #1),不在写接口 +入参内 —— 落库列只有 shipment_id 源数据,派生值实时计算,发货批次变更 +后无需回写历史。 +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.response import PageQuery + +# 金额校验是工资系统的红线口径(数字/非负/容量/两位小数拒绝),跨 schema +# 单点复用 T7 定稿工具,禁止各处复制导致口径漂移;AMOUNT_MAX 即 +# DECIMAL(14,2) 的容量上限,与回款列容量同源 +from app.schemas.business import AMOUNT_MAX, _check_amount + + +class ReceiptCreateIn(BaseModel): + """新增回款:业务必选,发货批次可暂不指定(手动指定,对照表 #18)。 + + shipment_id 指向的发货单必须与本回款同属一个业务(服务层强校验, + 数据完整性红线);shipment_id 可空 = 暂未指定,指定后回款天数方可派生。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + business_id: int = Field(ge=1, description="业务ID") + shipment_id: int | None = Field( + default=None, ge=1, description="发货单ID,可空=暂未指定" + ) + receipt_date: date = Field(description="回款到账日(M3 按此日匹配参数版本)") + amount: str = Field(description="回款金额(元),≥0,最多两位小数") + is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否") + remark: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("amount") + @classmethod + def _check_amount_value(cls, value: str) -> str: + return _check_amount(value, "回款金额", AMOUNT_MAX) or value + + +class ReceiptUpdateIn(BaseModel): + """编辑回款(全量替换语义)。 + + shipment_id 传 None = 清除指定(回到「暂未指定」);amount 是必填项, + 全量替换下不存在「不传即不变」的歧义。已核算(settlement_status=1) + 的回款在服务层禁改,schema 层不感知该状态。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + shipment_id: int | None = Field( + default=None, ge=1, description="发货单ID,可空=暂未指定(传空清除既有指定)" + ) + receipt_date: date = Field(description="回款到账日") + amount: str = Field(description="回款金额(元),≥0,最多两位小数") + is_barter: int = Field(default=0, ge=0, le=1, description="以物抵顶标记:1是 0否") + remark: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator("amount") + @classmethod + def _check_amount_value(cls, value: str) -> str: + return _check_amount(value, "回款金额", AMOUNT_MAX) or value + + +class ReceiptOut(BaseModel): + """回款响应(列表与写操作回包共用)。 + + business_no/shipment_no 为关联冗余展示;payment_days 为派生值: + 未指定发货批次时为 None,指定后 = 发货日到回款日的天数(发货日首日 + 计第 1 天)。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + receipt_batch_no: str + business_id: int + business_no: str + shipment_id: int | None = None + shipment_no: str | None = None + receipt_date: date + amount: str + is_barter: int + settlement_status: int + payment_days: int | None = None + remark: str | None = None + + +class ReceiptQuery(PageQuery): + """回款列表查询条件(筛选参数纯蛇形直出,同 BusinessQuery 契约)。""" + + business_id: int | None = Field(default=None, ge=1, description="业务筛选") + date_from: date | None = Field(default=None, description="回款到账日起(含)") + date_to: date | None = Field(default=None, description="回款到账日止(含)") + is_barter: int | None = Field(default=None, ge=0, le=1, description="以物抵顶筛选:1是 0否") + settlement_status: int | None = Field( + default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算" + ) diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py new file mode 100644 index 0000000..162acac --- /dev/null +++ b/backend/app/services/receipt_service.py @@ -0,0 +1,349 @@ +"""回款管理业务编排(需求 2.2.3)。 + +事务边界在本层:repository 只 flush,commit/rollback 只写在这里。 + +- **写操作前 FOR UPDATE 锁业务行**(delete_business 先例):同业务的 + 发货/回款/编辑相互串行化,防止并发写后 ``_recalc_totals`` 重算 + total_received_amount 互相覆盖(丢失更新);批次号生成(MAX+FOR + UPDATE 锁当日序号区间)与唯一键(1062)冲突重试一次兜底,模式同 + business_service +- **shipment_id 同业务强校验**(数据完整性红线):指定的发货单必须存在 + 且与本回款同属一个业务 —— 跨业务指定会让回款天数与台账归属漂移; + 存在性与归属两类失败统一同一文案(防通过响应差异探测发货单存在性, + 与登录判定的防枚举同哲学),FK RESTRICT 是最终防线 +- **settlement_status 是 M3 核算防重锚点**(需规 2.3.3「每笔回款仅核算 + 一次」):已核算回款禁改禁删 —— 改金额/删行会让 M3 台账与源数据漂移; + M2 只写入 0(未核算),置 1 由 M3 核算服务负责 +- 回款合计回写统一收口 ``business_service._recalc_totals``(增删改后 + 都重算,禁各处手写 SUM) +- payment_days 为**只读派生值**:shipment_id 非空且发货单在库时按 + ``formula.payment_days(ship_date, receipt_date)`` 实时计算(发货日首日 + 计第 1 天),不落库;未指定批次为 None +- 一期禁负数金额(≥0):schema 拒绝;红字/退款冲销需求出现时先补设计 +""" + +import logging +from decimal import Decimal + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.constants import ( + ACTION_CREATE, + ACTION_DELETE, + ACTION_UPDATE, + MODULE_RECEIPT, + NO_PREFIX_RECEIPT, +) +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import Receipt +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.models.receipt import SETTLEMENT_DONE, SETTLEMENT_PENDING +from app.repositories import business_repository, receipt_repository +from app.schemas.receipt import ( + ReceiptCreateIn, + ReceiptOut, + ReceiptQuery, + ReceiptUpdateIn, +) +from app.services import audit_service +from app.services.audit_service import AuditRecord, RequestMeta +from app.services.business_service import _recalc_totals +from app.services.formula import payment_days +from app.services.no_generator import next_no + +logger = logging.getLogger(__name__) + + +def _to_out(row: tuple) -> ReceiptOut: + """(receipt, business_no, shipment_no, ship_date) → 响应模型。 + + 金额出参 str;payment_days 为派生值 —— ship_date 为 None(未指定批次) + 时输出 None;回款日早于发货日时按公式照实输出负值,忠实反映源数据 + (钳制会掩盖录入异常,M3 台账同口径)。 + """ + receipt, business_no, shipment_no, ship_date = row + return ReceiptOut( + id=receipt.id, + receipt_batch_no=receipt.receipt_batch_no, + business_id=receipt.business_id, + business_no=business_no, + shipment_id=receipt.shipment_id, + shipment_no=shipment_no, + receipt_date=receipt.receipt_date, + amount=str(receipt.amount), + is_barter=receipt.is_barter, + settlement_status=receipt.settlement_status, + payment_days=( + payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None + ), + remark=receipt.remark, + ) + + +def _generate_no(session: Session) -> str: + """生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" + return next_no( + session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT + ) + + +def _ensure_shipment_of_business( + session: Session, shipment_id: int | None, business_id: int +) -> tuple[str | None, object | None]: + """发货批次同业务强校验,返回 (shipment_no, ship_date) 供 DTO 组装。 + + 「必须存在且 business_id 相同」两类失败统一同一文案(防探测发货单 + 存在性);shipment_id 为 None = 暂未指定,直接放行。 + + :raises BadRequestError: 发货批次不存在或不属于该业务 + """ + if shipment_id is None: + return None, None + shipment = receipt_repository.get_shipment(session, shipment_id) + if shipment is None or shipment.business_id != business_id: + raise BadRequestError("发货批次不属于该业务,无法指定") + return shipment.shipment_no, shipment.ship_date + + +def _ensure_business_writable(session: Session, business_id: int, action: str): + """锁业务行并要求状态=1 执行中(新增/编辑/删除共用的写准入)。 + + 锁在判定之前取:判定结果基于锁定后的行,配合 _recalc_totals 的重算 + 同事务提交,并发写不会丢失更新。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 + """ + business = business_repository.get_by_id(session, business_id, for_update=True) + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError(f"业务已完成或已终止,禁止{action}回款") + return business + + +def list_receipts(session: Session, query: ReceiptQuery) -> PageResult[ReceiptOut]: + """分页查询回款(附业务号/发货单号与 payment_days 派生值)。""" + rows = receipt_repository.list_receipts( + session, + offset=query.offset, + limit=query.page_size, + business_id=query.business_id, + date_from=query.date_from, + date_to=query.date_to, + is_barter=query.is_barter, + settlement_status=query.settlement_status, + ) + total = receipt_repository.count_receipts( + session, + business_id=query.business_id, + date_from=query.date_from, + date_to=query.date_to, + is_barter=query.is_barter, + settlement_status=query.settlement_status, + ) + return PageResult[ReceiptOut]( + records=[_to_out(row) for row in rows], + total=total, + page_num=query.page_num, + page_size=query.page_size, + ) + + +def get_detail(session: Session, receipt_id: int) -> ReceiptOut: + """回款详情(同列表 DTO,含 payment_days 派生值)。 + + :raises NotFoundError: 回款不存在 + """ + row = receipt_repository.get_with_refs(session, receipt_id) + if row is None: + raise NotFoundError("回款不存在") + return _to_out(row) + + +def create_receipt( + session: Session, + payload: ReceiptCreateIn, + *, + operator, + meta: RequestMeta | None = None, +) -> ReceiptOut: + """新增回款:锁业务行 → 校验 → 建行 + 重算合计,一个事务。 + + 摘要三要素(批次号/业务号/金额)在进入审计前就绪:业务号取自锁行 + 结果、批次号先行生成(SELECT MAX FOR UPDATE,不产生未提交变更)、 + 金额来自入参;引用校验(业务状态/发货批次同业务)在审计上下文内, + 失败也留痕 —— 失败路径回滚释放已取序号,当日序号出现空洞无害 + (唯一性不依赖连续)。 + + :raises NotFoundError: 业务不存在 + :raises BadRequestError: 业务已完成或已终止 / 发货批次不属于该业务 + """ + business = business_repository.get_by_id(session, payload.business_id, for_update=True) + batch_no = _generate_no(session) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_CREATE, + summary=( + f"新增回款 {batch_no}" + f"(业务 {business.business_no if business else f'#{payload.business_id}'}," + f"金额 {payload.amount})" + ), + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新增回款") + shipment_no, ship_date = _ensure_shipment_of_business( + session, payload.shipment_id, payload.business_id + ) + + try: + receipt = receipt_repository.create( + session, + Receipt( + receipt_batch_no=batch_no, + business_id=payload.business_id, + shipment_id=payload.shipment_id, + receipt_date=payload.receipt_date, + amount=Decimal(payload.amount), + is_barter=payload.is_barter, + settlement_status=SETTLEMENT_PENDING, + remark=payload.remark, + ), + ) + _recalc_totals(session, business) + out = _to_out((receipt, business.business_no, shipment_no, ship_date)) + new_id = receipt.id + session.commit() + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc): + # 批次号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁) + return _retry_create(session, payload, operator=operator, meta=meta) + raise + + logger.info( + "新增回款 | receipt_id=%s | no=%s | business_id=%s", + new_id, + batch_no, + payload.business_id, + ) + return out + + +def _retry_create( + session: Session, + payload: ReceiptCreateIn, + *, + operator, + meta: RequestMeta | None, +) -> ReceiptOut: + """批次号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。""" + return create_receipt(session, payload, operator=operator, meta=meta) + + +def update_receipt( + session: Session, + receipt_id: int, + payload: ReceiptUpdateIn, + *, + operator, + meta: RequestMeta | None = None, +) -> ReceiptOut: + """编辑回款(全量替换)+ 重算合计,一个事务。 + + **已核算回款禁止修改**(M3 防重锚点):M3 台账已按该行固化核算, + 改金额/到账日/批次指定会让台账与源数据漂移。shipment_id 变更后 + 重新做同业务强校验(全量替换语义下每次编辑都校验,无「未变即免检」 + 的旁路)。 + + :raises NotFoundError: 回款不存在 / 业务不存在 + :raises BadRequestError: 已核算禁改 / 业务已完成或已终止 / 发货批次不属于该业务 + """ + receipt = receipt_repository.get_by_id(session, receipt_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_UPDATE, + summary=f"修改回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + if receipt is None: + raise NotFoundError("回款不存在") + if receipt.settlement_status == SETTLEMENT_DONE: + raise BadRequestError("已核算回款禁止修改") + business = _ensure_business_writable(session, receipt.business_id, "修改") + # 返回值不取用:DTO 组装以 get_with_refs 重查为准(shipment 变更后同源) + _ensure_shipment_of_business(session, payload.shipment_id, receipt.business_id) + + receipt.receipt_date = payload.receipt_date + receipt.shipment_id = payload.shipment_id + receipt.amount = Decimal(payload.amount) + receipt.is_barter = payload.is_barter + receipt.remark = payload.remark + _recalc_totals(session, business) + out = _to_out(receipt_repository.get_with_refs(session, receipt_id)) + batch_no = receipt.receipt_batch_no + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 + logger.info("修改回款 | receipt_id=%s | no=%s", receipt_id, batch_no) + return out + + +def delete_receipt( + session: Session, + receipt_id: int, + *, + operator, + meta: RequestMeta | None = None, +) -> None: + """删除回款(物理删除)+ 重算合计,一个事务。 + + **已核算回款禁止删除**(M3 防重锚点,理由同编辑);业务须状态=1 + 执行中 —— 终态业务的回款是 M3 核算与报表的既成事实,不得删改。 + + :raises NotFoundError: 回款不存在 / 业务不存在 + :raises BadRequestError: 已核算禁删 / 业务已完成或已终止 + """ + receipt = receipt_repository.get_by_id(session, receipt_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RECEIPT, + action=ACTION_DELETE, + summary=f"删除回款 {receipt.receipt_batch_no if receipt else f'#{receipt_id}'}", + ), + meta=meta, + ): + if receipt is None: + raise NotFoundError("回款不存在") + if receipt.settlement_status == SETTLEMENT_DONE: + raise BadRequestError("已核算回款禁止删除") + business = _ensure_business_writable(session, receipt.business_id, "删除") + + batch_no = receipt.receipt_batch_no + receipt_repository.delete(session, receipt) + _recalc_totals(session, business) + session.commit() + # commit 后不再触碰 ORM 属性(audit_service 约定):批次号已固化 + logger.info("删除回款 | receipt_id=%s | no=%s", receipt_id, batch_no) + + +def _is_duplicate_entry(exc: IntegrityError) -> bool: + """识别 MySQL 唯一键冲突(1062),与 business_service 同口径。""" + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == 1062 -- 2.27.0 From 09831dbb7f8a43e8c8a2e8549301d18231281588 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:25:34 +0800 Subject: [PATCH 031/100] =?UTF-8?q?feat(business):=20=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E5=8F=91=E8=B4=A7/=E5=9B=9E=E6=AC=BE=E8=B7=AF=E7=94=B1=20/api/?= =?UTF-8?q?v1/business/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/app/api/router.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index f8c155f..4e3dc23 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -13,6 +13,8 @@ from app.api.v1.base import personnel as base_personnel from app.api.v1.base import products as base_products from app.api.v1.business import businesses as business_businesses from app.api.v1.business import ratings as business_ratings +from app.api.v1.business import receipts as business_receipts +from app.api.v1.business import shipments as business_shipments from app.api.v1.system import business_roles as system_business_roles from app.api.v1.system import logs as system_logs from app.api.v1.system import params as system_params @@ -48,6 +50,8 @@ v1_router.include_router(base_router) # 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问 business_router = APIRouter(prefix="/business") business_router.include_router(business_businesses.router) +business_router.include_router(business_shipments.router) +business_router.include_router(business_receipts.router) business_router.include_router(business_ratings.router) v1_router.include_router(business_router) -- 2.27.0 From 5207d14daa88be8347d73ec4ebad25c1e2637e8b Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:25:45 +0800 Subject: [PATCH 032/100] =?UTF-8?q?feat(base):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=E4=B8=89=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E8=AF=84=E7=BA=A7=E8=AE=A1=E7=AE=97=E5=99=A8?= =?UTF-8?q?=EF=BC=88=E4=BA=BA=E5=91=98/=E4=BA=A7=E5=93=81/=E5=AE=A2?= =?UTF-8?q?=E6=88=B7/=E8=AF=84=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条; 客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉 - 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉 - 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float Co-Authored-By: Claude Code --- frontend/src/api/customers.ts | 127 ++++ frontend/src/api/personnel.ts | 150 +++++ frontend/src/api/products.ts | 91 +++ frontend/src/api/ratings.ts | 60 ++ frontend/src/router/index.ts | 27 + frontend/src/utils/constants.ts | 10 +- frontend/src/views/base/customers/index.vue | 562 ++++++++++++++++ frontend/src/views/base/personnel/index.vue | 659 +++++++++++++++++++ frontend/src/views/base/products/index.vue | 351 ++++++++++ frontend/src/views/business/rating/index.vue | 482 ++++++++++++++ 10 files changed, 2518 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/customers.ts create mode 100644 frontend/src/api/personnel.ts create mode 100644 frontend/src/api/products.ts create mode 100644 frontend/src/api/ratings.ts create mode 100644 frontend/src/views/base/customers/index.vue create mode 100644 frontend/src/views/base/personnel/index.vue create mode 100644 frontend/src/views/base/products/index.vue create mode 100644 frontend/src/views/business/rating/index.vue diff --git a/frontend/src/api/customers.ts b/frontend/src/api/customers.ts new file mode 100644 index 0000000..812721c --- /dev/null +++ b/frontend/src/api/customers.ts @@ -0,0 +1,127 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 客户列表项(对应后端 `CustomerOut`)。 + * + * owner_personnel_name 由联表带出:归属人员不可考(被物理删除)时为 null, + * owner_personnel_id 仍保留原值供追溯。 + */ +export interface CustomerItem { + id: number + company_name: string + /** 客户公司的负责人姓名(非系统内营销人员) */ + owner_name: string + contact_person: string | null + contact_phone: string | null + address: string | null + industry: string | null + /** personal 个人户 / manager_personal 经理个人户 / enterprise 企业户(计提规则锚点) */ + customer_type: string + owner_personnel_id: number | null + owner_personnel_name: string | null + remark: string | null +} + +export interface CustomerQueryParams { + pageNum?: number + pageSize?: number + /** 公司名称或客户负责人,模糊匹配 */ + keyword?: string + customer_type?: string + owner_personnel_id?: number +} + +/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */ +export interface CustomerCreateParams { + company_name: string + owner_name: string + contact_person?: string | null + contact_phone?: string | null + address?: string | null + industry?: string | null + customer_type: string + owner_personnel_id?: number | null + remark?: string | null +} + +/** + * 编辑客户:不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership` + * (唯一写归属留痕的入口),编辑表单即使提交归属也会被后端忽略。 + */ +export type CustomerUpdateParams = Omit + +/** 归属变更入参(new_owner_personnel_id 空 = 清空归属) */ +export interface CustomerOwnershipParams { + new_owner_personnel_id?: number | null + reason?: string | null +} + +/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ +export interface OwnershipLogItem { + id: number + customer_id: number + old_owner_personnel_id: number | null + new_owner_personnel_id: number | null + old_owner_name: string | null + new_owner_name: string | null + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 客户分类下拉项(与后端 `core/constants.py` 的值域一致) */ +export const CUSTOMER_TYPE_OPTIONS = [ + { label: '个人户', value: 'personal' }, + { label: '经理个人户', value: 'manager_personal' }, + { label: '企业户', value: 'enterprise' }, +] as const + +/** 分类编码 → 展示名(表格列展示用,未知编码原样回显) */ +export function customerTypeLabel(code: string): string { + return CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code +} + +/** 分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名) */ +export function listCustomers(params: CustomerQueryParams): Promise> { + return request({ url: '/v1/base/customers', method: 'get', params }) +} + +/** 新增客户(公司名称+负责人组合唯一,冲突由后端拦截) */ +export function createCustomer(data: CustomerCreateParams): Promise { + return request({ url: '/v1/base/customers', method: 'post', data }) +} + +/** 编辑客户档案(不含归属;全量提交) */ +export function updateCustomer( + customerId: number, + data: CustomerUpdateParams, +): Promise { + return request({ url: `/v1/base/customers/${customerId}`, method: 'put', data }) +} + +/** 删除客户(物理删除) */ +export function deleteCustomer(customerId: number): Promise { + return request({ url: `/v1/base/customers/${customerId}`, method: 'delete' }) +} + +/** 归属变更(唯一写归属留痕的入口;新旧相同后端报「归属未变化」) */ +export function changeOwnership( + customerId: number, + data: CustomerOwnershipParams, +): Promise { + return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data }) +} + +/** 查某客户的归属变更留痕(分页,按变更时间倒序) */ +export function listOwnershipLogs( + customerId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/base/customers/${customerId}/ownership-logs`, + method: 'get', + params, + }) +} diff --git a/frontend/src/api/personnel.ts b/frontend/src/api/personnel.ts new file mode 100644 index 0000000..00b821d --- /dev/null +++ b/frontend/src/api/personnel.ts @@ -0,0 +1,150 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 辐射区域条目(对应后端 `PersonnelRegionOut`) */ +export interface PersonnelRegionItem { + id: number + region_name: string + remark: string | null +} + +/** 拓展计划条目(对应后端 `PersonnelPlanOut`) */ +export interface PersonnelPlanItem { + id: number + plan_period: string + plan_content: string + target_desc: string | null +} + +/** 人员列表项(对应后端 `PersonnelOut`) */ +export interface PersonnelItem { + id: number + personnel_name: string + business_role_id: number + /** 岗位名称由联表带出 */ + role_name: string | null + manager_id: number | null + manager_name: string | null + phone: string | null + /** 入职日期,ISO 串(YYYY-MM-DD) */ + hire_date: string | null + /** 1试用期 0正式 */ + is_probation: number + /** 月工资标准(元),字符串直出 —— 金额红线:不经 float */ + salary_standard: string | null + /** 1在职 0离职 */ + status: number + remark: string | null +} + +/** 人员详情:附辐射区域与拓展计划,供编辑抽屉一次回显 */ +export interface PersonnelDetail extends PersonnelItem { + regions: PersonnelRegionItem[] + plans: PersonnelPlanItem[] +} + +export interface PersonnelQueryParams { + pageNum?: number + pageSize?: number + /** 姓名,模糊匹配 */ + keyword?: string + /** 1在职 0离职 */ + status?: number + /** 岗位角色ID */ + business_role_id?: number +} + +/** + * 新增/编辑人员(全量提交)。 + * + * 不含状态 —— 在职/离职是独立操作(PUT /{id}/status),避免编辑表单 + * 误触离职。可空字段传 `null` 表示清空,与后端 `PersonnelCreateIn` 对齐。 + */ +export interface PersonnelSaveParams { + personnel_name: string + business_role_id: number + /** 直属经理ID,空=无;后端校验不能是自己且须在职 */ + manager_id?: number | null + phone?: string | null + /** ISO 串(YYYY-MM-DD) */ + hire_date?: string | null + /** 1试用期 0正式 */ + is_probation?: number + /** 月工资标准(元),字符串形式的数字,最多两位小数 */ + salary_standard?: string | null + remark?: string | null +} + +/** 辐射区域条目入参(全量替换用) */ +export interface RegionItemParams { + region_name: string + remark?: string | null +} + +/** 拓展计划条目入参(全量替换用) */ +export interface PlanItemParams { + plan_period: string + plan_content: string + target_desc?: string | null +} + +/** 分页查询人员(含离职),姓名模糊匹配 */ +export function listPersonnel(params: PersonnelQueryParams): Promise> { + return request({ url: '/v1/base/personnel', method: 'get', params }) +} + +/** 新增人员(默认在职;岗位须存在且启用) */ +export function createPersonnel(data: PersonnelSaveParams): Promise { + return request({ url: '/v1/base/personnel', method: 'post', data }) +} + +/** 人员详情(附辐射区域与拓展计划) */ +export function getPersonnelDetail(personnelId: number): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'get' }) +} + +/** 编辑人员(全量提交,不含状态) */ +export function updatePersonnel( + personnelId: number, + data: PersonnelSaveParams, +): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'put', data }) +} + +/** 删除人员(被团队成员或客户归属引用时后端拒绝,应改为离职) */ +export function deletePersonnel(personnelId: number): Promise { + return request({ url: `/v1/base/personnel/${personnelId}`, method: 'delete' }) +} + +/** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */ +export function setPersonnelStatus(personnelId: number, status: number): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/status`, + method: 'put', + data: { status }, + }) +} + +/** 全量替换辐射区域(空列表即清空该人员的全部区域) */ +export function replacePersonnelRegions( + personnelId: number, + items: RegionItemParams[], +): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/regions`, + method: 'put', + data: { items }, + }) +} + +/** 全量替换拓展计划(空列表即清空该人员的全部计划) */ +export function replacePersonnelPlans( + personnelId: number, + items: PlanItemParams[], +): Promise { + return request({ + url: `/v1/base/personnel/${personnelId}/plans`, + method: 'put', + data: { items }, + }) +} diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts new file mode 100644 index 0000000..8d14290 --- /dev/null +++ b/frontend/src/api/products.ts @@ -0,0 +1,91 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 产品列表项(对应后端 `ProductOut`)。 + * + * 数量/单价为字符串直出 —— 金额红线:前端禁用 float 解析与 toFixed, + * 展示原串、提交原串,Decimal 转换收敛在后端 service 层。 + */ +export interface ProductItem { + id: number + product_name: string + /** 规格型号,可空串(与名称构成组合唯一键) */ + spec_model: string + unit: string | null + stock_quantity: string | null + sale_price: string | null + /** municipal_water 市政水利 / rail_transit 轨道交通 —— 折算阈值的行级锚点 */ + product_category: string + /** 1启用 0停用 */ + status: number + remark: string | null + create_time: string + update_time: string +} + +export interface ProductQueryParams { + pageNum?: number + pageSize?: number + /** 名称或规格,模糊匹配 */ + keyword?: string + product_category?: string + status?: number +} + +/** 新增/编辑产品(全量提交;不含状态,启停走独立端点) */ +export interface ProductSaveParams { + product_name: string + /** 空串与 None 同义(后端归一为空串以保组合唯一键生效) */ + spec_model?: string | null + unit?: string | null + /** 非负数字字符串 */ + stock_quantity?: string | null + /** 非负数字字符串 */ + sale_price?: string | null + product_category: string + remark?: string | null +} + +/** 产品类别下拉项(与后端 `core/constants.py` 的值域一致) */ +export const PRODUCT_CATEGORY_OPTIONS = [ + { label: '市政水利', value: 'municipal_water' }, + { label: '轨道交通', value: 'rail_transit' }, +] as const + +/** 类别编码 → 展示名(表格列展示用,未知编码原样回显) */ +export function productCategoryLabel(code: string): string { + return PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code +} + +/** 分页查询产品(含停用) */ +export function listProducts(params: ProductQueryParams): Promise> { + return request({ url: '/v1/base/products', method: 'get', params }) +} + +/** 新增产品(默认启用;类别须在合法值域内) */ +export function createProduct(data: ProductSaveParams): Promise { + return request({ url: '/v1/base/products', method: 'post', data }) +} + +/** 编辑产品(全量提交,不含状态;名称+规格组合唯一,冲突由后端拦截) */ +export function updateProduct( + productId: number, + data: ProductSaveParams, +): Promise { + return request({ url: `/v1/base/products/${productId}`, method: 'put', data }) +} + +/** 删除产品(物理删除,仅限误录数据;常规下架用停用) */ +export function deleteProduct(productId: number): Promise { + return request({ url: `/v1/base/products/${productId}`, method: 'delete' }) +} + +/** 启用 / 停用产品 */ +export function setProductStatus(productId: number, status: number): Promise { + return request({ + url: `/v1/base/products/${productId}/status`, + method: 'put', + data: { status }, + }) +} diff --git a/frontend/src/api/ratings.ts b/frontend/src/api/ratings.ts new file mode 100644 index 0000000..434a2d0 --- /dev/null +++ b/frontend/src/api/ratings.ts @@ -0,0 +1,60 @@ +import { request } from './request' + +/** + * 评级等级配置项(对应后端 `RatingGradeOut`)。 + * + * min_score/max_score 按分值红线以**字符串**出参(后端 str 直出), + * 前端参与数值比较前必须显式 parseFloat 转数值,禁止直接做字符串比大小。 + */ +export interface RatingGradeItem { + id: number + grade_name: string + min_score: string + max_score: string + /** 判定顺序,升序;判定取首个 min_score ≤ 总分的等级 */ + sort_order: number + remark: string | null + create_time: string + update_time: string +} + +/** + * 新增 / 编辑等级入参(编辑为全量替换语义)。 + * + * 分值以字符串承载(如 "90.0"):number 无法精确表示全部十进制小数, + * 字符串是分值红线的入口闸门,Decimal 转换统一收敛在后端 service 层。 + */ +export interface RatingGradeParams { + grade_name: string + min_score: string + max_score: string + sort_order?: number + remark?: string +} + +/** + * 全量等级列表(按 sort_order 升序返回,不分页)。 + * + * 等级映射是评级计算器的判定表,数量个位数,分页无意义。 + */ +export function listRatingGrades(): Promise { + return request({ url: '/v1/business/rating-grades', method: 'get' }) +} + +/** 新增评级等级(区间合法与不重叠由后端校验兜底,前端校验仅是展示约束) */ +export function createRatingGrade(data: RatingGradeParams): Promise { + return request({ url: '/v1/business/rating-grades', method: 'post', data }) +} + +/** 编辑评级等级(全量替换,字段与新增一致) */ +export function updateRatingGrade( + gradeId: number, + data: RatingGradeParams, +): Promise { + return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data }) +} + +/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */ +export function deleteRatingGrade(gradeId: number): Promise { + return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 08e4639..b8f8836 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -70,6 +70,33 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/system/logs/index.vue'), meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 }, }, + { + // 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base) + path: '/base/personnel', + name: 'base-personnel', + component: () => import('@/views/base/personnel/index.vue'), + meta: { title: '人员管理', group: '基础数据', order: 8 }, + }, + { + path: '/base/products', + name: 'base-products', + component: () => import('@/views/base/products/index.vue'), + meta: { title: '产品管理', group: '基础数据', order: 9 }, + }, + { + path: '/base/customers', + name: 'base-customers', + component: () => import('@/views/base/customers/index.vue'), + meta: { title: '客户管理', group: '基础数据', order: 10 }, + }, + { + // 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, + // 登录用户即可用,不设 requiresAdmin(区别于系统管理组) + path: '/business/rating', + name: 'business-rating', + component: () => import('@/views/business/rating/index.vue'), + meta: { title: '客户评级计算器', group: '业务数据', order: 14 }, + }, ], }, // 未匹配路由回首页;若未登录会被守卫拦到登录页 diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 37ec5e0..939f9b2 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -7,7 +7,15 @@ export const ROLE_ADMIN = 'admin' * 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的 * `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。 */ -export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const +export const MODULE_OPTIONS = [ + '用户管理', + '角色管理', + '岗位角色', + '计提参数', + '人员管理', + '产品管理', + '客户管理', +] as const export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const /** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */ diff --git a/frontend/src/views/base/customers/index.vue b/frontend/src/views/base/customers/index.vue new file mode 100644 index 0000000..88eba5f --- /dev/null +++ b/frontend/src/views/base/customers/index.vue @@ -0,0 +1,562 @@ + + + + + diff --git a/frontend/src/views/base/personnel/index.vue b/frontend/src/views/base/personnel/index.vue new file mode 100644 index 0000000..0af2bd2 --- /dev/null +++ b/frontend/src/views/base/personnel/index.vue @@ -0,0 +1,659 @@ + + + + + diff --git a/frontend/src/views/base/products/index.vue b/frontend/src/views/base/products/index.vue new file mode 100644 index 0000000..c156c88 --- /dev/null +++ b/frontend/src/views/base/products/index.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend/src/views/business/rating/index.vue b/frontend/src/views/business/rating/index.vue new file mode 100644 index 0000000..b60ea56 --- /dev/null +++ b/frontend/src/views/business/rating/index.vue @@ -0,0 +1,482 @@ + + + + + -- 2.27.0 From 06cdf4141f3c85113f20c6a22d2b0a037759661b Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:50:10 +0800 Subject: [PATCH 033/100] =?UTF-8?q?feat(business):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1/=E5=8F=91=E8=B4=A7/=E5=9B=9E=E6=AC=BE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=89=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 业务页:明细行编辑器、详情抽屉五分区(含状态时间线与毛利润卡片) - 回款页:业务→发货批次级联指定(可清空)、已核算行禁改禁删 - 金额/比率字符串直出;毛利润卡片注明不含运费口径 Co-Authored-By: Claude Code --- frontend/src/api/businesses.ts | 294 ++++ frontend/src/api/receipts.ts | 104 ++ frontend/src/api/shipments.ts | 117 ++ frontend/src/router/index.ts | 19 + .../src/views/business/businesses/index.vue | 1309 +++++++++++++++++ .../src/views/business/receipts/index.vue | 508 +++++++ .../src/views/business/shipments/index.vue | 497 +++++++ 7 files changed, 2848 insertions(+) create mode 100644 frontend/src/api/businesses.ts create mode 100644 frontend/src/api/receipts.ts create mode 100644 frontend/src/api/shipments.ts create mode 100644 frontend/src/views/business/businesses/index.vue create mode 100644 frontend/src/views/business/receipts/index.vue create mode 100644 frontend/src/views/business/shipments/index.vue diff --git a/frontend/src/api/businesses.ts b/frontend/src/api/businesses.ts new file mode 100644 index 0000000..f9c10cf --- /dev/null +++ b/frontend/src/api/businesses.ts @@ -0,0 +1,294 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 业务产品明细行(对应后端 `BusinessItemOut`)。 + * + * 数量与金额一律字符串直出 —— 金额红线:不经 float、不 toFixed, + * Decimal 运算收敛在后端 service 层。 + */ +export interface BusinessItemRow { + id: number + product_id: number | null + product_name: string + spec_model: string | null + unit: string | null + /** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */ + product_category: string + /** 数字字符串,>0 */ + quantity: string + sale_price: string | null + /** 行销售额 = 数量 × 销售单价,后端计算固化 */ + sale_amount: string + cost_amount: string | null + guided_price: string | null + remark: string | null +} + +/** 业务列表项(对应后端 `BusinessOut`;四个汇总金额均为字符串直出) */ +export interface BusinessItem { + id: number + business_no: string + customer_id: number + customer_name: string | null + /** ISO 串(YYYY-MM-DD) */ + business_date: string | null + /** 1执行中 2已完成 3已终止 */ + status: number + owner_personnel_id: number | null + owner_personnel_name: string | null + assistant_personnel_id: number | null + assistant_personnel_name: string | null + /** 特殊业务标记:1是 0否 */ + is_special: number + special_note: string | null + total_sale_amount: string | null + total_cost_amount: string | null + total_ship_amount: string | null + total_received_amount: string | null + total_freight_amount: string | null + remark: string | null +} + +/** 业务详情:附产品明细行(编辑弹窗一次回显) */ +export interface BusinessDetail extends BusinessItem { + items: BusinessItemRow[] +} + +export interface BusinessQueryParams { + pageNum?: number + pageSize?: number + /** 业务编号/公司名称,模糊匹配 */ + keyword?: string + status?: number + customer_id?: number + owner_personnel_id?: number + /** 业务日期起(含),ISO 串 */ + date_from?: string + /** 业务日期止(含),ISO 串 */ + date_to?: string +} + +/** 明细行入参(product_id 空 = 自由录入;金额/数量为数字字符串) */ +export interface BusinessItemParams { + product_id?: number | null + product_name: string + spec_model?: string | null + unit?: string | null + product_category: string + quantity: string + sale_price?: string | null + cost_amount?: string | null + guided_price?: string | null + remark?: string | null +} + +/** + * 创建业务:主表信息 + 明细行(≥1)。 + * + * is_special=1 时 special_note 必填(后端校验,前端同步拦截)。 + */ +export interface BusinessCreateParams { + customer_id: number + business_date?: string | null + owner_personnel_id?: number | null + assistant_personnel_id?: number | null + is_special?: number + special_note?: string | null + items: BusinessItemParams[] + remark?: string | null +} + +/** + * 编辑业务:明细全量替换并重算汇总。 + * + * 不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`(唯一写归属 + * 留痕的入口),编辑表单即使提交归属也会被后端忽略。 + */ +export interface BusinessUpdateParams { + customer_id: number + business_date?: string | null + is_special?: number + special_note?: string | null + items: BusinessItemParams[] + remark?: string | null +} + +/** 状态流转入参:执行中 → 2已完成 / 3已终止;终止时 reason 必填 */ +export interface BusinessStatusParams { + status: number + reason?: string | null +} + +/** 归属变更入参(新负责/新协助;前后值写入留痕,不回溯历史) */ +export interface BusinessOwnershipParams { + new_owner_personnel_id?: number | null + new_assistant_personnel_id?: number | null + reason?: string | null +} + +/** 业务归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */ +export interface OwnershipLogItem { + id: number + business_id: number + old_owner_personnel_id: number | null + new_owner_personnel_id: number | null + old_assistant_personnel_id: number | null + new_assistant_personnel_id: number | null + old_owner_name: string | null + new_owner_name: string | null + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 业务状态流转记录(对应后端 `BusinessStatusLogOut`;创建首条 old=new=1) */ +export interface StatusLogItem { + id: number + business_id: number + old_status: number + new_status: number + change_reason: string | null + operator_id: number | null + operator_name: string + create_time: string +} + +/** 毛利润视图行(对应后端 `GrossProfitRowOut`);成本缺失行派生列为 null */ +export interface GrossProfitRowItem { + item_id: number + product_category: string + product_name: string + spec_model: string | null + quantity: string + sale_amount: string + cost_amount: string | null + profit_amount: string | null + profit_rate: string | null + threshold: string + converted_profit: string | null + missing_reason: string | null +} + +/** 按产品类别的毛利润汇总(成本缺失行不计入聚合) */ +export interface GrossProfitCategory { + product_category: string + sale_amount: string + cost_amount: string + profit_amount: string + /** 分数字符串原样(如 "997/1000"),不做数值化展示 */ + profit_rate: string + threshold: string + converted_profit: string +} + +/** 毛利润数据视图(对应后端 `GrossProfitOut`) */ +export interface GrossProfitView { + business_id: number + /** 参数取值时点(默认今日),ISO 串 */ + as_of: string + rows: GrossProfitRowItem[] + categories: GrossProfitCategory[] + missing_count: number + note: string +} + +/** 业务状态下拉项(与后端值域一致;执行中=1 已完成=2 已终止=3) */ +export const BUSINESS_STATUS_OPTIONS = [ + { label: '执行中', value: 1 }, + { label: '已完成', value: 2 }, + { label: '已终止', value: 3 }, +] as const + +/** 状态编码 → 展示名 */ +export function businessStatusLabel(status: number): string { + return BUSINESS_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? `状态${status}` +} + +/** 状态编码 → 标签类型(三色:执行中 primary / 已完成 success / 已终止 danger) */ +export function businessStatusTagType(status: number): 'primary' | 'success' | 'danger' | 'info' { + if (status === 2) return 'success' + if (status === 3) return 'danger' + return 'primary' +} + +/** 分页查询业务(编号/公司名称模糊,可按状态/客户/归属/日期范围筛选) */ +export function listBusinesses(params: BusinessQueryParams): Promise> { + return request({ url: '/v1/business/businesses', method: 'get', params }) +} + +/** 创建业务(编号自动生成,写首条状态留痕) */ +export function createBusiness(data: BusinessCreateParams): Promise { + return request({ url: '/v1/business/businesses', method: 'post', data }) +} + +/** 业务详情(附产品明细行) */ +export function getBusinessDetail(businessId: number): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'get' }) +} + +/** 编辑业务(明细全量替换;归属不在此端点调整) */ +export function updateBusiness( + businessId: number, + data: BusinessUpdateParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'put', data }) +} + +/** 删除业务(有发货或回款记录时后端拒绝,应改为已终止) */ +export function deleteBusiness(businessId: number): Promise { + return request({ url: `/v1/business/businesses/${businessId}`, method: 'delete' }) +} + +/** 状态流转(终态不可再变更;终止必填原因) */ +export function changeBusinessStatus( + businessId: number, + data: BusinessStatusParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}/status`, method: 'put', data }) +} + +/** 调整负责/协助营销人员(同事务写归属变更留痕) */ +export function changeBusinessOwnership( + businessId: number, + data: BusinessOwnershipParams, +): Promise { + return request({ url: `/v1/business/businesses/${businessId}/ownership`, method: 'put', data }) +} + +/** 某业务的归属变更留痕(分页,按变更时间倒序) */ +export function listOwnershipLogs( + businessId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/business/businesses/${businessId}/ownership-logs`, + method: 'get', + params, + }) +} + +/** 某业务的状态流转时间线(分页,按时间正序,从创建起展示) */ +export function listStatusLogs( + businessId: number, + params: { pageNum?: number; pageSize?: number }, +): Promise> { + return request({ + url: `/v1/business/businesses/${businessId}/status-logs`, + method: 'get', + params, + }) +} + +/** 毛利润数据视图(as_of 缺省=今日;阈值按该时点参数快照) */ +export function getGrossProfit( + businessId: number, + asOf?: string, +): Promise { + return request({ + url: `/v1/business/businesses/${businessId}/gross-profit`, + method: 'get', + params: asOf ? { as_of: asOf } : undefined, + }) +} diff --git a/frontend/src/api/receipts.ts b/frontend/src/api/receipts.ts new file mode 100644 index 0000000..3dbb437 --- /dev/null +++ b/frontend/src/api/receipts.ts @@ -0,0 +1,104 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 回款列表项(对应后端 `ReceiptOut`;字段契约纯蛇形直出)。 + * + * amount 字符串直出(金额红线);payment_days 为派生值:未指定发货 + * 批次时为 null,指定后 = 发货日到回款日的天数(发货日计第 1 天)。 + */ +export interface ReceiptItem { + id: number + receipt_batch_no: string + business_id: number + business_no: string + shipment_id: number | null + shipment_no: string | null + /** 回款到账日,ISO 串(YYYY-MM-DD);M3 核算按此日匹配参数版本 */ + receipt_date: string + /** 回款金额(元),字符串直出 */ + amount: string + /** 以物抵顶标记:1是 0否(绩效按比例折算) */ + is_barter: number + /** 核算状态:0未核算 1已核算(已核算禁改禁删) */ + settlement_status: number + payment_days: number | null + remark: string | null +} + +export interface ReceiptQueryParams { + pageNum?: number + pageSize?: number + business_id?: number + /** 回款到账日起(含),ISO 串 */ + date_from?: string + /** 回款到账日止(含),ISO 串 */ + date_to?: string + is_barter?: number + settlement_status?: number +} + +/** + * 新增回款:业务必选,发货批次可暂不指定(手动指定)。 + * + * shipment_id 须与本回款同属一个业务(后端强校验,数据完整性红线)。 + */ +export interface ReceiptCreateParams { + business_id: number + shipment_id?: number | null + receipt_date: string + /** 数字字符串,非负,最多两位小数 */ + amount: string + is_barter?: number + remark?: string | null +} + +/** + * 编辑回款:全量替换语义;不含 business_id(挂靠业务创建时定死)。 + * + * shipment_id 传 null = 清除指定(回到「暂未指定」)。 + */ +export interface ReceiptUpdateParams { + shipment_id?: number | null + receipt_date: string + amount: string + is_barter?: number + remark?: string | null +} + +/** 以物抵顶筛选项 */ +export const BARTER_OPTIONS = [ + { label: '以物抵顶', value: 1 }, + { label: '货币回款', value: 0 }, +] as const + +/** 核算状态筛选项(与列表标签文案一致) */ +export const SETTLEMENT_OPTIONS = [ + { label: '已核算', value: 1 }, + { label: '未核算', value: 0 }, +] as const + +/** 分页查询回款(附业务号/发货单号与回款天数派生值) */ +export function listReceipts(params: ReceiptQueryParams): Promise> { + return request({ url: '/v1/business/receipts', method: 'get', params }) +} + +/** 新增回款(批次号自动生成 HK 前缀;业务须执行中) */ +export function createReceipt(data: ReceiptCreateParams): Promise { + return request({ url: '/v1/business/receipts', method: 'post', data }) +} + +/** 回款详情(含回款天数派生值) */ +export function getReceiptDetail(receiptId: number): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'get' }) +} + +/** 编辑回款(全量替换;已核算回款后端拒绝) */ +export function updateReceipt(receiptId: number, data: ReceiptUpdateParams): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'put', data }) +} + +/** 删除回款(已核算/业务非执行中时后端拒绝;业务回款合计自动回退) */ +export function deleteReceipt(receiptId: number): Promise { + return request({ url: `/v1/business/receipts/${receiptId}`, method: 'delete' }) +} diff --git a/frontend/src/api/shipments.ts b/frontend/src/api/shipments.ts new file mode 100644 index 0000000..7feb2eb --- /dev/null +++ b/frontend/src/api/shipments.ts @@ -0,0 +1,117 @@ +import { request } from './request' +import type { PageData } from './types' + +/** + * 发货产品明细行(对应后端 `ShipmentItemOut`)。 + * + * 数量/单价/行金额一律字符串直出 —— 金额红线:不经 float、不 toFixed; + * 行金额 = 数量 × 单价由后端计算固化,不信任前端传值。 + */ +export interface ShipmentItemRow { + id: number + product_id: number | null + product_name: string + spec_model: string | null + unit: string | null + /** 数字字符串,>0 */ + quantity: string + /** 发货单价,可空 = 待人工补价(补价前行金额记 0) */ + sale_price: string | null + /** 行金额,后端按数量×单价计算固化 */ + amount: string +} + +/** 发货单列表项(对应后端 `ShipmentOut`;business_no 为联表附带列) */ +export interface ShipmentItem { + id: number + shipment_no: string + business_id: number + business_no: string | null + /** ISO 串(YYYY-MM-DD);发货日是回款天数计算基准日(发货日计第 1 天) */ + ship_date: string + /** 运费(元),仅记录展示,不计入毛利润口径 */ + freight_amount: string + /** 发货总金额,后端按明细固化 */ + total_amount: string + remark: string | null +} + +/** 发货单详情:附产品明细行(编辑弹窗一次回显) */ +export interface ShipmentDetail extends ShipmentItem { + items: ShipmentItemRow[] +} + +export interface ShipmentQueryParams { + pageNum?: number + pageSize?: number + /** 业务筛选,空 = 全部 */ + business_id?: number + /** 发货日期起(含),ISO 串 */ + ship_date_from?: string + /** 发货日期止(含),ISO 串 */ + ship_date_to?: string +} + +/** 发货明细行入参(product_id 空 = 自由录入;单价可空=待人工补价) */ +export interface ShipmentItemParams { + product_id?: number | null + product_name: string + spec_model?: string | null + unit?: string | null + quantity: string + sale_price?: string | null +} + +/** + * 创建发货单:业务维度 + 明细行(≥1)。 + * + * 业务须处于执行中(后端校验);ship_date 为回款天数计算基准日。 + */ +export interface ShipmentCreateParams { + business_id: number + ship_date: string + freight_amount?: string | null + items: ShipmentItemParams[] + remark?: string | null +} + +/** + * 编辑发货单:明细全量替换。 + * + * 不含 business_id —— 发货单挂靠的业务创建时定死,改挂会让已指定 + * 本发货单的回款语义断裂。 + */ +export interface ShipmentUpdateParams { + ship_date: string + freight_amount?: string | null + items: ShipmentItemParams[] + remark?: string | null +} + +/** 分页查询发货单(附业务编号,可按业务/发货日期范围筛选) */ +export function listShipments(params: ShipmentQueryParams): Promise> { + return request({ url: '/v1/business/shipments', method: 'get', params }) +} + +/** 创建发货单(明细 ≥1;业务须执行中) */ +export function createShipment(data: ShipmentCreateParams): Promise { + return request({ url: '/v1/business/shipments', method: 'post', data }) +} + +/** 发货单详情(附产品明细行) */ +export function getShipmentDetail(shipmentId: number): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'get' }) +} + +/** 编辑发货单(明细全量替换;终态业务的发货单后端拒绝) */ +export function updateShipment( + shipmentId: number, + data: ShipmentUpdateParams, +): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'put', data }) +} + +/** 删除发货单(连带明细并回写业务汇总;被回款批次指定时后端拒绝) */ +export function deleteShipment(shipmentId: number): Promise { + return request({ url: `/v1/business/shipments/${shipmentId}`, method: 'delete' }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b8f8836..b3fb951 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -89,6 +89,25 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/base/customers/index.vue'), meta: { title: '客户管理', group: '基础数据', order: 10 }, }, + { + // 业务数据组一律为业务操作:登录即可访问,不设 requiresAdmin + path: '/business/businesses', + name: 'business-businesses', + component: () => import('@/views/business/businesses/index.vue'), + meta: { title: '业务管理', group: '业务数据', order: 11 }, + }, + { + path: '/business/shipments', + name: 'business-shipments', + component: () => import('@/views/business/shipments/index.vue'), + meta: { title: '发货管理', group: '业务数据', order: 12 }, + }, + { + path: '/business/receipts', + name: 'business-receipts', + component: () => import('@/views/business/receipts/index.vue'), + meta: { title: '回款管理', group: '业务数据', order: 13 }, + }, { // 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额, // 登录用户即可用,不设 requiresAdmin(区别于系统管理组) diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue new file mode 100644 index 0000000..ae3769a --- /dev/null +++ b/frontend/src/views/business/businesses/index.vue @@ -0,0 +1,1309 @@ + + + + + diff --git a/frontend/src/views/business/receipts/index.vue b/frontend/src/views/business/receipts/index.vue new file mode 100644 index 0000000..35a6e80 --- /dev/null +++ b/frontend/src/views/business/receipts/index.vue @@ -0,0 +1,508 @@ + + + + + diff --git a/frontend/src/views/business/shipments/index.vue b/frontend/src/views/business/shipments/index.vue new file mode 100644 index 0000000..2b3b788 --- /dev/null +++ b/frontend/src/views/business/shipments/index.vue @@ -0,0 +1,497 @@ + + + + + -- 2.27.0 From 77e578085aeaf968731d57b6fb73c50e64dcae3f Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 12:51:20 +0800 Subject: [PATCH 034/100] =?UTF-8?q?feat(business):=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=A8=A1=E5=9D=97=E8=AF=8D=E8=A1=A8=E8=A1=A5?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE=E5=9B=9B=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=88=E4=B8=8E=E5=90=8E=E7=AB=AF=20MODULE=5F*=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- frontend/src/utils/constants.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 939f9b2..89b641a 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -15,6 +15,10 @@ export const MODULE_OPTIONS = [ '人员管理', '产品管理', '客户管理', + '业务管理', + '发货管理', + '回款管理', + '客户评级', ] as const export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const -- 2.27.0 From ca4cc72eea9259d485139143d2998023a10129ed Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 13:39:09 +0800 Subject: [PATCH 035/100] =?UTF-8?q?test(business):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E9=9B=86?= =?UTF-8?q?=E6=88=90=E7=94=A8=E4=BE=8B=EF=BC=8870=20=E4=BE=8B=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E4=B8=A4=E5=A4=84=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖:编号格式与序号递增/状态机终态拦截/三处汇总冗余一致性/跨业务批次 400/已核算禁改禁删/毛利润金标准与防除零/asOf 回溯/评级重叠/审计双记录 - 缺陷修复①:change_status 留痕 old_status 取到赋值后属性(时间线失真 3→3) - 缺陷修复②:freight_amount 漏金额校验(负数/三位小数可落库污染运费合计) - conftest 扩展业务六表清理(编号前缀 + FK 逆序收口)与评级种子基线恢复 - 全量回归 603 passed(单元 381 / 集成 222) Co-Authored-By: Claude Code --- backend/app/schemas/shipment.py | 12 + backend/app/services/business_service.py | 7 +- backend/tests/README.md | 21 +- backend/tests/integration/conftest.py | 61 ++ backend/tests/integration/test_businesses.py | 811 +++++++++++++++++++ backend/tests/integration/test_ratings.py | 294 +++++++ backend/tests/integration/test_receipts.py | 422 ++++++++++ backend/tests/integration/test_shipments.py | 394 +++++++++ 8 files changed, 2017 insertions(+), 5 deletions(-) create mode 100644 backend/tests/integration/test_businesses.py create mode 100644 backend/tests/integration/test_ratings.py create mode 100644 backend/tests/integration/test_receipts.py create mode 100644 backend/tests/integration/test_shipments.py diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py index 61253f1..9ce2377 100644 --- a/backend/app/schemas/shipment.py +++ b/backend/app/schemas/shipment.py @@ -95,6 +95,12 @@ class ShipmentCreateIn(BaseModel): items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行") remark: str | None = Field(default=None, max_length=255) + @field_validator("freight_amount") + @classmethod + def _check_freight(cls, value: str | None) -> str | None: + """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" + return _check_amount(value, "运费金额", AMOUNT_MAX) + class ShipmentUpdateIn(BaseModel): """编辑发货单(明细全量替换)。 @@ -112,6 +118,12 @@ class ShipmentUpdateIn(BaseModel): ) remark: str | None = Field(default=None, max_length=255) + @field_validator("freight_amount") + @classmethod + def _check_freight(cls, value: str | None) -> str | None: + """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" + return _check_amount(value, "运费金额", AMOUNT_MAX) + class ShipmentQuery(PageQuery): """发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 0c92e1d..4a49f2d 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -511,12 +511,15 @@ def change_status( if business.status == payload.status: raise BadRequestError("状态未变化") + # 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值 + # (留痕会记成 old==new 失真,时间线无法回溯) + old_status = business.status business.status = payload.status business_repository.add_status_log( session, BusinessStatusLog( business_id=business_id, - old_status=business.status, + old_status=old_status, new_status=payload.status, change_reason=payload.reason, operator_id=operator.id, @@ -528,7 +531,7 @@ def change_status( logger.info( "业务状态变更 | business_id=%s | %s -> %s", business_id, - business.status, + old_status, payload.status, ) return out diff --git a/backend/tests/README.md b/backend/tests/README.md index 8cbd5d3..5620916 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -1,6 +1,6 @@ # 后端测试 -> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。 +> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。 ## 一、测试约定 @@ -102,6 +102,17 @@ uv run pytest tests/integration -q # 仅集成 运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。 +### 业务数据管理(2026-10-13 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 | +| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 | +| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 | +| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 | + +运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。 + ## 三、运行记录 | 日期 | 范围 | 用例数 | 结果 | 备注 | @@ -112,6 +123,8 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 | | 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) | | 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) | +| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 | +| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 | ## 四、测试发现并修复的缺陷 @@ -132,13 +145,15 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 | | 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 | | 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) | +| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 | +| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 | ## 五、待补用例 | 模块 | 状态 | 说明 | | --- | --- | --- | -| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护在业务数据表就位后于 T11 补测 | -| 业务数据管理 | 进行中 | 集成用例在 T11 统一编写(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) | +| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 | +| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 | | 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) | | 报表统计 | 未开始 | 依赖报表口径确认 | diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 6323f94..52e3b6b 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -12,9 +12,15 @@ 消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言 - 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id`` 会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态 +- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日 + 序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前 + 只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员 + 清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 —— + 评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等 """ import uuid +from decimal import Decimal import pytest from sqlalchemy import delete, select, update @@ -23,18 +29,35 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER from app.core.db import get_session_factory from app.core.security import create_access_token, hash_password from app.models import ( + Business, + BusinessItem, BusinessRole, + BusinessStatusLog, Customer, + CustomerRatingGrade, OperationLog, OwnershipChangeLog, Personnel, PersonnelPlan, PersonnelRegion, Product, + Receipt, Role, + Shipment, + ShipmentItem, User, UserRole, ) +from app.models.customer import OWNER_TYPE_BUSINESS + +# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序 +# 取首个 min_score ≤ 总分的等级,种子铺满 0-100 +_RATING_SEEDS = [ + ("A", Decimal("90"), Decimal("100"), 1, "优秀"), + ("B", Decimal("75"), Decimal("89"), 2, "良好"), + ("C", Decimal("60"), Decimal("74"), 3, "合格"), + ("D", Decimal("0"), Decimal("59"), 4, "待改进"), +] TEST_PASSWORD = "TestPwd@2026" # 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希 @@ -69,6 +92,44 @@ def _purge_test_data() -> None: session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) session.execute(delete(Role).where(Role.id.in_(role_ids))) + # ---- M2 业务数据(T11):业务/发货/回款三类单据 ---- + # 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前 + # 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货 + # 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键, + # 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住 + # 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT) + business_ids = list( + session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars() + ) + shipment_ids = list( + session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars() + ) + if business_ids: + session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids))) + if shipment_ids: + session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids))) + session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids))) + if business_ids: + session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids))) + session.execute( + delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids)) + ) + session.execute( + delete(OwnershipChangeLog).where( + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + OwnershipChangeLog.business_id.in_(business_ids), + ) + ) + session.execute(delete(Business).where(Business.id.in_(business_ids))) + + # 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景, + # 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全) + session.execute(delete(CustomerRatingGrade)) + session.add_all( + CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm) + for n, lo, hi, so, rm in _RATING_SEEDS + ) + # ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ---- # 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清, # 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品 diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py new file mode 100644 index 0000000..6c5b423 --- /dev/null +++ b/backend/tests/integration/test_businesses.py @@ -0,0 +1,811 @@ +"""业务管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是业务单的五处汇总冗余一致性与状态机/归属留痕 —— 明细/发货/回款任何 +写路径漏算汇总,业务行的 total_* 就会与源数据漂移,M3 起付判定直接读冗余 +列,漂移就是算错钱;状态留痕的「变更前状态」若取到赋值后的值(old==new +失真)或归属变更拆成「主表已换、留痕缺失」,事后追溯无据;编辑端点若能 +改归属,就绕过留痕产生「改了归属却无留痕」的旁路。毛利润视图为只读派生 +值,断言锁定金标准口径(销售额 0 防除零、成本缺失行不进类别汇总)。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;业务/ +发货/回款编号系统生成(YW/SH/HK 前缀),conftest 按编号前缀全量清; +删除语义的用例自验 DELETE 端点。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from app.core.constants import ( + ACTION_CREATE, + BUSINESS_ROLE_SALESPERSON, + MODULE_BUSINESS, + PRODUCT_CATEGORY_MUNICIPAL_WATER, + PRODUCT_CATEGORY_RAIL_TRANSIT, +) +from app.core.db import get_session_factory +from app.models import ( + Business, + BusinessItem, + BusinessRole, + OperationLog, + OwnershipChangeLog, + ParamChangeBatch, + ParamValue, +) +from app.models.business import BUSINESS_STATUS_ACTIVE +from app.models.customer import OWNER_TYPE_BUSINESS + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" +_PARAMS = "/api/v1/system/params" + + +def _company() -> str: + return f"t_公司_{uuid.uuid4().hex[:8]}" + + +def _personnel(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。""" + factory = get_session_factory() + with factory() as session: + role_id = int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON, + BusinessRole.status == 1, + ) + ).scalar_one() + ) + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}", + "business_role_id": role_id, + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _customer(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀测试客户(业务的必选引用)。""" + resp = client.post( + _CUSTOMERS, + json={ + "company_name": _company(), + "owner_name": "张老板", + "customer_type": "personal", + "address": "本溪市平山区", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _item(**overrides: object) -> dict: + """业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000,成本 30)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "30.00", + } + body.update(overrides) + return body + + +def _payload(customer_id: int, **overrides: object) -> dict: + body: dict = { + "customer_id": customer_id, + "business_date": str(date.today()), + "items": [_item()], + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, customer_id: int, **overrides: object) -> dict: + resp = client.post(_BUSINESSES, json=_payload(customer_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +@pytest.fixture +def threshold_batch(client: TestClient, admin: dict): + """提交「立即生效」的产品阈值参数变更批次,用例结束整批清理。 + + 走参数接口的真实路径(system params 要求管理员);清理按 batch_id 删 + 条目与批次头(test_param_management.track_batches 同款)。 + """ + created: list[int] = [] + + def _factory(dimension_value: str, value: str) -> dict: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": "t_毛利润阈值变更", + "items": [ + { + "param_code": "product.threshold", + "dimension_value": dimension_value, + "value": value, + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + batch = resp.json()["data"] + created.append(batch["id"]) + return batch + + yield _factory + + factory = get_session_factory() + with factory() as session: + if created: + session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created))) + session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created))) + session.commit() + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问业务列表_401(self, client: TestClient) -> None: + resp = client.get(_BUSINESSES) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端 + 传值,列表检索与 M3 取数都会错乱。""" + + def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + first = _create(client, headers, customer["id"]) + second = _create(client, headers, customer["id"]) + + # 编号格式:YW{YYYYMMDD}-{4位当日序号}(日期取当日,序号连续 +1) + expected_prefix = f"YW{date.today():%Y%m%d}-" + for no in (first["business_no"], second["business_no"]): + assert re.fullmatch(r"YW\d{8}-\d{4}", no), no + assert no.startswith(expected_prefix), (no, expected_prefix) + assert int(second["business_no"].rsplit("-", 1)[1]) == ( + int(first["business_no"].rsplit("-", 1)[1]) + 1 + ), (first["business_no"], second["business_no"]) + + # 回包契约:默认执行中、客户名直出 + assert first["status"] == BUSINESS_STATUS_ACTIVE + assert first["customer_name"] == customer["company_name"] + + def test_空明细_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + + def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(quantity="0")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "数量" in resp.json()["message"], resp.text + + def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None: + """类别是折算阈值的行级锚点:脏类别取不到阈值参数。""" + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(product_category="water")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "产品类别不合法" in resp.json()["message"], resp.text + + def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], items=[_item(sale_price="abc")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "须为有效数字" in resp.json()["message"], resp.text + + def test_销售额由服务层固化_入参sale_amount不生效( + self, client: TestClient, normal: dict + ) -> None: + """schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由 + 数量×单价计算,否则前端传值直接改业务销售额。""" + headers = normal["headers"] + customer = _customer(client, headers) + item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00") + data = _create(client, headers, customer["id"], items=[item]) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02") + assert Decimal(detail["total_sale_amount"]) == Decimal("37.02") + + def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], is_special=1), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "特殊业务必须填写说明" in resp.json()["message"], resp.text + + def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算") + assert data["is_special"] == 1 + assert data["special_note"] == "公司参与结算" + + +class TestEdit: + """编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 / + 数据库 SUM)不一致就说明重算有旁路。""" + + def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[ + _item(quantity="100", sale_price="100.00", cost_amount="30.00"), + _item(quantity="50", sale_price="200.00", cost_amount="40.00"), + ], + ) + before = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(before["total_sale_amount"]) == Decimal("20000") + assert Decimal(before["total_cost_amount"]) == Decimal("70") + + updated = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + items=[_item(quantity="200", sale_price="50.00", cost_amount="30.00")], + ), + headers=headers, + ) + assert updated.status_code == 200, updated.text + assert Decimal(updated.json()["data"]["total_sale_amount"]) == Decimal("10000") + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留" + item_sum_sale = sum(Decimal(i["sale_amount"]) for i in detail["items"]) + item_sum_cost = sum(Decimal(i["cost_amount"]) for i in detail["items"] if i["cost_amount"]) + assert item_sum_sale == Decimal(detail["total_sale_amount"]) + assert item_sum_cost == Decimal(detail["total_cost_amount"]) + + # 数据库侧再核一次 SUM(接口回包来自内存计算,两处同源才可靠) + factory = get_session_factory() + with factory() as session: + db_business = session.get(Business, data["id"]) + db_sale = ( + session.execute( + select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"]) + ) + .scalars() + .all() + ) + assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00") + + def test_编辑端点塞owner不生效_归属唯一走ownership( + self, client: TestClient, normal: dict + ) -> None: + """归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。""" + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + someone = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(customer["id"], owner_personnel_id=someone["id"]), + headers=headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["owner_personnel_id"] == owner["id"], resp.text + + logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[ + "data" + ] + assert logs["total"] == 0, "编辑端点改归属却无留痕" + + +class TestStatus: + """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" + + def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + # 终止必填原因(schema 层校验,400) + no_reason = client.put( + f"{_BUSINESSES}/{data['id']}/status", json={"status": 3}, headers=headers + ) + assert no_reason.status_code == 400, no_reason.text + assert "终止业务必须填写原因" in no_reason.json()["message"], no_reason.text + + done = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 3, "reason": "客户取消订单"}, + headers=headers, + ) + assert done.status_code == 200, done.text + assert done.json()["data"]["status"] == 3 + + # 终态不可再变更 + again = client.put( + f"{_BUSINESSES}/{data['id']}/status", + json={"status": 2, "reason": "强行翻转"}, + headers=headers, + ) + assert again.status_code == 400, again.text + assert "不可再变更状态" in again.json()["message"], again.text + + logs = client.get(f"{_BUSINESSES}/{data['id']}/status-logs", headers=headers).json()["data"] + assert logs["total"] == 2, logs["total"] + rows = logs["list"] # 正序:创建首条在前 + assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1) + assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3) + assert rows[1]["change_reason"] == "客户取消订单" + assert rows[1]["operator_name"] == normal["username"] + + +class TestOwnership: + """归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。""" + + def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + old_owner = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"]) + + new_owner = _personnel(client, headers) + new_assistant = _personnel(client, headers) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={ + "new_owner_personnel_id": new_owner["id"], + "new_assistant_personnel_id": new_assistant["id"], + "reason": "人员调动", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert body["owner_personnel_id"] == new_owner["id"] + assert body["owner_personnel_name"] == new_owner["personnel_name"] + assert body["assistant_personnel_id"] == new_assistant["id"] + + logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[ + "data" + ] + assert logs["total"] == 1, "首次归属变更只产生一条留痕" + row = logs["list"][0] + assert row["old_owner_personnel_id"] == old_owner["id"] + assert row["new_owner_personnel_id"] == new_owner["id"] + assert row["old_assistant_personnel_id"] is None + assert row["new_assistant_personnel_id"] == new_assistant["id"] + assert row["old_owner_name"] == old_owner["personnel_name"] + assert row["new_owner_name"] == new_owner["personnel_name"] + assert row["change_reason"] == "人员调动" + assert row["operator_name"] == normal["username"] + + # owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属) + factory = get_session_factory() + with factory() as session: + log = session.execute( + select(OwnershipChangeLog).where(OwnershipChangeLog.business_id == data["id"]) + ).scalar_one() + assert log.owner_type == OWNER_TYPE_BUSINESS, ( + f"owner_type 应为业务归属(2):{log.owner_type}" + ) + + def test_归属未变化_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"]) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": owner["id"], "reason": "无变化"}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "归属未变化" in resp.json()["message"], resp.text + + def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + quit_owner = _personnel(client, headers) + off = client.put( + f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers + ) + assert off.status_code == 200, off.text + customer = _customer(client, headers) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], owner_personnel_id=quit_owner["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "负责人员不存在或已离职" in resp.json()["message"], resp.text + + def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + resp = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": 999999999, "reason": "填错人"}, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "负责人员不存在或已离职" in resp.json()["message"], resp.text + + +class TestCustomerProtect: + """客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据 + 语义断裂 —— 服务层检查是唯一防线。""" + + def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + other = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + # 有发货记录:禁换 + ship = client.post( + "/api/v1/business/shipments", + json={ + "business_id": data["id"], + "ship_date": str(date.today()), + "items": [_item(product_name="t_货", quantity="1", sale_price="10.00")], + }, + headers=headers, + ) + assert ship.status_code == 200, ship.text + blocked_ship = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(other["id"]), + headers=headers, + ) + assert blocked_ship.status_code == 400, blocked_ship.text + assert "已有发货或回款记录" in blocked_ship.json()["message"], blocked_ship.text + + # 解除发货后仍有回款:禁换(同一路径 has_docs) + client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers) + receipt = client.post( + "/api/v1/business/receipts", + json={ + "business_id": data["id"], + "receipt_date": str(date.today()), + "amount": "100.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + blocked_receipt = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload(other["id"]), + headers=headers, + ) + assert blocked_receipt.status_code == 400, blocked_receipt.text + assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text + + def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + + ship = client.post( + "/api/v1/business/shipments", + json={ + "business_id": data["id"], + "ship_date": str(date.today()), + "items": [_item(product_name="t_货", quantity="1", sale_price="10.00")], + }, + headers=headers, + ) + assert ship.status_code == 200, ship.text + blocked_ship = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert blocked_ship.status_code == 400, blocked_ship.text + assert "请改为已终止而非删除" in blocked_ship.json()["message"], blocked_ship.text + + # 解除发货后仍有回款:禁删(文案同一路径) + client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers) + receipt = client.post( + "/api/v1/business/receipts", + json={ + "business_id": data["id"], + "receipt_date": str(date.today()), + "amount": "100.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + blocked_receipt = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert blocked_receipt.status_code == 400, blocked_receipt.text + assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text + + def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + owner = _personnel(client, headers) + new_owner = _personnel(client, headers) + data = _create( + client, + headers, + customer["id"], + owner_personnel_id=owner["id"], + items=[_item(quantity="1", sale_price="5.00")], + ) + # 先造一次归属留痕,删除时须连带清理(无外键固化表) + moved = client.put( + f"{_BUSINESSES}/{data['id']}/ownership", + json={"new_owner_personnel_id": new_owner["id"], "reason": "调动"}, + headers=headers, + ) + assert moved.status_code == 200, moved.text + + deleted = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers) + assert deleted.status_code == 200, deleted.text + assert client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).status_code == 404 + + factory = get_session_factory() + with factory() as session: + assert session.get(Business, data["id"]) is None + assert ( + session.execute(select(BusinessItem).where(BusinessItem.business_id == data["id"])) + .scalars() + .first() + is None + ) + # 状态留痕(business_status_logs)与归属留痕(owner_type=2)均无 + # 外键固化,删除时必须显式清 + assert ( + session.execute( + select(OwnershipChangeLog).where( + OwnershipChangeLog.business_id == data["id"], + OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS, + ) + ) + .scalars() + .first() + is None + ) + + +class TestGrossProfit: + """毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、 + 折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。""" + + def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None: + """销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% → + 折算 10000 ×(997/1000 − 30/100) = 6970。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + assert view["as_of"] == str(date.today()) + + row = view["rows"][0] + assert Decimal(row["sale_amount"]) == Decimal("10000") + assert Decimal(row["cost_amount"]) == Decimal("30") + assert Decimal(row["profit_amount"]) == Decimal("9970") + assert row["profit_rate"] == "997/1000", "率须精确分数串(约分后),不得截断" + assert row["threshold"] == "30" + assert Decimal(row["converted_profit"]) == Decimal("6970") + assert row["missing_reason"] is None + assert view["missing_count"] == 0 + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"] + assert Decimal(cats[0]["sale_amount"]) == Decimal("10000") + assert Decimal(cats[0]["profit_amount"]) == Decimal("9970") + assert cats[0]["profit_rate"] == "997/1000" + assert Decimal(cats[0]["converted_profit"]) == Decimal("6970") + + def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None: + """单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常, + 否则录入期(补价前)打开毛利润视图就是 500。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="5", sale_price=None, cost_amount="0.00")], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + row = view["rows"][0] + assert Decimal(row["sale_amount"]) == Decimal("0") + assert Decimal(row["profit_amount"]) == Decimal("0") + assert row["profit_rate"] == "0" + assert Decimal(row["converted_profit"]) == Decimal("0") + assert view["missing_count"] == 0 + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"] + assert Decimal(cats[0]["sale_amount"]) == Decimal("0") + assert Decimal(cats[0]["converted_profit"]) == Decimal("0") + + def test_毛利润_成本缺失行_派生为空不计入类别汇总( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[ + _item( + product_name="t_产品_市政", + quantity="1", + sale_price="5000.00", + cost_amount="100.00", + ), + _item( + product_name="t_产品_轨交", + product_category=PRODUCT_CATEGORY_RAIL_TRANSIT, + quantity="2", + sale_price=None, + cost_amount=None, + ), + ], + ) + resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers) + assert resp.status_code == 200, resp.text + view = resp.json()["data"] + assert view["missing_count"] == 1 + + rows = view["rows"] + assert rows[1]["cost_amount"] is None + assert rows[1]["profit_amount"] is None + assert rows[1]["profit_rate"] is None + assert rows[1]["converted_profit"] is None + assert "成本缺失" in (rows[1]["missing_reason"] or ""), rows[1] + assert rows[1]["threshold"] == "20" + # 可计算行正常派生 + assert Decimal(rows[0]["profit_amount"]) == Decimal("4900") + assert rows[0]["profit_rate"] == "49/50" + + cats = view["categories"] + assert [c["product_category"] for c in cats] == ["municipal_water"], "缺失行不得进类别汇总" + assert Decimal(cats[0]["sale_amount"]) == Decimal("5000") + assert Decimal(cats[0]["cost_amount"]) == Decimal("100") + assert Decimal(cats[0]["profit_amount"]) == Decimal("4900") + assert cats[0]["profit_rate"] == "49/50" + assert Decimal(cats[0]["converted_profit"]) == Decimal("3400") + + def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值( + self, client: TestClient, normal: dict, threshold_batch + ) -> None: + """改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」 + 靠 asOf 回溯参数快照,M3 台账才是法定固化点。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create( + client, + headers, + customer["id"], + items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")], + ) + threshold_batch("municipal_water", "25") # 立即生效(今日) + + today = client.get( + f"{_BUSINESSES}/{data['id']}/gross-profit", + params={"as_of": str(date.today())}, + headers=headers, + ) + assert today.status_code == 200, today.text + row_today = today.json()["data"]["rows"][0] + assert row_today["threshold"] == "25" + # 25%:10000 ×(997/1000 − 25/100) = 7470 + assert Decimal(row_today["converted_profit"]) == Decimal("7470") + + past = client.get( + f"{_BUSINESSES}/{data['id']}/gross-profit", + params={"as_of": "2026-02-17"}, + headers=headers, + ) + assert past.status_code == 200, past.text + row_past = past.json()["data"]["rows"][0] + assert row_past["threshold"] == "30", "旧业务日期须回溯变更前的阈值" + assert Decimal(row_past["converted_profit"]) == Decimal("6970") + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _customer(client, headers) + _create(client, headers, customer["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE + ] + assert logs, "新增业务未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "创建业务" in latest.summary + + def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None: + customer = _customer(client, normal["headers"]) + resp = client.post( + _BUSINESSES, + json=_payload(customer["id"], owner_personnel_id=999999999), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_BUSINESS and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "负责人员不存在或已离职" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_ratings.py b/backend/tests/integration/test_ratings.py new file mode 100644 index 0000000..2f598c1 --- /dev/null +++ b/backend/tests/integration/test_ratings.py @@ -0,0 +1,294 @@ +"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。 + +守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分 +命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验; +② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠 +(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住; +「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。 + +评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest +兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用), +因此种子断言用例(列表全量返回)始终在纯种子上运行。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest +恢复,删除语义的用例自验 DELETE 端点。 +""" + +import uuid +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_RATING +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_GRADES = "/api/v1/business/rating-grades" + +# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1)) +_SEED = [ + ("A", "90.0", "100.0", 1), + ("B", "75.0", "89.0", 2), + ("C", "60.0", "74.0", 3), + ("D", "0.0", "59.0", 4), +] + + +def _tname() -> str: + return f"t_等级_{uuid.uuid4().hex[:6]}" + + +def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict: + body: dict = { + "grade_name": grade_name, + "min_score": min_score, + "max_score": max_score, + "sort_order": 99, + "remark": "t_评级测试", + } + body.update(overrides) + return body + + +def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int: + """从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。""" + rows = client.get(_GRADES, headers=headers).json()["data"] + match = next(row for row in rows if row["grade_name"] == grade_name) + return match["id"] + + +def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None: + """删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。 + + 种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级 + 完全分离的空隙,否则重叠约束(设计如此)一律 400。 + """ + resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers) + assert resp.status_code == 200, resp.text + + +def _names(client: TestClient, headers: dict) -> list[str]: + return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]] + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问评级等级列表_401(self, client: TestClient) -> None: + resp = client.get(_GRADES) + assert resp.status_code == 401, resp.text + + +class TestList: + """列表直出种子基线:判定表是前端评级计算器的唯一数据源。""" + + def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None: + resp = client.get(_GRADES, headers=normal["headers"]) + assert resp.status_code == 200, resp.text + rows = resp.json()["data"] + assert [(r["grade_name"], r["sort_order"]) for r in rows] == [ + (name, order) for name, _, _, order in _SEED + ], rows + # 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float) + for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True): + assert Decimal(row["min_score"]) == Decimal(min_score) + assert Decimal(row["max_score"]) == Decimal(max_score) + + +class TestCreate: + """新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束 + 任一缺失都会算错等级。""" + + def test_删除种子后新增相邻区间_成功且判定表更新( + self, client: TestClient, normal: dict + ) -> None: + """种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74), + 新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。""" + headers = normal["headers"] + client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) + resp = client.post( + _GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers + ) + assert resp.status_code == 200, resp.text + body = resp.json()["data"] + assert Decimal(body["min_score"]) == Decimal("60") + assert Decimal(body["max_score"]) == Decimal("74") + assert body["sort_order"] == 5 + + rows = client.get(_GRADES, headers=headers).json()["data"] + assert _names(client, headers) == ["A", "B", "D", "t_E"], rows + + def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_分值超上限_400(self, client: TestClient, normal: dict) -> None: + resp = client.post( + _GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_分值负下限_400(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间不合法" in resp.json()["message"], resp.text + + def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None: + """先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞 + 「与等级 D 重叠」而走不到名称校验。""" + headers = normal["headers"] + _drop_seed(client, headers, "D") + resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers) + assert resp.status_code == 400, resp.text + assert "该等级名称已存在" in resp.json()["message"], resp.text + + def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + """85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。""" + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text + # 失败不落库:判定表仍是 4 条种子 + assert _names(client, normal["headers"]) == ["A", "B", "C", "D"] + + +class TestEdit: + """编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与 + 新增路共用重叠约束,两处都要拦。""" + + def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None: + """把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。""" + headers = normal["headers"] + b_id = _seed_id(client, headers, "B") + resp = client.put( + f"{_GRADES}/{b_id}", + json=_gpayload("B", "60", "65", sort_order=2), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text + # 失败不落库:种子保持原样 + assert _names(client, headers) == ["A", "B", "C", "D"] + + def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None: + """删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠** + (74 与 75 相邻),这是种子铺法的正例。""" + headers = normal["headers"] + client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers) + d_id = _seed_id(client, headers, "D") + resp = client.put( + f"{_GRADES}/{d_id}", + json=_gpayload("D", "0", "74", sort_order=4), + headers=headers, + ) + assert resp.status_code == 200, resp.text + + rows = client.get(_GRADES, headers=headers).json()["data"] + assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)] + assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2] + + def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + created = client.post( + _GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers + ) + assert created.status_code == 200, created.text + grade_id = created.json()["data"]["id"] + + new_name = _tname() + updated = client.put( + f"{_GRADES}/{grade_id}", + json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"), + headers=headers, + ) + assert updated.status_code == 200, updated.text + body = updated.json()["data"] + assert body["grade_name"] == new_name + assert Decimal(body["min_score"]) == Decimal("15") + assert Decimal(body["max_score"]) == Decimal("25") + assert body["sort_order"] == 60 + + rows = client.get(_GRADES, headers=headers).json()["data"] + match = next(r for r in rows if r["id"] == grade_id) + assert match["grade_name"] == new_name + assert Decimal(match["min_score"]) == Decimal("15") + + +class TestDelete: + """删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 + 立即反映,二次删除 404。""" + + def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + created = client.post( + _GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers + ) + assert created.status_code == 200, created.text + grade_id = created.json()["data"]["id"] + assert grade_name in _names(client, headers) + + assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200 + assert grade_name not in _names(client, headers) + + again = client.delete(f"{_GRADES}/{grade_id}", headers=headers) + assert again.status_code == 404, again.text + assert "评级等级不存在" in again.json()["message"], again.text + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + _drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚 + grade_name = _tname() + resp = client.post( + _GRADES, + json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), + headers=headers, + ) + assert resp.status_code == 200, resp.text + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RATING and log.action == ACTION_CREATE + ] + assert logs, "新增评级等级未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert grade_name in latest.summary + + def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None: + resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"]) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RATING and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_receipts.py b/backend/tests/integration/test_receipts.py new file mode 100644 index 0000000..5d6e47e --- /dev/null +++ b/backend/tests/integration/test_receipts.py @@ -0,0 +1,422 @@ +"""回款管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让 +回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规 +2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移; +③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。 +payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次 +编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走 +直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_RECEIPT +from app.core.db import get_session_factory +from app.models import OperationLog, Receipt +from app.models.receipt import SETTLEMENT_DONE + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_CUSTOMERS = "/api/v1/base/customers" + + +def _customer(client: TestClient, headers: dict) -> dict: + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _bitem(**overrides: object) -> dict: + """业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + } + body.update(overrides) + return body + + +def _bpayload(customer_id: int, **overrides: object) -> dict: + body: dict = { + "customer_id": customer_id, + "business_date": str(date.today()), + "items": [_bitem()], + } + body.update(overrides) + return body + + +def _create_business(client: TestClient, headers: dict) -> dict: + """建一名 t_ 客户并挂一笔默认业务(回款/发货单挂在业务上)。""" + customer = _customer(client, headers) + resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _sitem(**overrides: object) -> dict: + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "quantity": "1", + "sale_price": "500.00", + } + body.update(overrides) + return body + + +def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "ship_date": ship_date, + "items": [_sitem()], + } + body.update(overrides) + return body + + +def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict: + resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _rpayload(business_id: int, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "receipt_date": str(date.today()), + "amount": "1200.00", + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict: + resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _settle(receipt_id: int) -> None: + """已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位, + 不绕开「M2 不置位」的实现约定。""" + factory = get_session_factory() + with factory() as session: + row = session.get(Receipt, receipt_id) + assert row is not None, "回款行不存在" + row.settlement_status = SETTLEMENT_DONE + session.commit() + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问回款列表_401(self, client: TestClient) -> None: + resp = client.get(_RECEIPTS) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。""" + + def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + + # 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日) + expected_prefix = f"HK{date.today():%Y%m%d}-" + assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"] + assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"] + + # 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生 + assert data["shipment_id"] is None + assert data["shipment_no"] is None + assert data["payment_days"] is None + # M2 只写 0:置 1 由 M3 核算负责 + assert data["settlement_status"] == 0 + assert Decimal(data["amount"]) == Decimal("1200") + + def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None: + """发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天, + 需规「26 天」示例已作废)。""" + headers = normal["headers"] + business = _create_business(client, headers) + shipment = _create_shipment(client, headers, business["id"], "2026-10-05") + data = _create( + client, + headers, + business["id"], + shipment_id=shipment["id"], + receipt_date="2026-11-02", + amount="500.00", + ) + assert data["shipment_id"] == shipment["id"] + assert data["shipment_no"] == shipment["shipment_no"] + assert data["payment_days"] == 29, data + + def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_received_amount"]) == Decimal("1200") + + assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200 + rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(rollback["total_received_amount"]) == Decimal("0") + + def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: + resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"]) + assert resp.status_code == 404, resp.text + assert "业务不存在" in resp.json()["message"], resp.text + + def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text + + def test_金额负数_400(self, client: TestClient, normal: dict) -> None: + """一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + assert "不能为负数" in resp.json()["message"], resp.text + + def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None: + """小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _RECEIPTS, + json=_rpayload(business["id"], amount="12.345"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "最多两位小数" in resp.json()["message"], resp.text + + +class TestCrossBusiness: + """发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应 + 差异探测发货单存在性。""" + + def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + + resp = client.post( + _RECEIPTS, + json=_rpayload(business2["id"], shipment_id=shipment["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text + + def test_编辑时指定跨业务发货批次_400(self, client: TestClient, normal: dict) -> None: + """编辑路径(全量替换)同样强校验,无「未变即免检」旁路。""" + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + data = _create(client, headers, business2["id"]) + + resp = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": shipment["id"], + "receipt_date": str(date.today()), + "amount": "1200.00", + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "发货批次不属于该业务,无法指定" in resp.json()["message"], resp.text + + def test_编辑回款_全量替换_派生值实时刷新(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + shipment = _create_shipment(client, headers, business["id"], "2026-10-05") + data = _create(client, headers, business["id"]) + assert data["payment_days"] is None + + specified = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": shipment["id"], + "receipt_date": "2026-11-02", + "amount": "300.00", + }, + headers=headers, + ) + assert specified.status_code == 200, specified.text + body = specified.json()["data"] + assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生" + assert body["shipment_no"] == shipment["shipment_no"] + + cleared = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": None, + "receipt_date": "2026-11-02", + "amount": "300.00", + }, + headers=headers, + ) + assert cleared.status_code == 200, cleared.text + assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null" + + +class TestSettled: + """已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。""" + + def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _settle(data["id"]) + + resp = client.put( + f"{_RECEIPTS}/{data['id']}", + json={ + "shipment_id": None, + "receipt_date": str(date.today()), + "amount": "9999.00", + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "已核算回款禁止修改" in resp.json()["message"], resp.text + + def test_已核算禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _settle(data["id"]) + + resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers) + assert resp.status_code == 400, resp.text + assert "已核算回款禁止删除" in resp.json()["message"], resp.text + + +class TestFilter: + """核算状态筛选(M3 核算消费的入口口径)。""" + + def test_settlement_status筛选_0只回未核算_1只回已核算( + self, client: TestClient, normal: dict + ) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + pending = _create(client, headers, business["id"], amount="500.00") + settled = _create(client, headers, business["id"], amount="700.00") + _settle(settled["id"]) + + resp0 = client.get( + _RECEIPTS, + params={"business_id": business["id"], "settlement_status": 0}, + headers=headers, + ) + assert resp0.status_code == 200, resp0.text + rows0 = resp0.json()["data"]["list"] + assert [r["id"] for r in rows0] == [pending["id"]], rows0 + assert rows0[0]["settlement_status"] == 0 + + resp1 = client.get( + _RECEIPTS, + params={"business_id": business["id"], "settlement_status": 1}, + headers=headers, + ) + assert resp1.status_code == 200, resp1.text + rows1 = resp1.json()["data"]["list"] + assert [r["id"] for r in rows1] == [settled["id"]], rows1 + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE + ] + assert logs, "新增回款未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "新增回款" in latest.summary + assert data["receipt_batch_no"] in latest.summary + + def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business1 = _create_business(client, headers) + shipment = _create_shipment(client, headers, business1["id"], str(date.today())) + business2 = _create_business(client, headers) + resp = client.post( + _RECEIPTS, + json=_rpayload(business2["id"], shipment_id=shipment["id"]), + headers=headers, + ) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_RECEIPT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0] diff --git a/backend/tests/integration/test_shipments.py b/backend/tests/integration/test_shipments.py new file mode 100644 index 0000000..d9db666 --- /dev/null +++ b/backend/tests/integration/test_shipments.py @@ -0,0 +1,394 @@ +"""发货管理接口集成测试(需要 MySQL 与 Redis)。 + +守的是发货单的行金额固化与双重汇总回写 —— 行金额(amount)与发货单总额 +(total_amount)若经前端传值,业务级 total_ship_amount 与运费合计就是可 +被篡改的源数据,M3 核算直接读业务冗余列;终态业务发货单禁写缺失会让核算 +基线漂移;被回款批次指定的发货单若能直接删,指定它的回款就指向悬空批次, +回款天数无从派生。 + +运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` + +数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;发货单 +编号系统生成(SH 前缀),conftest 按编号前缀全量清;删除语义的用例自验 +DELETE 端点。 +""" + +import re +import uuid +from datetime import date +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT +from app.core.db import get_session_factory +from app.models import OperationLog + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_PERSONNEL = "/api/v1/base/personnel" +_CUSTOMERS = "/api/v1/base/customers" + + +def _customer(client: TestClient, headers: dict) -> dict: + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _bitem(**overrides: object) -> dict: + """业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + } + body.update(overrides) + return body + + +def _bpayload(customer_id: int, **overrides: object) -> dict: + body: dict = { + "customer_id": customer_id, + "business_date": str(date.today()), + "items": [_bitem()], + } + body.update(overrides) + return body + + +def _create_business(client: TestClient, headers: dict) -> dict: + """建一名 t_ 客户并挂一笔默认业务(业务须先存在,发货单挂在业务上)。""" + customer = _customer(client, headers) + resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _item(**overrides: object) -> dict: + """发货明细行入参(默认:3 × 300.00 = 900)。""" + body: dict = { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "quantity": "3", + "sale_price": "300.00", + } + body.update(overrides) + return body + + +def _spayload(business_id: int, **overrides: object) -> dict: + body: dict = { + "business_id": business_id, + "ship_date": str(date.today()), + "items": [_item()], + } + body.update(overrides) + return body + + +def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict: + resp = client.post(_SHIPMENTS, json=_spayload(business_id, **overrides), headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _terminate(client: TestClient, headers: dict, business_id: int) -> None: + """把业务流转到已终止(终态禁写场景的造数步骤)。""" + resp = client.put( + f"{_BUSINESSES}/{business_id}/status", + json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"}, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _logs_of(username: str) -> list[OperationLog]: + """取某操作人的审计行(按 id 倒序,最新在前)。""" + factory = get_session_factory() + with factory() as session: + return list( + session.execute( + select(OperationLog) + .where(OperationLog.username == username) + .order_by(OperationLog.id.desc()) + ).scalars() + ) + + +class TestAuth: + """鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。""" + + def test_未登录访问发货单列表_401(self, client: TestClient) -> None: + resp = client.get(_SHIPMENTS) + assert resp.status_code == 401, resp.text + + +class TestCreate: + """创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总 + 是 M3 起付判定的直接消费源,错写就是算错钱。""" + + def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="88.50", + items=[ + _item(quantity="3", sale_price="300.00", amount="999.99"), + _item(quantity="2", sale_price="250.00"), + ], + ) + + # 编号格式:SH{YYYYMMDD}-{4位当日序号}(日期取当日) + expected_prefix = f"SH{date.today():%Y%m%d}-" + assert re.fullmatch(r"SH\d{8}-\d{4}", data["shipment_no"]), data["shipment_no"] + assert data["shipment_no"].startswith(expected_prefix), data["shipment_no"] + + # 行金额由服务层按 数量×单价 固化:schema 无 amount 入参,塞了也忽略 + detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"] + amounts = [Decimal(i["amount"]) for i in detail["items"]] + assert amounts == [Decimal("900"), Decimal("500")], detail["items"] + assert Decimal(detail["total_amount"]) == Decimal("1400") + assert Decimal(detail["freight_amount"]) == Decimal("88.50") + + # 业务级汇总回写(发货金额 / 运费合计) + business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[ + "data" + ] + assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400") + assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50") + + def test_业务不存在_404(self, client: TestClient, normal: dict) -> None: + resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"]) + assert resp.status_code == 404, resp.text + assert "业务不存在" in resp.json()["message"], resp.text + + def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text + + def test_明细为空_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"] + ) + assert resp.status_code == 400, resp.text + + def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], items=[_item(quantity="0")]), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "数量" in resp.json()["message"], resp.text + + def test_运费负数_400(self, client: TestClient, normal: dict) -> None: + """运费是金额源数据:负值会让业务运费合计与报表直接错乱。""" + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], freight_amount="-8.00"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "不能为负数" in resp.json()["message"], resp.text + + def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None: + business = _create_business(client, normal["headers"]) + resp = client.post( + _SHIPMENTS, + json=_spayload(business["id"], freight_amount="8.333"), + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + assert "最多两位小数" in resp.json()["message"], resp.text + + +class TestEdit: + """编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。""" + + def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="50.00", + items=[_item(quantity="1", sale_price="1000.00")], + ) + before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(before["total_ship_amount"]) == Decimal("1000") + + updated = client.put( + f"{_SHIPMENTS}/{data['id']}", + json={ + "ship_date": str(date.today()), + "freight_amount": "12.00", + "items": [_item(quantity="2", sale_price="150.00")], + }, + headers=headers, + ) + assert updated.status_code == 200, updated.text + assert Decimal(updated.json()["data"]["total_amount"]) == Decimal("300") + + # 业务级两处汇总随编辑同步 + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_ship_amount"]) == Decimal("300") + assert Decimal(after["total_freight_amount"]) == Decimal("12") + + detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"] + assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留" + assert Decimal(detail["items"][0]["amount"]) == Decimal("300") + + +class TestDelete: + """删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。""" + + def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="20.00", + items=[_item(quantity="1", sale_price="500.00")], + ) + receipt = client.post( + _RECEIPTS, + json={ + "business_id": business["id"], + "shipment_id": data["id"], + "receipt_date": str(date.today()), + "amount": "500.00", + }, + headers=headers, + ) + assert receipt.status_code == 200, receipt.text + + blocked = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "已被回款批次指定" in blocked.json()["message"], blocked.text + + # 解除回款的批次指定(未核算回款可删)后发货单可删,业务级汇总回退 + assert ( + client.delete( + f"{_RECEIPTS}/{receipt.json()['data']['id']}", headers=headers + ).status_code + == 200 + ) + freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert freed.status_code == 200, freed.text + business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[ + "data" + ] + assert Decimal(business_detail["total_ship_amount"]) == Decimal("0") + assert Decimal(business_detail["total_freight_amount"]) == Decimal("0") + + def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create( + client, + headers, + business["id"], + freight_amount="100.00", + items=[_item(quantity="2", sale_price="1000.00")], + ) + before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(before["total_ship_amount"]) == Decimal("2000") + + freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert freed.status_code == 200, freed.text + assert client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).status_code == 404 + after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"] + assert Decimal(after["total_ship_amount"]) == Decimal("0") + assert Decimal(after["total_freight_amount"]) == Decimal("0") + + +class TestTerminal: + """终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算 + 的源数据,终态后不得变动。""" + + def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _terminate(client, headers, business["id"]) + + resp = client.put( + f"{_SHIPMENTS}/{data['id']}", + json={ + "ship_date": str(date.today()), + "items": [_item(quantity="1", sale_price="1.00")], + }, + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "禁止编辑发货单" in resp.json()["message"], resp.text + + def test_终态业务发货单禁删_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + _terminate(client, headers, business["id"]) + + resp = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers) + assert resp.status_code == 400, resp.text + assert "禁止删除发货单" in resp.json()["message"], resp.text + + +class TestAudit: + """写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。""" + + def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + data = _create(client, headers, business["id"]) + logs = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE + ] + assert logs, "新增发货单未留操作日志" + latest = logs[0] + assert latest.success == 1 + assert "新增发货单" in latest.summary + assert data["shipment_no"] in latest.summary + + def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + business = _create_business(client, headers) + _terminate(client, headers, business["id"]) + resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers) + assert resp.status_code == 400, resp.text + failed = [ + log + for log in _logs_of(normal["username"]) + if log.module == MODULE_SHIPMENT and log.success == 0 + ] + assert failed, "失败操作未留痕" + assert "禁止新发货" in (failed[0].error_message or ""), failed[0] -- 2.27.0 From c512cb77fb498a6b43042f5248c6c5a61c4007b0 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 13:43:17 +0800 Subject: [PATCH 036/100] =?UTF-8?q?docs:=20=E5=90=8E=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B8=85=E5=8D=95=E8=A1=A5=E5=9F=BA=E7=A1=80/?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=95=B0=E6=8D=AE=E4=B8=A4=E8=8A=82=E4=B8=8E?= =?UTF-8?q?=20M2=20=E5=85=B3=E9=94=AE=E7=BA=A6=E5=AE=9A=EF=BC=9B=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20e2e=20=E6=B8=85=E5=8D=95=E9=A2=84=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ frontend/README.md | 9 +++++++++ 2 files changed, 51 insertions(+) diff --git a/backend/README.md b/backend/README.md index d17d2db..c876b2f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -71,6 +71,41 @@ app/ | GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) | | GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) | +### 基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/base/personnel` | 人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) | +| GET / PUT / DELETE | `/api/v1/base/personnel/{id}` | 详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) | +| PUT | `/api/v1/base/personnel/{id}/status` | 在职 / 离职切换 | +| PUT | `/api/v1/base/personnel/{id}/regions` | 辐射区域全量替换(空列表即清空) | +| PUT | `/api/v1/base/personnel/{id}/plans` | 市场拓展计划全量替换 | +| GET / POST | `/api/v1/base/products` | 产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) | +| GET / PUT / DELETE | `/api/v1/base/products/{id}` | 详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) | +| PUT | `/api/v1/base/products/{id}/status` | 启用 / 停用 | +| GET / POST | `/api/v1/base/customers` | 客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) | +| GET / PUT / DELETE | `/api/v1/base/customers/{id}` | 详情 / 编辑(**不触归属**)/ 删除 | +| PUT | `/api/v1/base/customers/{id}/ownership` | 归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) | +| GET | `/api/v1/base/customers/{id}/ownership-logs` | 归属变更留痕分页(join 人员出姓名) | + +### 业务数据接口(需求 2.2,登录即可) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET / POST | `/api/v1/business/businesses` | 业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 `YW{日期}-{序号}` 自动生成;明细 ≥1 行;销售额服务层固化) | +| GET / PUT / DELETE | `/api/v1/business/businesses/{id}` | 详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;**归属不在此端点**)/ 删除(有发货或回款 → 400 引导改已终止) | +| PUT | `/api/v1/business/businesses/{id}/status` | 状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) | +| PUT | `/api/v1/business/businesses/{id}/ownership` | 负责/协助归属变更(同事务写留痕,owner_type=2) | +| GET | `/api/v1/business/businesses/{id}/ownership-logs` | 业务归属变更留痕分页 | +| GET | `/api/v1/business/businesses/{id}/status-logs` | 状态流转时间线(创建写首条 old=new=1) | +| GET | `/api/v1/business/businesses/{id}/gross-profit` | **毛利润数据视图**(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 `as_of` 取参数快照;成本缺失行标注无法计算) | +| GET / POST | `/api/v1/business/shipments` | 发货单列表 / 创建(编号 `SH…`;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) | +| GET / PUT / DELETE | `/api/v1/business/shipments/{id}` | 详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) | +| GET / POST | `/api/v1/business/receipts` | 回款列表(含 **payment_days 派生**:发货日首日=第 1 天 #1)/ 新增(编号 `HK…`;**shipment_id 同业务强校验**;业务须执行中) | +| GET / PUT / DELETE | `/api/v1/business/receipts/{id}` | 详情 / 编辑 / 删除(**已核算回款禁改禁删**——M3 防重锚点) | +| GET / POST | `/api/v1/business/rating-grades` | 评级等级全量列表(sort_order 升序)/ 新增(区间值域 + **与既有等级重叠 → 400**) | +| PUT / DELETE | `/api/v1/business/rating-grades/{id}` | 编辑(重叠判定)/ 删除(无下游引用,允许清空) | + 全部写操作自动写入操作日志(成功与失败都记,机制见下)。 统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。 @@ -154,6 +189,13 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 **角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。 +### M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读) + +- **单据编号**(`services/no_generator.py`):`{前缀}{YYYYMMDD}-{当日4位序号}`,业务 YW / 发货 SH / 回款 HK(`core/constants.py` 的 `NO_PREFIX_*`)。`SELECT MAX ... FOR UPDATE` 锁当日区间防并发重号;残余竞态以唯一键 1062 冲突**重试一次**兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。 +- **五项汇总冗余**(`businesses.total_*`):明细/发货/回款的任何写路径统一收口到 `business_service._recalc_totals` 单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前**必须 `get_by_id(for_update=True)` 锁业务行**(防两模块并发写同一业务的丢失更新)。毛利润率/折算额**不落库**——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(`/gross-profit`)实时算,M3 核算台账才是法定固化点。 +- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 +- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 + ## 常见问题 **起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。 diff --git a/frontend/README.md b/frontend/README.md index f4943aa..162f2e6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -148,3 +148,12 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py | `e2e/param_management.py` | 计提参数 7 步:目录分组 → 修改入待提交 → 批次提交(立即生效)→ 目录反映新值 → 版本记录 → 恢复种子值 → 计算预览金标准(27 天 / 9.7‰ / 145.5 元) | ✓ 全通过(首跑) | 运行前置:容器 + 后端(8000)+ 前端 dev(5173);`E2E_ADMIN_PASSWORD` 传管理员密码。 + +## 端到端测试(2026-10-13 追加 · M2 基础数据与业务数据) + +| 脚本 | 覆盖 | 结果 | +| --- | --- | --- | +| `e2e/base_data.py` | 基础数据链路:菜单两组 → 人员建(经理/业务员指派)→ 编辑抽屉加辐射区域回显 → 删除保护(400 引导改离职)→ 产品建 + 重名 400 → 客户建 → 归属变更(确认文案含「不回溯历史」)→ 留痕抽屉可见 | 待运行 | +| `e2e/business_data.py` | 业务全链路:基础数据 → 业务建(双类别明细)→ 编辑汇总重算 → 详情抽屉(时间线/毛利润卡片)→ 归属调整 → 发货(SH 编号)→ 回款级联指定批次 → 回款天数展示 → 未指定批次文案 → 状态流转已完成 → 终态禁发货 → 评级计算器出 A 级 | 待运行 | + +运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。 -- 2.27.0 From 299b72483c7b59775041528945f1b2616b35d855 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 14:17:28 +0800 Subject: [PATCH 037/100] =?UTF-8?q?test(business):=20M2=20=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E6=95=B0=E6=8D=AE=E4=B8=8E=E4=B8=9A=E5=8A=A1=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=A4=E4=B8=AA=20e2e=20=E9=93=BE=E8=B7=AF=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=8813+16=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- frontend/e2e/base_data.py | 567 +++++++++++++++++++++++++++++++ frontend/e2e/business_data.py | 612 ++++++++++++++++++++++++++++++++++ 2 files changed, 1179 insertions(+) create mode 100644 frontend/e2e/base_data.py create mode 100644 frontend/e2e/business_data.py diff --git a/frontend/e2e/base_data.py b/frontend/e2e/base_data.py new file mode 100644 index 0000000..81a43d4 --- /dev/null +++ b/frontend/e2e/base_data.py @@ -0,0 +1,567 @@ +"""基础数据端到端验证:人员 / 产品 / 客户。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 侧边菜单「基础数据」分组展开与三页导航 +- 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域 + 编辑抽屉行内添加保存并重开回显 +- 人员删除保护:被团队成员引用 → 400 引导「改为离职」,改为离职成功 +- 产品:新增(轨道交通类别)、同名称同规格重复 → 400 +- 客户:新增(企业户 + 初始归属业务员)、归属调整二次确认文案含 + 「不回溯历史」(对照表 #19 业务口径)、变更留痕展示旧 → 新归属 +- 控制台无 JS 错误(4xx 请求的网络资源日志属预期,见 step_console_assertion) + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/base_data.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;被引用保护拦截的清理项 +打印留档说明而非失败(删除保护本身是预期行为)。公共工具见 `e2e/common.py`。 +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(__file__)) + +from playwright.sync_api import Page, sync_playwright + +from common import ( + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + toast_text, + wait_toast, +) + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ADMIN = "admin" + +SUFFIX = str(int(time.time())) +MANAGER_NAME = f"e2e_经理_{SUFFIX}" +SALESMAN_NAME = f"e2e_业务员_{SUFFIX}" +MANAGER_PHONE = "13800000001" +MANAGER_SALARY = "5000" +SALESMAN_SALARY = "4000" +PRODUCT_NAME = f"e2e_产品_{SUFFIX}" +PRODUCT_SPEC = f"e2e_规格_{SUFFIX}" +CUSTOMER_COMPANY = f"e2e_企业_{SUFFIX}" +CUSTOMER_OWNER = "e2e_客户负责人" +REGION_NAME = "沈阳市铁西区" + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + + +# ==================== 页面操作辅助 ==================== + + +def open_menu(page: Page, title: str, group: str) -> None: + """点击侧边菜单项,必要时先展开指定分组。 + + 分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。 + """ + menu = page.locator(".app-menu") + item = menu.locator(".el-menu-item", has_text=title).first + if not item.is_visible(): + menu.locator(".el-sub-menu__title", has_text=group).click() + page.wait_for_timeout(400) + item.click() + page.wait_for_timeout(800) + + +def fill_filter(page: Page, label: str, value: str) -> None: + """在**当前可见**的筛选表单中按标签填写输入框。 + + 选择器一律带 `:visible` —— 同页存在多个表单实例(隐藏未销毁)时, + 不带可见性过滤会命中隐藏元素。 + """ + form_item = page.locator( + f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ).first + form_item.locator("input").first.fill(value) + + +def select_filter_option(page: Page, label: str, option: str) -> None: + """展开可见筛选表单中指定标签的下拉并选择选项(发货/回款页按业务筛选)。""" + item = page.locator( + f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))' + ) + item.locator(".el-select").click() + page.wait_for_timeout(300) + page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click() + page.wait_for_timeout(200) + + +def click_query(page: Page) -> None: + """点击可见筛选表单中的查询按钮。""" + page.locator(".filter-form:visible button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + +def table_rows(page: Page): + """当前可见的表格行(选择器带 :visible,避开隐藏实例)。""" + return page.locator(".el-table__body .el-table__row:visible") + + +def visible_dialog(page: Page): + """当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。""" + return page.locator(".el-dialog:visible").last + + +def confirm_message_box(page: Page, button_text: str) -> None: + """点击确认框(ElMessageBox)中的指定按钮。""" + page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click() + page.wait_for_timeout(800) + + +def confirm_text(page: Page) -> str: + """当前确认框的完整文本(业务口径文案断言用),轮询等待出现。""" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if page.locator(".el-message-box:visible").count(): + break + page.wait_for_timeout(200) + return page.locator(".el-message-box:visible").last.inner_text() + + +def fill_form_item(page: Page, scope, label: str, value: str) -> None: + """在表单(dialog / drawer)范围内按 form-item 标签填写输入框。 + + 标签用 `:text-is` 精确匹配(子串会让「姓名」命中「真实姓名」一类歧义); + 只用于真实 el-input 的表单项(el-select 的表单项走 select_form_option)。 + """ + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator("input").first.fill(value) + + +def fill_date(page: Page, scope, label: str, value: str) -> None: + """el-date-picker(type=date)按 form-item 标签填日期:点输入框 → 键入 → 回车。""" + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator("input").first.click() + page.keyboard.type(value) + page.keyboard.press("Enter") + page.wait_for_timeout(300) + + +def select_form_option(page: Page, scope, label: str, option: str) -> None: + """在表单范围内展开指定标签的下拉并选择选项。 + + 下拉面板 teleport 到 body,展开与选项均按 `:visible` 过滤;选项渲染 + (filterable / 页面挂载时拉取的数据源)可能滞后,轮询等待再点击。 + """ + item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))') + item.locator(".el-select").first.click() + option_item = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=option + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if option_item.count(): + break + page.wait_for_timeout(200) + if option_item.count() == 0: + fail("下拉选择", f"下拉「{label}」中未出现选项「{option}」") + option_item.first.click() + page.wait_for_timeout(200) + + +def refresh_page_data(page: Page) -> None: + """刷新当前页面(保持路径):重新挂载页面并重拉挂载时的一次性数据源。""" + page.reload(wait_until="networkidle") + page.wait_for_timeout(800) + + +def find_row(page: Page, filter_label: str, keyword: str): + """按筛选条件查询并返回含 keyword 的第一行(找不到时 count() 为 0)。""" + fill_filter(page, filter_label, keyword) + click_query(page) + return table_rows(page).filter(has_text=keyword).first + + +def delete_row(page: Page, row, success_toast: str) -> None: + """删除行:点「删除」→ 确认「删除」→ 断言成功提示(各模块成功文案不同)。""" + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, success_toast, "数据清理") + page.wait_for_timeout(600) + + +# ==================== 基础数据创建(本脚本与 business_data 复用) ==================== + + +def create_manager_via_ui(page: Page, name: str) -> None: + """导航到人员管理并经界面新增营销经理 → 列表岗位回显。""" + open_menu(page, "人员管理", "基础数据") + if current_path(page) != "/base/personnel": + fail("菜单导航", f"未进入人员管理页,当前 {page.url}") + + page.click("button:has-text('新增人员')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "姓名", name) + select_form_option(page, dialog, "岗位", "营销经理") + fill_form_item(page, dialog, "联系电话", MANAGER_PHONE) + fill_form_item(page, dialog, "工资标准", MANAGER_SALARY) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "人员已创建", "新增人员") + + row = find_row(page, "姓名", name) + if row.count() == 0: + fail("新增人员", f"列表中未找到新建的 {name}") + if "营销经理" not in row.inner_text(): + fail("新增人员", "列表岗位列未回显「营销经理」") + page.wait_for_timeout(400) + + +def create_salesman_via_ui(page: Page, name: str, manager_name: str) -> None: + """在人员管理页新增营销员并挂直属经理(先刷新页面重拉经理下拉)。 + + 经理下拉数据源只在页面挂载时拉取一次 —— 同一页面连续「新建经理再 + 新建业务员」时下拉不含新经理,必须先刷新页面。 + """ + open_menu(page, "人员管理", "基础数据") + refresh_page_data(page) + if current_path(page) != "/base/personnel": + fail("菜单导航", f"刷新后未回到人员管理页,当前 {page.url}") + + page.click("button:has-text('新增人员')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "姓名", name) + select_form_option(page, dialog, "岗位", "营销员") + select_form_option(page, dialog, "直属经理", manager_name) + fill_form_item(page, dialog, "工资标准", SALESMAN_SALARY) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "人员已创建", "新增业务员") + + row = find_row(page, "姓名", name) + if row.count() == 0: + fail("新增业务员", f"列表中未找到新建的 {name}") + if manager_name not in row.inner_text(): + fail("新增业务员", "列表「直属经理」列未回显经理姓名") + page.wait_for_timeout(400) + + +def create_product_via_ui(page: Page, product_name: str, spec: str) -> None: + """导航到产品管理并经界面新增产品(类别=轨道交通)→ 列表回显。""" + open_menu(page, "产品管理", "基础数据") + if current_path(page) != "/base/products": + fail("菜单导航", f"未进入产品管理页,当前 {page.url}") + + page.click("button:has-text('新增产品')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "产品名称", product_name) + fill_form_item(page, dialog, "规格型号", spec) + fill_form_item(page, dialog, "数量", "100") + fill_form_item(page, dialog, "单价(元)", "3000") + select_form_option(page, dialog, "产品类别", "轨道交通") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "产品已创建", "新增产品") + + row = find_row(page, "关键词", product_name) + if row.count() == 0: + fail("新增产品", f"列表中未找到新建的 {product_name}") + if "轨道交通" not in row.inner_text(): + fail("新增产品", "列表类别列未回显「轨道交通」") + page.wait_for_timeout(400) + + +def create_customer_via_ui(page: Page, company: str, owner: str, initial_owner: str) -> None: + """导航到客户管理并经界面新增客户(企业户 + 初始归属)→ 列表回显。""" + open_menu(page, "客户管理", "基础数据") + if current_path(page) != "/base/customers": + fail("菜单导航", f"未进入客户管理页,当前 {page.url}") + + page.click("button:has-text('新增客户')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "公司名称", company) + fill_form_item(page, dialog, "负责人", owner) + fill_form_item(page, dialog, "行业", "轨道交通") + select_form_option(page, dialog, "客户分类", "企业户") + select_form_option(page, dialog, "初始归属", initial_owner) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "客户已创建", "新增客户") + + row = find_row(page, "关键词", company) + if row.count() == 0: + fail("新增客户", f"列表中未找到新建的 {company}") + if initial_owner not in row.inner_text(): + fail("新增客户", "列表「归属人员」列未回显初始归属") + if "企业户" not in row.inner_text(): + fail("新增客户", "列表分类列未回显「企业户」") + page.wait_for_timeout(400) + + +# ==================== 流程步骤 ==================== + + +def step_open_group_and_personnel(page: Page, admin_password: str) -> None: + """登录、「基础数据」分组出现且含三页,点开「人员管理」。""" + login(page, ADMIN, admin_password) + menu = page.locator(".app-menu") + if menu.locator(".el-sub-menu__title", has_text="基础数据").count() == 0: + fail("菜单分组", "侧边菜单未出现「基础数据」分组") + for title in ("人员管理", "产品管理", "客户管理"): + if menu.locator(".el-menu-item", has_text=title).count() == 0: + fail("菜单分组", f"「基础数据」分组内未找到「{title}」") + + open_menu(page, "人员管理", "基础数据") + if current_path(page) != "/base/personnel": + fail("菜单导航", f"未进入人员管理页,当前 {page.url}") + print("✓ 1. 菜单导航 「基础数据」分组出现、三页可点开,已进入人员管理") + + +def step_person_create_manager(page: Page) -> None: + create_manager_via_ui(page, MANAGER_NAME) + print(f"✓ 2. 新增经理 创建 {MANAGER_NAME}(岗位=营销经理),列表可见") + + +def step_person_create_salesman(page: Page) -> None: + create_salesman_via_ui(page, SALESMAN_NAME, MANAGER_NAME) + print(f"✓ 3. 新增业务员 创建 {SALESMAN_NAME}(岗位=营销员),经理名回显") + + +def step_person_edit_regions(page: Page) -> None: + """编辑抽屉给经理添加一条辐射区域行 → 保存 → 重开回显。""" + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('区域与计划')").click() + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer:visible").last + drawer.locator("button:has-text('添加区域')").click() + page.wait_for_timeout(300) + drawer.locator('input[placeholder="如 沈阳市铁西区"]').last.fill(REGION_NAME) + drawer.locator("button:has-text('保存区域')").click() + expect_toast(page, "辐射区域已保存", "编辑辐射区域") + + # 重开抽屉验证回显(保存走全量替换接口,详情须持久化) + page.keyboard.press("Escape") + page.wait_for_timeout(600) + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('区域与计划')").click() + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer:visible").last + region_input = drawer.locator('input[placeholder="如 沈阳市铁西区"]').first + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if region_input.count() and region_input.input_value() == REGION_NAME: + break + page.wait_for_timeout(200) + if region_input.count() == 0 or region_input.input_value() != REGION_NAME: + fail("辐射区域回显", "重新打开抽屉未回显已保存的区域名称") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 4. 辐射区域 编辑抽屉添加区域保存,重开回显正确") + + +def step_person_delete_protection(page: Page) -> None: + """删除被引用的经理被 400 拦截并引导「改为离职」,改为离职成功。""" + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + # 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验 + confirm_message_box(page, "删除") + expect_toast(page, "请改为离职", "人员删除保护") + + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('设为离职')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "设为离职") + expect_toast(page, "已设为离职", "人员离职") + + row = find_row(page, "姓名", MANAGER_NAME) + if "离职" not in row.inner_text(): + fail("人员离职", "列表状态未更新为离职") + print("✓ 5. 删除保护 删除被引人员被 400 拦截,改为离职成功") + + +def step_products(page: Page) -> None: + """新增产品(轨道交通)与同名称同规格重复负例。""" + create_product_via_ui(page, PRODUCT_NAME, PRODUCT_SPEC) + print(f"✓ 6. 新增产品 创建 {PRODUCT_NAME}(类别=轨道交通),列表可见") + + # 同名称同规格重复 → 400(唯一键 = 名称 + 规格) + page.click("button:has-text('新增产品')") + page.wait_for_timeout(500) + dialog = visible_dialog(page) + fill_form_item(page, dialog, "产品名称", PRODUCT_NAME) + fill_form_item(page, dialog, "规格型号", PRODUCT_SPEC) + select_form_option(page, dialog, "产品类别", "轨道交通") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + if "已存在同名称同规格的产品" not in wait_toast(page, "已存在"): + fail("产品重复负例", f"重复产品未提示已存在,当前提示:{toast_text(page) or '(无)'}") + visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 7. 产品重复负例 同名称同规格被 400 拒绝并给出中文文案") + + +def step_customers(page: Page) -> None: + create_customer_via_ui(page, CUSTOMER_COMPANY, CUSTOMER_OWNER, SALESMAN_NAME) + print(f"✓ 8. 新增客户 创建 {CUSTOMER_COMPANY}(企业户,初始归属=业务员)") + + +def step_ownership_change(page: Page) -> None: + """归属调整到经理:二次确认含「不回溯历史」,留痕展示旧 → 新。""" + row = find_row(page, "关键词", CUSTOMER_COMPANY) + row.locator("button:has-text('归属变更')").click() + page.wait_for_timeout(500) + dialog = visible_dialog(page) + if SALESMAN_NAME not in dialog.inner_text(): + fail("归属变更", "变更弹窗未回显当前归属业务员") + select_form_option(page, dialog, "新归属人员", MANAGER_NAME) + dialog.locator("button:has-text('提交变更')").click() + page.wait_for_timeout(500) + + # 业务口径(对照表 #19):二次确认文案必须含「不回溯历史」 + box = confirm_text(page) + if "不回溯历史" not in box: + fail("归属变更确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}") + confirm_message_box(page, "确认变更") + expect_toast(page, "归属已调整", "归属调整") + + row = find_row(page, "关键词", CUSTOMER_COMPANY) + if row.count() == 0: + fail("归属调整", "按关键词查询未找到客户行") + if MANAGER_NAME not in row.inner_text(): + fail("归属调整", "列表「归属人员」列未更新为经理") + print("✓ 9. 归属调整 二次确认含「不回溯历史」,归属已改到经理") + + row.locator("button:has-text('变更记录')").click() + page.wait_for_timeout(800) + drawer = page.locator(".el-drawer:visible").last + transition = f"{SALESMAN_NAME} → {MANAGER_NAME}" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".el-table__row", has_text=transition).count(): + break + page.wait_for_timeout(200) + if drawer.locator(".el-table__row", has_text=transition).count() == 0: + fail("归属留痕", f"变更记录抽屉未看到「{transition}」的留痕行") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 10. 归属留痕 留痕抽屉展示旧 → 新归属一条记录") + + +def step_cleanup(page: Page) -> None: + """清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。 + + 经理在删除保护步骤已改为离职;仍被引用拦截时打印留档说明(属预期 + 保护行为,不做假绿)。 + """ + open_menu(page, "客户管理", "基础数据") + row = find_row(page, "关键词", CUSTOMER_COMPANY) + if row.count() > 0: + delete_row(page, row, "客户已删除") + + open_menu(page, "产品管理", "基础数据") + row = find_row(page, "关键词", PRODUCT_NAME) + if row.count() > 0: + delete_row(page, row, "产品已删除") + + open_menu(page, "人员管理", "基础数据") + row = find_row(page, "姓名", SALESMAN_NAME) + if row.count() > 0: + delete_row(page, row, "人员已删除") + + row = find_row(page, "姓名", MANAGER_NAME) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "人员已删除" in text: + pass + elif "请改为离职" in text or "存在团队成员" in text: + print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留离职态经理") + else: + fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}") + print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理") + + +def step_console_assertion() -> None: + """断言无 JS 控制台错误。 + + 口径:4xx 请求触发的浏览器网络层日志(Failed to load resource)在 + 负例步骤属预期,不算 JS 错误;只拦脚本错误(console.error / pageerror)。 + 网络资源日志仍收集在 console_errors 供失败诊断。 + """ + real_errors = [e for e in console_errors if "Failed to load resource" not in e] + if real_errors: + fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}") + print("✓ 13. 控制台检查 无 JS 控制台错误") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/base_data.py", + ) + + step_open_group_and_personnel(page, admin_password) + step_person_create_manager(page) + step_person_create_salesman(page) + step_person_edit_regions(page) + step_person_delete_protection(page) + step_products(page) + step_customers(page) + step_ownership_change(page) + step_cleanup(page) + step_console_assertion() + + if console_errors: + print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 860}) + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/business_data.py b/frontend/e2e/business_data.py new file mode 100644 index 0000000..7757bbf --- /dev/null +++ b/frontend/e2e/business_data.py @@ -0,0 +1,612 @@ +"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 基础数据准备(复用 `e2e/base_data.py` 建数):经理 / 业务员(挂经理)/ + 产品(轨道交通)/ 客户(企业户,归属=业务员) +- 业务:创建(两行明细:市政+轨交,数量/单价/成本)→ YW 业务编号与销售额 + 显示;编辑明细数量 → 汇总重算(销售额变化) +- 详情抽屉:产品明细回显、状态时间线「业务创建」首条、毛利润类别汇总卡片 + 与「不含运费」口径注释 +- 归属调整(业务员 → 经理):二次确认含「不回溯历史」(对照表 #19)、 + 详情留痕表可见旧 → 新 +- 发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号与发货总额 +- 回款管理:选业务后级联加载其发货单(断言下拉出现刚建发货单号)→ 指定 + 批次 → 回款天数派生显示「6 天」(发货日计第 1 天);第二笔不指定批次 → + 显示「未指定批次」 +- 状态机:执行中 → 已完成(详情时间线新增记录)→ 终态业务新发货被 400 + 拦截(「禁止新发货」) +- 客户评级计算器:五项各 20 分 → 总分 100 → 等级 A;等级映射抽屉 A/B/C/D +- 控制台无 JS 错误;清理按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序 + +**运行前置**(缺一不可):: + + docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis + cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端 + cd frontend && pnpm dev # 前端 + +管理员密码经环境变量传入(不写死在源码里):: + + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/business_data.py + +脚本自建的测试数据(`e2e_` 前缀)在流程内清理;删除保护拦截属预期行为, +被拦截时打印留档说明。页面辅助与基础数据建数复用 `e2e/base_data.py`。 +""" + +import os +import re +import sys +import time + +sys.path.insert(0, os.path.dirname(__file__)) + +from playwright.sync_api import Page, sync_playwright + +from common import ( + E2EFailure, + collect_console_errors, + current_path, + expect_toast, + fail, + login, + wait_toast, +) + +import base_data as base + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ADMIN = "admin" + +# 明细两行:市政 10×5000(成本 3000)+ 轨交 5×3000(成本 2000) +ITEM_NAME_MUNICIPAL = f"e2e_市政产品_{base.SUFFIX}" +BUSINESS_DATE = "2026-01-05" +SHIP_DATE = "2026-01-10" +RECEIPT_DATE = "2026-01-15" +INIT_SALE_AMOUNT = "65000" # 10×5000 + 5×3000 +EDITED_SALE_AMOUNT = "115000" # 编辑后 20×5000 + 5×3000 +SHIP_FREIGHT = "100" +SHIP_ITEM_QUANTITY = "6" +SHIP_ITEM_PRICE = "4800" +SHIP_TOTAL = "28800" # 6×4800 +RECEIPT_AMOUNT_1 = "20000" +RECEIPT_AMOUNT_2 = "5000" +RECEIPT_DAYS = "6 天" # 回款日相对发货日 2026-01-10(发货日计第 1 天) + +# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取 +console_errors: list[str] = [] + +# 运行时提取的单据编号(新增业务/发货单后从列表文本提取,级联与清理用) +business_no = "" +shipment_no = "" + + +# ==================== 流程步骤 ==================== + + +def step_setup_base(page: Page, admin_password: str) -> None: + """登录并复用基础数据建数:经理 / 业务员(挂经理)/ 产品 / 客户(归属=业务员)。""" + login(page, ADMIN, admin_password) + base.create_manager_via_ui(page, base.MANAGER_NAME) + print(f"✓ 1. 建经理 {base.MANAGER_NAME}(岗位=营销经理)") + + base.create_salesman_via_ui(page, base.SALESMAN_NAME, base.MANAGER_NAME) + print(f"✓ 2. 建业务员 {base.SALESMAN_NAME}(岗位=营销员,挂经理)") + + base.create_product_via_ui(page, base.PRODUCT_NAME, base.PRODUCT_SPEC) + print(f"✓ 3. 建产品 {base.PRODUCT_NAME}(类别=轨道交通)") + + base.create_customer_via_ui( + page, base.CUSTOMER_COMPANY, base.CUSTOMER_OWNER, base.SALESMAN_NAME + ) + print(f"✓ 4. 建客户 {base.CUSTOMER_COMPANY}(企业户,归属=业务员)") + + +def fill_item_row(row, name: str, quantity: str, price: str, cost: str) -> None: + """按 placeholder 填写业务明细行:名称 / 数量 / 销售单价 / 成本额。 + + 「元,选填」的 placeholder 在单价/成本/指导价三列重复,按列序 nth 取 + (单价在前、成本居次),指导价不填。 + """ + row.locator('input[placeholder="产品名称,可手填"]').fill(name) + row.locator('input[placeholder=">0"]').fill(quantity) + row.locator('input[placeholder="元,选填"]').nth(0).fill(price) + row.locator('input[placeholder="元,选填"]').nth(1).fill(cost) + + +def select_row_option(page: Page, row, option: str) -> None: + """点开表格行内的下拉(明细行「类别」列,非 form-item 结构)并选择选项。""" + row.locator(".el-select").first.click() + option_item = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=option + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if option_item.count(): + break + page.wait_for_timeout(200) + if option_item.count() == 0: + fail("下拉选择", f"行内下拉未出现选项「{option}」") + option_item.first.click() + page.wait_for_timeout(200) + + +def step_business_create(page: Page) -> None: + """创建业务:选客户、两行明细(市政+轨交)、负责人员=业务员。""" + global business_no + base.open_menu(page, "业务管理", "业务数据") + if current_path(page) != "/business/businesses": + fail("菜单导航", f"未进入业务管理页,当前 {page.url}") + + page.click("button:has-text('新增业务')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "客户", base.CUSTOMER_COMPANY) + base.fill_date(page, dialog, "业务日期", BUSINESS_DATE) + base.select_form_option(page, dialog, "负责人员", base.SALESMAN_NAME) + + item_rows = dialog.locator(".items-table .el-table__row") + fill_item_row(item_rows.nth(0), ITEM_NAME_MUNICIPAL, "10", "5000", "3000") + + dialog.locator("button:has-text('添加一行')").click() + page.wait_for_timeout(300) + item_rows = dialog.locator(".items-table .el-table__row") + select_row_option(page, item_rows.nth(1), "轨道交通") + fill_item_row(item_rows.nth(1), base.PRODUCT_NAME, "5", "3000", "2000") + + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "业务已创建", "新增业务") + + row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY) + if row.count() == 0: + fail("新增业务", f"列表中未找到新建业务(客户 {base.CUSTOMER_COMPANY})") + text = row.inner_text() + no_match = re.search(r"YW\d{8}-\d+", text) + if not no_match: + fail("新增业务", f"列表未显示 YW 前缀业务编号:{text[:120]}") + business_no = no_match.group(0) + if INIT_SALE_AMOUNT not in text: + fail("新增业务", f"列表销售额未显示 {INIT_SALE_AMOUNT}:{text[:160]}") + print(f"✓ 5. 新增业务 两行明细创建成功,业务编号 {business_no},销售额 {INIT_SALE_AMOUNT}") + + +def step_business_edit(page: Page) -> None: + """编辑业务:第一行数量 10 → 20 → 保存 → 列表销售额汇总重算。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('编辑')").click() + # openEdit 异步拉详情后全量替换明细草稿(此时预置草稿仍显示旧值), + # 固定等待让「回显替换」先完成,避免填到将被丢弃的草稿上 + page.wait_for_timeout(1500) + dialog = base.visible_dialog(page) + item_rows = dialog.locator(".items-table .el-table__row") + if item_rows.count() < 2: + fail("编辑业务", "编辑弹窗未回显两行明细") + item_rows.nth(0).locator('input[placeholder=">0"]').fill("20") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "已保存", "编辑业务") + + row = base.find_row(page, "关键词", business_no) + if EDITED_SALE_AMOUNT not in row.inner_text(): + fail("编辑汇总重算", f"保存后销售额未更新为 {EDITED_SALE_AMOUNT}:{row.inner_text()[:160]}") + print(f"✓ 6. 编辑重算 明细数量 10→20,销售额 {INIT_SALE_AMOUNT} → {EDITED_SALE_AMOUNT}") + + +def step_business_detail(page: Page) -> None: + """详情抽屉:明细表回显、时间线「业务创建」首条、毛利润类别汇总与口径注释。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".detail-desc").count(): + break + page.wait_for_timeout(200) + + detail_text = drawer.inner_text() + if ITEM_NAME_MUNICIPAL not in detail_text or base.PRODUCT_NAME not in detail_text: + fail("业务详情", "详情抽屉产品明细未回显两行产品") + timeline = drawer.locator(".status-timeline .el-timeline-item") + if timeline.count() == 0: + fail("业务详情", "状态时间线为空") + if "业务创建" not in (timeline.first.inner_text() or ""): + fail("业务详情", "时间线首条不是「业务创建」") + if ( + drawer.locator(".gp-card", has_text="市政水利").count() == 0 + or drawer.locator(".gp-card", has_text="轨道交通").count() == 0 + ): + fail("毛利润视图", "类别汇总卡片未展示市政水利/轨道交通") + note = drawer.locator(".gp-note").inner_text() + if "不含运费" not in note: + fail("毛利润注释", f"口径注释缺少「不含运费」:{note[:120]}") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 7. 详情抽屉 明细回显、时间线「业务创建」首条、毛利润汇总与口径注释") + + +def step_business_ownership(page: Page) -> None: + """归属调整(业务员 → 经理):确认含「不回溯历史」→ 详情留痕表可见。""" + row = base.find_row(page, "关键词", business_no) + row.locator("button:has-text('归属')").click() + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + if business_no not in dialog.inner_text(): + fail("归属调整", "归属调整弹窗未回显业务编号") + base.select_form_option(page, dialog, "新负责人员", base.MANAGER_NAME) + dialog.locator("button:has-text('提交调整')").click() + page.wait_for_timeout(500) + + box = base.confirm_text(page) + if "不回溯历史" not in box: + fail("归属调整确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}") + base.confirm_message_box(page, "确认调整") + expect_toast(page, "归属已调整", "业务归属调整") + + row = base.find_row(page, "关键词", business_no) + if row.count() == 0: + fail("业务归属调整", "按关键词查询未找到业务行") + if base.MANAGER_NAME not in row.inner_text(): + fail("业务归属调整", "列表「负责人员」列未更新为经理") + + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + transition = f"负责 {base.SALESMAN_NAME} → {base.MANAGER_NAME}" + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if drawer.locator(".el-table__row", has_text=transition).count(): + break + page.wait_for_timeout(200) + if drawer.locator(".el-table__row", has_text=transition).count() == 0: + fail("归属留痕", f"详情抽屉归属变更记录未看到「{transition}」") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 8. 归属调整 业务员→经理确认含「不回溯历史」,详情留痕表可见") + + +def step_shipment_create(page: Page) -> None: + """发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号。""" + global shipment_no + base.open_menu(page, "发货管理", "业务数据") + if current_path(page) != "/business/shipments": + fail("菜单导航", f"未进入发货管理页,当前 {page.url}") + + page.click("button:has-text('新增发货单')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + base.fill_date(page, dialog, "发货日期", SHIP_DATE) + base.fill_form_item(page, dialog, "运费(元)", SHIP_FREIGHT) + item_row = dialog.locator(".items-table .el-table__row").first + item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME) + item_row.locator('input[placeholder=">0"]').fill(SHIP_ITEM_QUANTITY) + item_row.locator('input[placeholder="可空=待人工补价"]').fill(SHIP_ITEM_PRICE) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + page.wait_for_timeout(500) + base.confirm_message_box(page, "确认创建") + expect_toast(page, "发货单已创建", "新增发货单") + + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + ship_row = base.table_rows(page).filter(has_text=business_no).first + if ship_row.count() == 0: + fail("新增发货单", "发货列表未显示该业务的发货单") + ship_text = ship_row.inner_text() + no_match = re.search(r"SH\d{8}-\d+", ship_text) + if not no_match: + fail("新增发货单", f"发货单号未显示 SH 前缀:{ship_text[:120]}") + shipment_no = no_match.group(0) + if SHIP_TOTAL not in ship_text: + fail("新增发货单", f"发货总额未显示 {SHIP_TOTAL}:{ship_text[:160]}") + print(f"✓ 9. 发货单 挂业务创建成功,发货单号 {shipment_no},发货总额 {SHIP_TOTAL}") + + +def step_receipt_with_shipment(page: Page) -> None: + """第一笔回款:选业务 → 级联加载发货单并断言下拉出现刚建单号 → 天数派生。""" + base.open_menu(page, "回款管理", "业务数据") + if current_path(page) != "/business/receipts": + fail("菜单导航", f"未进入回款管理页,当前 {page.url}") + + page.click("button:has-text('新增回款')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + page.wait_for_timeout(600) # 等发货批次级联加载完成 + + # 级联断言:发货批次下拉必须出现刚建的发货单号 + batch_item = dialog.locator('.el-form-item:has(.el-form-item__label:text-is("发货批次"))') + batch_item.locator(".el-select").first.click() + batch_option = page.locator( + ".el-select-dropdown:visible .el-select-dropdown__item", has_text=shipment_no + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if batch_option.count(): + break + page.wait_for_timeout(200) + if batch_option.count() == 0: + fail("回款级联", f"发货批次下拉未级联出刚建的发货单 {shipment_no}") + batch_option.first.click() + page.wait_for_timeout(200) + + base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE) + base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_1) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "回款已登记", "新增回款") + + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + rows_text = " ".join(base.table_rows(page).all_inner_texts()) + if RECEIPT_DAYS not in rows_text: + fail("回款天数派生", f"指定批次后回款天数未显示「{RECEIPT_DAYS}」:{rows_text[:200]}") + if "未指定批次" in rows_text: + fail("回款天数派生", "指定批次后不应显示「未指定批次」") + print(f"✓ 10. 回款第一笔 级联出 {shipment_no} 并指定,回款天数显示「{RECEIPT_DAYS}」") + + +def step_receipt_without_shipment(page: Page) -> None: + """第二笔回款:不指定批次 → 列表显示「未指定批次」,第一笔天数不消失。""" + page.click("button:has-text('新增回款')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + page.wait_for_timeout(600) + base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE) + base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_2) + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + expect_toast(page, "回款已登记", "新增第二笔回款") + + base.click_query(page) + rows_text = " ".join(base.table_rows(page).all_inner_texts()) + if "未指定批次" not in rows_text: + fail("未指定批次", "第二笔回款(未指定批次)未显示「未指定批次」") + if RECEIPT_DAYS not in rows_text: + fail("未指定批次", f"第一笔回款的「{RECEIPT_DAYS}」不应消失") + print("✓ 11. 回款第二笔 不指定批次显示「未指定批次」,第一笔天数保留") + + +def step_status_flow(page: Page) -> None: + """状态机:执行中 → 已完成(详情时间线新增记录)。""" + base.open_menu(page, "业务管理", "业务数据") + row = base.find_row(page, "关键词", business_no) + if "执行中" not in row.inner_text(): + fail("状态流转", f"业务当前状态不是执行中:{row.inner_text()[:120]}") + row.locator("button:has-text('状态')").click() + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + # 目标状态默认「已完成」(openStatus 预设 status=2),直接确认 + dialog.locator("button:has-text('确认变更')").click() + page.wait_for_timeout(500) + + box = base.confirm_text(page) + if "终态不可再变更" not in box: + fail("状态流转确认", f"二次确认文案缺少「终态不可再变更」:{box[:120]}") + base.confirm_message_box(page, "已完成") + expect_toast(page, "状态已变更", "状态流转") + + row = base.find_row(page, "关键词", business_no) + if row.count() == 0: + fail("状态流转", "按关键词查询未找到业务行") + if "已完成" not in row.inner_text(): + fail("状态流转", "列表状态未更新为已完成") + + row.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + timeline = drawer.locator(".status-timeline .el-timeline-item") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if timeline.count() >= 2: + break + page.wait_for_timeout(200) + if timeline.count() < 2: + fail("状态时间线", f"流转后时间线未新增记录({timeline.count()} 条)") + if "已完成" not in (timeline.last.inner_text() or ""): + fail("状态时间线", "时间线末条未显示已完成状态") + if "业务创建" not in (timeline.first.inner_text() or ""): + fail("状态时间线", "时间线首条「业务创建」不应消失") + page.keyboard.press("Escape") + page.wait_for_timeout(600) + print("✓ 12. 状态流转 执行中→已完成,详情时间线新增一条记录") + + +def step_terminal_shipment_blocked(page: Page) -> None: + """终态业务(已完成)再建发货单被 400 拦截(「禁止新发货」)。""" + base.open_menu(page, "发货管理", "业务数据") + page.click("button:has-text('新增发货单')") + page.wait_for_timeout(500) + dialog = base.visible_dialog(page) + base.select_form_option(page, dialog, "业务", business_no) + base.fill_date(page, dialog, "发货日期", SHIP_DATE) + item_row = dialog.locator(".items-table .el-table__row").first + item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME) + item_row.locator('input[placeholder=">0"]').fill("1") + dialog.locator(".el-dialog__footer button:has-text('保存')").click() + page.wait_for_timeout(500) + base.confirm_message_box(page, "确认创建") + expect_toast(page, "禁止新发货", "终态业务发货拦截") + base.visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click() + page.wait_for_timeout(500) + print("✓ 13. 终态拦截 已完成业务新发货被 400 拒绝(禁止新发货)") + + +def step_rating(page: Page) -> None: + """客户评级计算器:五项各 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D。""" + base.open_menu(page, "客户评级计算器", "业务数据") + if current_path(page) != "/business/rating": + fail("菜单导航", f"未进入客户评级计算器页,当前 {page.url}") + + for label in ("发货额", "回款率", "毛利率", "合同履约配合能力", "现场规范施工能力"): + score_row = page.locator(".calc-form .score-row", has_text=label).first + score_row.locator(".el-input-number input").fill("20") + score_row.locator(".el-input-number input").press("Enter") + page.wait_for_timeout(200) + + total = (page.locator(".total-num").first.text_content() or "").strip() + if total != "100": + fail("评级总分", f"五项各 20 分后总分未显示 100:{total or '(无)'}") + grade = (page.locator(".grade-name").first.text_content() or "").strip() + if grade != "A": + fail("评级等级", f"总分 100 未命中等级 A:{grade or '(无)'}") + + page.click("button:has-text('等级映射设置')") + page.wait_for_timeout(600) + drawer = page.locator(".el-drawer").filter(has_text="等级映射设置").last + table_text = drawer.inner_text() + for letter in ("A", "B", "C", "D"): + if letter not in table_text: + fail("等级映射抽屉", f"映射抽屉表格缺少等级 {letter}") + page.keyboard.press("Escape") + page.wait_for_timeout(500) + print("✓ 14. 评级计算器 五项满 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D") + + +def step_cleanup(page: Page) -> None: + """清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。 + + 删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。 + """ + # 回款(未核算可删;删除后业务回款合计自动回退) + base.open_menu(page, "回款管理", "业务数据") + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + while base.table_rows(page).count() > 0: + base.table_rows(page).first.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + expect_toast(page, "回款已删除", "数据清理") + page.wait_for_timeout(600) + base.click_query(page) + + # 发货单(回款已删,无批次指定;删除连带明细并回写业务汇总) + base.open_menu(page, "发货管理", "业务数据") + base.select_filter_option(page, "业务", business_no) + base.click_query(page) + while base.table_rows(page).count() > 0: + base.table_rows(page).first.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "发货单已删除" not in text: + print(" 数据清理 发货单删除被拦(属预期保护),留档说明:发货单保留在开发库") + break + page.wait_for_timeout(600) + base.click_query(page) + + # 业务(发货/回款均已删则可删;仍被拦时留档) + base.open_menu(page, "业务管理", "业务数据") + row = base.find_row(page, "关键词", business_no) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "业务已删除" in text: + print(f" 数据清理 业务 {business_no} 已删除") + else: + print( + f" 数据清理 业务删除被拦(属预期保护),留档说明:业务 {business_no} 保留在开发库" + ) + + # 基础数据:客户 → 产品 → 业务员 → 经理 + base.open_menu(page, "客户管理", "基础数据") + row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY) + if row.count() > 0: + base.delete_row(page, row, "客户已删除") + + base.open_menu(page, "产品管理", "基础数据") + row = base.find_row(page, "关键词", base.PRODUCT_NAME) + if row.count() > 0: + base.delete_row(page, row, "产品已删除") + + base.open_menu(page, "人员管理", "基础数据") + row = base.find_row(page, "姓名", base.SALESMAN_NAME) + if row.count() > 0: + base.delete_row(page, row, "人员已删除") + + row = base.find_row(page, "姓名", base.MANAGER_NAME) + if row.count() > 0: + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "人员已删除" in text: + print(" 数据清理 经理已删除") + elif "请改为离职" in text or "存在团队成员" in text: + print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留经理") + else: + fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}") + print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档") + + +def step_console_assertion() -> None: + """断言无 JS 控制台错误(口径同 base_data.py:只拦脚本错误)。""" + real_errors = [e for e in console_errors if "Failed to load resource" not in e] + if real_errors: + fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}") + print("✓ 16. 控制台检查 无 JS 控制台错误") + + +# ==================== 入口 ==================== + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail( + "环境变量", + "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)," + "示例:E2E_ADMIN_PASSWORD=xxx python e2e/business_data.py", + ) + + step_setup_base(page, admin_password) + step_business_create(page) + step_business_edit(page) + step_business_detail(page) + step_business_ownership(page) + step_shipment_create(page) + step_receipt_with_shipment(page) + step_receipt_without_shipment(page) + step_status_flow(page) + step_terminal_shipment_blocked(page) + step_rating(page) + step_cleanup(page) + step_console_assertion() + + if console_errors: + print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 860}) + page.set_default_timeout(10_000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + if console_errors: + print(f" 控制台错误:{console_errors[-5:]}") + print(" 已保存截图 e2e/failure.png") + return 1 + finally: + browser.close() + + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) -- 2.27.0 From fba8cfc215dcae9dca8b315c74f6897025df2371 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 17:06:46 +0800 Subject: [PATCH 038/100] =?UTF-8?q?fix(business):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E8=A1=A5=E9=BD=90=E4=B8=8E=E5=8D=95=E4=BB=B7?= =?UTF-8?q?=E5=B0=8F=E6=95=B0=E4=BD=8D=E5=8F=A3=E5=BE=84=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88e2e=20=E5=8F=91=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露) - 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500) - schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000' 原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败) - 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」 Co-Authored-By: Claude Code --- .../app/repositories/customer_repository.py | 13 +++++ .../app/repositories/personnel_repository.py | 27 ++++++++++ backend/app/schemas/business.py | 29 +++++++--- backend/app/schemas/shipment.py | 3 +- backend/app/services/customer_service.py | 21 +++++--- backend/app/services/personnel_service.py | 15 ++++-- backend/tests/integration/test_customers.py | 28 ++++++++++ backend/tests/integration/test_personnel.py | 54 ++++++++++++++++++- 8 files changed, 167 insertions(+), 23 deletions(-) diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 0471601..b1d8688 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -23,6 +23,19 @@ def get_by_id(session: Session, customer_id: int) -> Customer | None: return session.get(Customer, customer_id) +def count_business_refs(session: Session, customer_id: int) -> int: + """被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。""" + from app.models import Business + + return int( + session.execute( + select(func.count()) + .select_from(Business) + .where(Business.customer_id == customer_id) + ).scalar_one() + ) + + def get_with_owner_name(session: Session, customer_id: int) -> Row | None: """查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。""" stmt = _base_select().where(Customer.id == customer_id) diff --git a/backend/app/repositories/personnel_repository.py b/backend/app/repositories/personnel_repository.py index 602e127..3f25451 100644 --- a/backend/app/repositories/personnel_repository.py +++ b/backend/app/repositories/personnel_repository.py @@ -94,6 +94,33 @@ def count_customer_ownerships(session: Session, personnel_ids: list[int]) -> dic return {int(owner_id): int(count) for owner_id, count in session.execute(stmt).all()} +def count_business_roles_refs(session: Session, personnel_ids: list[int]) -> dict[int, int]: + """按人员批量统计业务单据归属引用(负责/协助合计;业务表就位后补齐的检查)。 + + businesses.owner/assistant_personnel_id 均为 RESTRICT 外键——漏检会让 + 「删除被业务引用的人员」以 500 暴露而非可读的 400。 + """ + + from app.models import Business + + owner = ( + select(Business.owner_personnel_id, func.count(Business.id)) + .where(Business.owner_personnel_id.in_(personnel_ids)) + .group_by(Business.owner_personnel_id) + ) + assistant = ( + select(Business.assistant_personnel_id, func.count(Business.id)) + .where(Business.assistant_personnel_id.in_(personnel_ids)) + .group_by(Business.assistant_personnel_id) + ) + refs: dict[int, int] = {} + for stmt in (owner, assistant): + for owner_id, count in session.execute(stmt).all(): + if owner_id is not None: + refs[int(owner_id)] = refs.get(int(owner_id), 0) + int(count) + return refs + + def list_regions(session: Session, personnel_id: int) -> list[PersonnelRegion]: """查人员的全部辐射区域,按插入顺序返回。""" stmt = ( diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index cfdf5b8..eabd6b0 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -24,11 +24,18 @@ PRICE_MAX = Decimal("9999999999.9999") def _check_amount( - value: str | None, label: str, cap: Decimal, allow_positive_only: bool = False + value: str | None, + label: str, + cap: Decimal, + allow_positive_only: bool = False, + scale: int = 2, ) -> str | None: - """字符串金额的通用校验:数字/有限/非负/容量/两位小数。 + """字符串金额的通用校验:数字/有限/非负/容量/小数位上限。 - 小数位超过两位直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + 小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。 + ``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列 + DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带 + 精度的历史单价都被编辑端点永远拒收)。 """ if value is None: return None @@ -45,8 +52,8 @@ def _check_amount( raise ValueError(f"{label}不能为负数") if amount > cap: raise ValueError(f"{label}超出可录入上限") - if -amount.as_tuple().exponent > 2: - raise ValueError(f"{label}最多两位小数") + if -amount.as_tuple().exponent > scale: + raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数") return value @@ -88,10 +95,16 @@ class BusinessItemIn(BaseModel): def _check_quantity(cls, value: str) -> str: return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value - @field_validator("sale_price", "cost_amount", "guided_price") + @field_validator("sale_price", "guided_price") @classmethod - def _check_prices(cls, value: str | None) -> str | None: - return _check_amount(value, "金额", PRICE_MAX) + def _check_unit_prices(cls, value: str | None) -> str | None: + """单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) + + @field_validator("cost_amount") + @classmethod + def _check_cost(cls, value: str | None) -> str | None: + return _check_amount(value, "金额", AMOUNT_MAX) class BusinessItemOut(BaseModel): diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py index 9ce2377..2df61d9 100644 --- a/backend/app/schemas/shipment.py +++ b/backend/app/schemas/shipment.py @@ -40,7 +40,8 @@ class ShipmentItemIn(BaseModel): @field_validator("sale_price") @classmethod def _check_price(cls, value: str | None) -> str | None: - return _check_amount(value, "金额", PRICE_MAX) + """发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。""" + return _check_amount(value, "单价", PRICE_MAX, scale=4) class ShipmentItemOut(BaseModel): diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index c4da6e7..1329c7d 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -294,14 +294,14 @@ def delete_customer( operator: User, meta: RequestMeta | None = None, ) -> None: - """删除客户(物理删除)。 + """删除客户(物理删除):被业务引用 → 400 引导处理业务单据。 - **被业务数据引用的保护检查待业务数据表(V5)就位后补充**:customers - 当前仅被 ownership_change_logs 以无外键的固化 ID 引用,而留痕本就要求 - 「被引用方删除后记录仍完整可读」(见 models/customer.py 的固化论证), - 故现在删除不会产生悬挂外键。 + businesses.customer_id 为 RESTRICT 外键,引用检查必须先于删除—— + 否则并发窗口内的 FK 冲突会以 500 暴露(T4 时该检查占位、业务表就位 + 后由本处补齐,留痕表是无外键固化不参与拦截)。 :raises NotFoundError: 客户不存在 + :raises BadRequestError: 客户已被业务单据引用 """ customer = customer_repository.get_by_id(session, customer_id) with audit_service.audit( @@ -316,10 +316,17 @@ def delete_customer( ): if customer is None: raise NotFoundError("客户不存在") + if customer_repository.count_business_refs(session, customer_id): + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") company_name = customer.company_name # 固化:对象随删除失效 - customer_repository.delete(session, customer) - session.commit() + try: + customer_repository.delete(session, customer) + session.commit() + except IntegrityError as exc: + # 先查后删的并发窗口:新业务挂上了该客户,FK RESTRICT 兜底为 400 + session.rollback() + raise BadRequestError("该客户已存在关联业务,请先处理相关业务单据") from exc logger.info("删除客户 | customer_id=%s | company=%s", customer_id, company_name) diff --git a/backend/app/services/personnel_service.py b/backend/app/services/personnel_service.py index 2a06778..606fa3c 100644 --- a/backend/app/services/personnel_service.py +++ b/backend/app/services/personnel_service.py @@ -46,8 +46,9 @@ from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -# 删除保护的统一文案:两种引用共享同一提示,引导管理员走「离职」而非删除 -_DELETE_BLOCKED = "存在团队成员或客户归属引用,请改为离职而非删除" +# 删除保护的统一文案:三种引用(团队成员/客户归属/业务归属)共享同一提示, +# 引导管理员走「离职」而非删除 +_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: @@ -229,9 +230,13 @@ def delete_personnel( raise NotFoundError("人员不存在") ids = [personnel_id] - if personnel_repository.count_team_members( - session, ids - ) or personnel_repository.count_customer_ownerships(session, ids): + if ( + personnel_repository.count_team_members(session, ids) + or personnel_repository.count_customer_ownerships(session, ids) + or personnel_repository.count_business_roles_refs(session, ids) + ): + # 业务归属引用(负责/协助)随业务表就位一并纳入删除保护—— + # 漏检会让删除以 FK RESTRICT 的 500 暴露而非可读的 400 raise BadRequestError(_DELETE_BLOCKED) name = personnel.personnel_name # 固化:对象随删除失效 diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py index f290f9f..429fe25 100644 --- a/backend/tests/integration/test_customers.py +++ b/backend/tests/integration/test_customers.py @@ -22,6 +22,7 @@ from app.core.constants import ( CUSTOMER_TYPE_ENTERPRISE, CUSTOMER_TYPE_PERSONAL, MODULE_CUSTOMER, + PRODUCT_CATEGORY_MUNICIPAL_WATER, ) from app.core.db import get_session_factory from app.models import BusinessRole, OperationLog, OwnershipChangeLog @@ -31,6 +32,7 @@ pytestmark = pytest.mark.integration _PERSONNEL = "/api/v1/base/personnel" _CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" def _company() -> str: @@ -149,6 +151,32 @@ class TestCrudFlow: assert dup.status_code == 400, dup.text assert "该公司名称与负责人的客户已存在" in dup.json()["message"] + def test_删除被业务引用的客户_400引导处理单据( + self, client: TestClient, normal: dict + ) -> None: + """业务表就位后删除保护补齐:FK RESTRICT 不能以 500 暴露。""" + headers = normal["headers"] + customer = _create_customer(client, headers) + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer["id"], + "items": [ + { + "product_name": "t_被引用产品", + "product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER, + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + blocked = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "已存在关联业务" in blocked.json()["message"] + def test_客户分类非法_400(self, client: TestClient, normal: dict) -> None: resp = client.post( _CUSTOMERS, diff --git a/backend/tests/integration/test_personnel.py b/backend/tests/integration/test_personnel.py index 7e2d4a2..a13a5fd 100644 --- a/backend/tests/integration/test_personnel.py +++ b/backend/tests/integration/test_personnel.py @@ -29,6 +29,7 @@ pytestmark = pytest.mark.integration _PERSONNEL = "/api/v1/base/personnel" _CUSTOMERS = "/api/v1/base/customers" +_BUSINESSES = "/api/v1/business/businesses" def _uname() -> str: @@ -234,7 +235,7 @@ class TestDeleteProtection: member = _create(client, headers, manager_id=manager["id"]) # 团队成员占用 blocked = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) assert blocked.status_code == 400, blocked.text - assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + assert "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] client.delete(f"{_PERSONNEL}/{member['id']}", headers=headers) freed = client.delete(f"{_PERSONNEL}/{manager['id']}", headers=headers) @@ -260,7 +261,7 @@ class TestDeleteProtection: blocked = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) assert blocked.status_code == 400, blocked.text - assert "存在团队成员或客户归属引用,请改为离职而非删除" in blocked.json()["message"] + assert "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" in blocked.json()["message"] # 保护与在职状态无关:离职并不能让物理删除放行 client.put(f"{_PERSONNEL}/{owner['id']}/status", json={"status": 0}, headers=headers) @@ -269,6 +270,55 @@ class TestDeleteProtection: # 删客户解除引用后方可删人 assert client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers).status_code == 200 + freed = client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers) + assert freed.status_code == 200, freed.text + + def test_被业务归属引用禁删_负责或协助任一即拦( + self, client: TestClient, normal: dict + ) -> None: + """业务表就位后删除保护补齐:负责/协助引用任一即拦,FK 不以 500 暴露。""" + headers = normal["headers"] + owner = _create(client, headers) + assistant = _create(client, headers) + resp_customer = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp_customer.status_code == 200, resp_customer.text + customer_id = resp_customer.json()["data"]["id"] + resp = client.post( + _BUSINESSES, + json={ + "customer_id": customer_id, + "owner_personnel_id": owner["id"], + "assistant_personnel_id": assistant["id"], + "items": [ + { + "product_name": "t_引用产品", + "product_category": "municipal_water", + "quantity": "1", + "sale_price": "1", + } + ], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + for target, label in ((owner, "负责"), (assistant, "协助")): + blocked = client.delete(f"{_PERSONNEL}/{target['id']}", headers=headers) + assert blocked.status_code == 400, blocked.text + assert "业务归属" in blocked.json()["message"], f"{label}引用未拦截" + + # 删除业务解除引用后,人员方可删除(owner 仍被客户归属引用,先删客户) + business_id = resp.json()["data"]["id"] + assert client.delete(f"{_BUSINESSES}/{business_id}", headers=headers).status_code == 200 + assert client.delete(f"{_CUSTOMERS}/{customer_id}", headers=headers).status_code == 200 assert client.delete(f"{_PERSONNEL}/{owner['id']}", headers=headers).status_code == 200 -- 2.27.0 From 8c4acb82fe6c94a7968b8ddaa6d7e9ff8498738f Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 17:06:46 +0800 Subject: [PATCH 039/100] =?UTF-8?q?fix(business):=20e2e=20=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E9=AA=8C=E8=AF=81=E4=B8=8E=E4=B8=A4=E5=A4=84=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wait_toast 超时返回最后见到的非空提示(错误 toast 一闪而过时诊断信息全丢) - 业务/发货明细行单价类字段放行 4 位小数(与后端 DECIMAL(14,4) 对齐) - 清理步骤适配删除保护(被终态业务/引用拦截时留档而非失败) Co-Authored-By: Claude Code --- frontend/e2e/base_data.py | 36 +++++++++-------- frontend/e2e/business_data.py | 39 ++++++++++++------- frontend/e2e/common.py | 11 ++++-- .../src/views/business/businesses/index.vue | 16 +++++--- .../src/views/business/shipments/index.vue | 6 ++- 5 files changed, 67 insertions(+), 41 deletions(-) diff --git a/frontend/e2e/base_data.py b/frontend/e2e/base_data.py index 81a43d4..2d374c6 100644 --- a/frontend/e2e/base_data.py +++ b/frontend/e2e/base_data.py @@ -370,24 +370,18 @@ def step_person_edit_regions(page: Page) -> None: def step_person_delete_protection(page: Page) -> None: - """删除被引用的经理被 400 拦截并引导「改为离职」,改为离职成功。""" + """删除被引用的经理被 400 拦截并引导「改为离职」。 + + 此处**不真改离职**——后续归属调整步骤要以在职经理为目标; + 「改为离职」的动作移到清理步骤执行(删除保护路径已在本步断言)。 + """ row = find_row(page, "姓名", MANAGER_NAME) row.locator("button:has-text('删除')").click() page.wait_for_timeout(400) # 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验 confirm_message_box(page, "删除") expect_toast(page, "请改为离职", "人员删除保护") - - row = find_row(page, "姓名", MANAGER_NAME) - row.locator("button:has-text('设为离职')").click() - page.wait_for_timeout(400) - confirm_message_box(page, "设为离职") - expect_toast(page, "已设为离职", "人员离职") - - row = find_row(page, "姓名", MANAGER_NAME) - if "离职" not in row.inner_text(): - fail("人员离职", "列表状态未更新为离职") - print("✓ 5. 删除保护 删除被引人员被 400 拦截,改为离职成功") + print("✓ 5. 删除保护 删除被引人员被 400 拦截,引导改为离职") def step_products(page: Page) -> None: @@ -460,8 +454,8 @@ def step_ownership_change(page: Page) -> None: def step_cleanup(page: Page) -> None: """清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。 - 经理在删除保护步骤已改为离职;仍被引用拦截时打印留档说明(属预期 - 保护行为,不做假绿)。 + 经理此刻仍在职(删除保护步骤刻意未改);删除被拦时先「设为离职」 + 再删——届时客户/业务员均已删除,经理应无引用、可正常删除。 """ open_menu(page, "客户管理", "基础数据") row = find_row(page, "关键词", CUSTOMER_COMPANY) @@ -486,8 +480,18 @@ def step_cleanup(page: Page) -> None: text = wait_toast(page, "已删除") if "人员已删除" in text: pass - elif "请改为离职" in text or "存在团队成员" in text: - print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留离职态经理") + elif "请改为离职" in text or "存在团队成员" in text or "业务归属" in text: + # 引导路径照做:设为离职后再删(引用已随客户/业务员删除解除) + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('设为离职')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "设为离职") + expect_toast(page, "已设为离职", "清理·经理离职") + row = find_row(page, "姓名", MANAGER_NAME) + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + confirm_message_box(page, "删除") + expect_toast(page, "人员已删除", "清理·经理删除") else: fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}") print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理") diff --git a/frontend/e2e/business_data.py b/frontend/e2e/business_data.py index 7757bbf..45af1cc 100644 --- a/frontend/e2e/business_data.py +++ b/frontend/e2e/business_data.py @@ -466,8 +466,10 @@ def step_cleanup(page: Page) -> None: """清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。 删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。 + 特别地,步骤 12 已把业务流转为「已完成」——终态业务禁删回款/发货是 + 设计保护(单据金额参与核算口径),此处删除被拦即留档,不视为失败。 """ - # 回款(未核算可删;删除后业务回款合计自动回退) + # 回款(业务此刻已是终态:删除必然被拦,留档说明) base.open_menu(page, "回款管理", "业务数据") base.select_filter_option(page, "业务", business_no) base.click_query(page) @@ -475,7 +477,10 @@ def step_cleanup(page: Page) -> None: base.table_rows(page).first.locator("button:has-text('删除')").click() page.wait_for_timeout(400) base.confirm_message_box(page, "删除") - expect_toast(page, "回款已删除", "数据清理") + text = wait_toast(page, "已删除") + if "回款已删除" not in text: + print(" 数据清理 回款删除被拦(业务已终态属预期保护),留档说明:回款保留在开发库") + break page.wait_for_timeout(600) base.click_query(page) @@ -510,10 +515,18 @@ def step_cleanup(page: Page) -> None: ) # 基础数据:客户 → 产品 → 业务员 → 经理 + # 客户被留档的业务引用时删除被拦(属预期保护),宽清处理不误伤主流程 base.open_menu(page, "客户管理", "基础数据") row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY) if row.count() > 0: - base.delete_row(page, row, "客户已删除") + row.locator("button:has-text('删除')").click() + page.wait_for_timeout(400) + base.confirm_message_box(page, "删除") + text = wait_toast(page, "已删除") + if "客户已删除" in text: + pass + else: + print(" 数据清理 客户删除被拦(被留档业务引用属预期保护),留档说明:客户保留在开发库") base.open_menu(page, "产品管理", "基础数据") row = base.find_row(page, "关键词", base.PRODUCT_NAME) @@ -521,22 +534,20 @@ def step_cleanup(page: Page) -> None: base.delete_row(page, row, "产品已删除") base.open_menu(page, "人员管理", "基础数据") - row = base.find_row(page, "姓名", base.SALESMAN_NAME) - if row.count() > 0: - base.delete_row(page, row, "人员已删除") - - row = base.find_row(page, "姓名", base.MANAGER_NAME) - if row.count() > 0: + for person_name in (base.SALESMAN_NAME, base.MANAGER_NAME): + row = base.find_row(page, "姓名", person_name) + if row.count() == 0: + continue row.locator("button:has-text('删除')").click() page.wait_for_timeout(400) base.confirm_message_box(page, "删除") text = wait_toast(page, "已删除") if "人员已删除" in text: - print(" 数据清理 经理已删除") - elif "请改为离职" in text or "存在团队成员" in text: - print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留经理") - else: - fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}") + continue + if "请改为离职" in text or "存在团队成员" in text or "业务归属" in text: + print(f" 数据清理 {person_name} 仍被引用(属预期保护),留档说明:保留人员") + continue + fail("数据清理", f"清理人员出现未预期提示:{text or '(无)'}") print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档") diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py index cb88b58..6642cc8 100644 --- a/frontend/e2e/common.py +++ b/frontend/e2e/common.py @@ -85,19 +85,24 @@ def toast_text(page: Page) -> str: def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str: - """等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。 + """等待包含 expected 的全局提示出现,返回其文本(超时返回最后见到的非空提示)。 直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里 读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。 + 返回值语义:匹配成功返回该条;超时返回**见过的最后一条非空提示** —— + toast 只有 3 秒生命周期,等到超时才回来断言的场景(如「请求已 400、 + 错误 toast 一闪而过」)若返回最后一次的空串,诊断信息会全部丢失。 """ deadline = time.monotonic() + timeout_ms / 1000 - text = "" + last_seen = "" while time.monotonic() < deadline: text = toast_text(page) + if text: + last_seen = text if expected in text: return text page.wait_for_timeout(200) - return text + return last_seen def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None: diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index ae3769a..de64eb6 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -103,6 +103,10 @@ function personnelLabel(option: PersonnelOption): string { /** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */ const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/ +// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出 +// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何 +// 带小数精度的历史记录在编辑时都会被前端永远拦下 +const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/ /** 全零形态:数量用它排除「>0」语义,不走 float 比较 */ const ZERO_PATTERN = /^0+(\.0+)?$/ @@ -234,13 +238,13 @@ function validateItemRows(rows: ItemDraft[]): string | null { return `${no}的数量须为大于 0 的数字,最多两位小数` } const prices = [ - ['销售单价', row.sale_price], - ['成本额', row.cost_amount], - ['指导价', row.guided_price], + ['销售单价', row.sale_price, PRICE_PATTERN, '最多四位小数'], + ['成本额', row.cost_amount, AMOUNT_PATTERN, '最多两位小数'], + ['指导价', row.guided_price, PRICE_PATTERN, '最多四位小数'], ] as const - for (const [label, value] of prices) { - if (value.trim() && !AMOUNT_PATTERN.test(value.trim())) { - return `${no}的${label}须为非负数字,最多两位小数` + for (const [label, value, pattern, hint] of prices) { + if (value.trim() && !pattern.test(value.trim())) { + return `${no}的${label}须为非负数字,${hint}` } } } diff --git a/frontend/src/views/business/shipments/index.vue b/frontend/src/views/business/shipments/index.vue index 2b3b788..2143908 100644 --- a/frontend/src/views/business/shipments/index.vue +++ b/frontend/src/views/business/shipments/index.vue @@ -82,6 +82,8 @@ function businessLabel(option: BusinessOption): string { /** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */ const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/ +// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位 +const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/ /** 全零形态:数量排除「>0」语义,不走 float 比较 */ const ZERO_PATTERN = /^0+(\.0+)?$/ @@ -183,8 +185,8 @@ function validateItemRows(rows: ItemDraft[]): string | null { if (!AMOUNT_PATTERN.test(row.quantity) || ZERO_PATTERN.test(row.quantity)) { return `${no}的数量须为大于 0 的数字,最多两位小数` } - if (row.sale_price.trim() && !AMOUNT_PATTERN.test(row.sale_price.trim())) { - return `${no}的发货单价须为非负数字,最多两位小数` + if (row.sale_price.trim() && !PRICE_PATTERN.test(row.sale_price.trim())) { + return `${no}的发货单价须为非负数字,最多四位小数` } } return null -- 2.27.0 From d77728265b8ee37d533cad87dc0cca9d18ca68de Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 17:13:21 +0800 Subject: [PATCH 040/100] =?UTF-8?q?docs(business):=20M2=20=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E2=80=94=E2=80=94e2e=20=E8=AE=B0=E5=BD=95=E3=80=81?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=E8=A1=A8=E3=80=81AI=5FCONTEXT=20=E6=91=98?= =?UTF-8?q?=E8=A6=81=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- backend/tests/README.md | 3 +++ frontend/README.md | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/tests/README.md b/backend/tests/README.md index 5620916..3141351 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -124,6 +124,7 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) | | 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) | | 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 | +| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 | | 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 | ## 四、测试发现并修复的缺陷 @@ -146,6 +147,8 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 | | 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) | | 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 | +| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 | +| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 | | 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 | ## 五、待补用例 diff --git a/frontend/README.md b/frontend/README.md index 162f2e6..385d138 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -153,7 +153,9 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py | 脚本 | 覆盖 | 结果 | | --- | --- | --- | -| `e2e/base_data.py` | 基础数据链路:菜单两组 → 人员建(经理/业务员指派)→ 编辑抽屉加辐射区域回显 → 删除保护(400 引导改离职)→ 产品建 + 重名 400 → 客户建 → 归属变更(确认文案含「不回溯历史」)→ 留痕抽屉可见 | 待运行 | -| `e2e/business_data.py` | 业务全链路:基础数据 → 业务建(双类别明细)→ 编辑汇总重算 → 详情抽屉(时间线/毛利润卡片)→ 归属调整 → 发货(SH 编号)→ 回款级联指定批次 → 回款天数展示 → 未指定批次文案 → 状态流转已完成 → 终态禁发货 → 评级计算器出 A 级 | 待运行 | +| `e2e/base_data.py` | 基础数据 13 步:菜单两组 → 人员建(经理/业务员指派)→ 编辑抽屉加辐射区域回显 → 删除保护(400 引导改离职)→ 产品建 + 重名 400 → 客户建 → 归属变更(确认文案含「不回溯历史」)→ 留痕抽屉可见 → 控制台无 JS 错误 | ✓ 全通过(首跑) | +| `e2e/business_data.py` | 业务全链路 16 步:基础建数 → 业务建(双类别明细)→ 编辑汇总重算(65000→115000)→ 详情抽屉(时间线/毛利润卡片)→ 归属调整 → 发货(SH 编号)→ 回款级联指定批次 → 回款天数「6 天」→ 未指定批次文案 → 状态流转已完成 → 终态禁发货 → 评级计算器出 A 级 → 清理留档 | ✓ 全通过(修复单价小数位/删除保护后) | 运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。 +**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、 +单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。 -- 2.27.0 From d8dba27fb2be73ad587646498c25543e6183833f Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 13 Oct 2026 18:00:23 +0800 Subject: [PATCH 041/100] =?UTF-8?q?docs(requirements):=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD=E6=B8=85=E5=8D=95?= =?UTF-8?q?=EF=BC=88=E5=BC=80=E5=8F=91=E4=BA=A4=E4=BB=98=E8=A7=86=E8=A7=92?= =?UTF-8?q?=EF=BC=9A14=20=E9=A1=B5=E9=9D=A2=E5=B7=B2=E4=BA=A4=E4=BB=98/M3+?= =?UTF-8?q?M4=20=E5=BE=85=E5=BC=80=E5=8F=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- docs/模块页面功能清单_2026-10-13.xlsx | Bin 0 -> 12127 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/模块页面功能清单_2026-10-13.xlsx diff --git a/docs/模块页面功能清单_2026-10-13.xlsx b/docs/模块页面功能清单_2026-10-13.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b4ccd018f551a22d705cf9bc3c863ddfb9b40f67 GIT binary patch literal 12127 zcmZ{K19W8D+HI_kZCjnBV>=b6W81bnwr#UJwrzC9=vW=wPF~JA@4kC)zyGfqd+)JF zjrr~MX|BC$O$BLi2y_q-5Eu~CSXmwMVOc=x#~twFK>j$4?Ti#0?d+XC8`#@3y4zUG z#>#%`V?q{vmpWK0YCI4!6q+kM81<6jV2?9SH4c6_FAgAT#tFICzo8%#GDQ-b!&Y2k zNBd%vMC%%09w8c_Y-FESxJAmmfx*4cfIr1gO0GKIq(k=|-Xh3BNYCKQ0m0Xcg_>d3 ztPY_1R4u3KC!rJO1Owks#7UfJXD0WA0y^az{#%d=|K&s< zp^4D>$KdI}KtNFb(+MLxN0Yz2044}o_A?=iRiidEOVn8q_gzvJmt(>qG&oZgNs?M> z2xl1X-VIuAD=dRM^IUtpBgm8n&hc+M-GGv5$dsRiFiR3Nort273(inE)6Vm6rmfju zOw&j;#!A3Q`F2Pd@}jJhcnj9MdrUEt~;QKh;>yRZ@0VS>9%pE$frJm!~()zN)KCk$ALq za$>SGVvEV*-lD5px3B)U{!AANcZy}cjgj_fbl~&JQ~x>TNk=X8b8K{Vz&Ggbw}N-D z|Bg<&b`}{d6bMKb6$l90M|9k+KRcV7*qHotX8tQardcFq1M zUb-1tk()*8pE_ThcE@AQK4s-AUE#7>oOWh6e+l^&R2nGM95b|$cW7IRLv+`kt(n1g zfE|M>{s?qMO>CSOttTF~XT>_HNH5;!l&em7Wgm;GNL}+JnSfm6Q8k3q3&#Ic#c&MI zx(oRHg(Wl$!X4yLS<(H5I9Du>wb+qCaitokIWB@6=!l0#>k>Va^0_M zUXxvWe%f~g*K{1(y4G(%Qn%tFzV}Ojz!kSujzVT!8!ZDhm_LEXnL=?h+BWXTi`)S@ zsHG4I3JoiXICm{FQECq^C`s-4f|=xF1))H78m!jAAzdbP*X%j+JF`0D(mI;d7Ga{eFPB zA~o~k!6z}2sXalt1LeiSNpm>(l;|#b)EZLHOPt^UwvP_e!UN-lS}+e{I%ez$4GwA0 zz$mJK=Yq~=g=^Iv8)(Xd)Gxm|3I-dYIW>o^R2q_+|2SD58Vt0 zJ8yd{#>&V%=B2}8+SxMe1dY+6=jcdl-E$FY)WlFy?eMfA@4nW#S-o+OR24?foPO*U zbxIr(Db0<5GSwoiF?r9~a`rs0$zvU%Sh@nJYsI0goXS2%bu0DaAo+$5g2}ai7jnH} zj+%#_39QaiWy=c7n{DzY&g^r-7C~1Me6#*)BI2TN0irJwcSO8YMtZ2o7|I1_k8QKg zcM)1JaZ^fJI>|1n{oK#PDqvr(;(nja794p#Pas|wSov~4txF&Nl1NDncFv(4@Qr76 zb1n8*x3J6z$=_|v4svkD+U(l6V~!yHbdlvhL@iaX;g@Vt(_=`u30T=$SqRD@e33e6 z7HvRvFST7>sIjwf6cB-k75myy)bsf-$He85iwaokm zgC(5$4Ar_d z88S+dC>YBiNssW6=m2V6khl>|OI0FuusD1JEQsMAmM0pHqn1S$oiE%QLVeUwcvk^) z0xM#T963qq6Uq99>HN2Y4YnpVadz6@rt~sFbr}~jqYMUUN$D7c6++HqOK%=kEjA1yX|guDXm_Pq(Cy3|~_f+xJ5s3k1$FXYO(@4fw&ciLa@8 zP?Ax+ygTc2409H#wj)GwEA_mI2{r{HWN}$cx*pgOYP8^OE9{N7)s{d7N{lBTll5Yg z9D!bzRi`2&Uw&ukC0eo_KU<`m4jny-b#du*UbBjr>hB1w{6w3&p$or>`)SbIFviCu zFLSTir+?ecu`ksuhqwSw1^wsDk%ZYuO^gWw0_6w-g8k3T;b!M(r-EW;4>&w zC~(A)ps`4kgtVc`=VLmi(p=T!4Gy9VQ>|}Z-CDL2ltfJ{p>`#+z}wJ0pDvGDPxF-A z^YiuUh5PH%y}^$bTjQ@N_T|8cNd;lo9DaU& zd>VHHt{u%T1yMKAW~`RHV$edHAwF zrube<^?^@q`(go0W%u8cbI01}kKEq3LNn`OGu>y#OP;Q-`_~M;*ijNK+dUs@J-WXw zt*&Lub|oiT`gH1eTkB4y0+)WTS+?t}J#SFWoAPY1n=y`kKP2LUcA(QEgm1OdCzueuf4XlEctL|{*rW=?Z_2ax7n!R@3b_p zZ76l?Y+LE+o~N?5qMX0oF>P-vHG3>-)1$vUfB91BP|CN+Lx_Ci2Ov`5A&k56lOa@S z4=FkTP39#Yh=FG1%bO)xB0$3S>?z`_AwZhD5zL-2SOiZYtf^^j!K+Eex;}k*aqfIl zuFP-TV8Qt@Ao~$l-Dk`m>M46Kb`AQ@j+YJ{`3ZTf%$Xk+pdc;3jnFYXJXVvd(t4_+bBnxUt zBPb~5ot zb~$a5{uG6p92L2LpPLw5=kBP2yGFs3-V}Uri&iy{bqqo_;#!E#bDs%X%rX&2X&>M^6M`b+dA%=#Z29m~DWR#r>c zW}_n-s84ZIp^L6WlaGftMyXCU!ONZJGExW1G*EPkbVM?YG|F7OU+#=XgRV7ZiJr@W8hT&5g7+ znjtsiLU#1VF&a`o*tZxoF=?tkqW?V|oH!}vN1F&GBb3uZeWc;9Y_rR@K>5JC2KPRu zNST7`6$h@aFtt>>((3oO>lI6G-&LQqw`b3{n$9<-_m#CH*V>!+D#B*pcDpw;%&)ut z2*uVNGx>$i z2ma0@qTDyr#f5-)dSM!hE5onch=uB%-q+(|JvooSS1_)4>{JL!A5&asJ#+{%l>8u; z%D1s_=UiQM&U_R39xz%*ljE4Yp||hvg1%EZ8rbs6X>4vDbYIU0H;(6Z>Ooz!Q_?<0 zK`t&(JbU3 zs)T{PO{s0{KFGc1=yLh$P-LXPaG6F>n9B>(=E0d~!_($NBhss^=6hj`w{joC7_RH) zLgU!LZA}wEO>gHk~)l-JQYI`!j{c8pJs^>IpnIQ>Wk5cz13B`K69v7XU_y) zizICmkz}I*p8y3kP6ZmjkxZGpM6~fcUO=fZD@s~g!MhH%g!=4ZMBt3<6Idu!kIumZqDwivC z_pLqvDzm?XFw9Tq?2f9%z4@w?0|F=`&hN7OCsWzS&fl$06ksgcHxijCqqYx{Eww9` z{f2s6Q*)~`c|LI)yZj@k1WZV{1+*hi&tv9d{>O1DK2_InVNamKxsQ{$X_GQCwr#eKB{BN`ue@@q5*y5sFM?L;@@@`8=mcLfN_yv#@49$gV;JH& zimNP=S{Q|YA(OG{D8PELKIl?|ks&E^sblL2z(e2vc7Lj|jHkM8PxZ!AiBACF;9%99t zv_rnNy0KtE7tKs)TI7T?{~2eQR2a8Ltb>Y#C;d1Lv^j#iCJljt#`anYl{Fas;0IvB zU+w|_`3u`^>DPYy8Fes*nFDW2(4A;oDP)NWPMFXnwkVE)pa2RhONRP`ufP0%RKun7 zvmOI2TdmJ%+f!8>u&429k%OrMf>~A|V|x_=p)>(}pwAHINHL|t;|bQhS5GrGh#>*P zi8}Pu`2BH2G!b&t7}0nD+S5S%Y%nB#LBv#2=y$rf!e66;rC9MhObIlO3c{+6y}*-W zklBN6l6Cc(9pNr}5M;hW@B$lbXA_x%QAq-tkYMm-2WS{$OK03LJ!m-zfJt%utML*K z95(#;FOnVo6@ z(Q$zfAgk(c#&FGE3S0vcf@4w7xjuOruqN87=EGu&3)t}6EnXknn2q24(u9*o9#P^k z>ltR!Y+>Kj>kQ^pyO1vosANzE6>*ldx;K%ZV&}u{TkuI zki)u+Dvmy{c#+RAq#KHf4n9wfW0EQr(_>;FjSOCs0DpOukk|ox6oNe9G+ONQ zynt`zat2#37kT6|GC5@?lp>Ay6=PvBn#~!ViDg-)#Q{dXM}yapzL@2w3$ljeOpZVw8gE0BgJv}gDob$Fh~{p;9Fu%qED=8i znY7?^VI8fJZv*eFnPaD+Y@tdMU@_i`Jv1o>XX;A}MAJ2BGwL9O77AIyrgT^mr8QQT zzwz`Q;dmDUDU+uu^ZR06m@VS7xx2rtgg+Rj&U;_q->XnP!1b0fYg>J~eu)o6U!0lY zwY(l^QKes$4oLOdT+S!2Wi`g%@bO7rdL&?)58->_MRz=~I5GoS6ZVJQAli2ZD9o`* zV6$+>=N$rgTn#<=)bWns#_&T}?W=d_e}3od)xS2lwBMk=cS}a3%!0(ZEh2Ve3KY_Q zrBBR|?Az_lhVfsH(}Vp5>LIQud83q>KBf4RHmZ+wzAk@uf%Hj4nufS&K5T9;95jDU zvMt05q|bm{$?khqG9=R{-e;Hy4D8v z7hhH+|72*yeqNFue7|xwFM#Dtgufq|O6h|cKljK)p z@32hM_D(C_zSq4;DgGx-KyMmT{SlSkv_Qqef!g7HIY-+(F)mnLf>i^J;yNs$jj#YA z1Hi*I4L0FfP+rS+H1sxwm!YMIT^^oqZ%Qz#${`wHP^z)Lrm^Ly7FAeQT9+`T0#m%{ z{mZGB8>NAq3l9k9Nr|cHXyZOPp%20XTD44VKQl2U1d!SR2pxh;;WccT$()OxQ{v-q zxhK&enQ6E)Jx5%6x2v!7ck4{rWqd2=KqAi(gSjq(sfA8dxC!6UUQz!n*3B@7DY`FC zQ}(rNiD@{qj<^O? zv%^p@pogN55yN3e2IKr3Y>3E~oV{f!z6|?H&juS-wjR{2bzO@h?anP~_DV zf2|Zvwvv)^sH-dPk@1+I{k5M=lNyH&q|XX^wbqQQh4Hv9VAXsuEqHLqA(KYaLN#_z zEmsD478+;6_% z_o&n<#CA*RWhd(ZRw#pOp=MD{AgxP-pWSOHeLi}d05*!cmC!LN*4!$ZkJtsixb=$- z{Yauex2`x)35vDAyaz25aN2`$v{2qa2(KIrh!SvB7}%ZIJ85_s_iAZ2CXAiKi07^| zPJwQbW)b^U9{Pw?b0N@6p{)Tay{wDNs{qqY?)#-N7a_SE*GZaS176ee!LWYGcT$N zo2wPec^8Uro<-A@_@P(5-aGyH(-qo3HEZWi=g~a2od#JAKLH3N+_l7D)J)gAddW(kf ztE4-uio*CdQpI7+ic8QLTK|9~?9UwdWb{J1QG?@OsIdaAJ?q=nW$ZG5+pevlPPEHU zV$2nKH+Rl@NfB&HnwtefMjYQ#h`-{t+n$=VCJv+%joE2h??5MF}G~BXok)m?v+){wn38JVtL0^aw64rX6 zVoAL!F82MPZ<2iXT3n#2W}SATV>1d2b!@I7yCl|{bnK9vsCX<3fntQ_*7E|IORF@7 zVbPMi`IuA&cTN#O^k0?HR75a@V|IVN`_iQG-qiBrN^2gYQ9ltOf;e3)R%%f}c|C`x zU@Ku~s~p#_%SaqphBk2*-`zAL2Tyk@s%ar%lcWBINVlJ9TF@+6O$PP@iLVRUQaXSx zd!g@cm<2dkJW{>p!XU0mzF4lgbX*V|uzvPgUiJI!Wzf?*r`uG(ieQenq#GHi z36LqmF#hmKtV~-dMLQKuzDo%bDb+L-f`I+4%vmV*do5;zZ=fW1!r^h`d3}^s%y7%! zL5u;m?110cUARA7aWyXlXmB@AGh{r%(s%R%tzRX^af>T+QTv~0;uqw$hEkC~j?w@@Q?`bG|cq?K~XvRBD*-=?C>w)a~%O4CQ~LN#-* z_w8CSbrUXxNHa_q6rqT+71v<1pzCaV+Ns7KAMZ8q(xO$%iYMu&wmyqX(AN2bSgW?Y zt5+%x&ym7J3q{*&p$_C7z;Nhb?9>;xBhn!Y;#WKW#c9M5;4)3Ln9`R1l+a4fCxO^p zb9R)r`rn6Hxoi)J22)Z33nfeUd@rg$!F1sGergqJA>y5Tj4_bYlA{RORVHO9Enqha zy>z!RGGvOR-jyoh)}{%Zk0RlPeVnAvLYEjM?`Z2QRrIeEZ$GgA_N<8UtFo8C;CF*t zKNmW?upX1PUyn1NE~ZWvSce5Ao%D{p561?5m2Z}>2A^&Hw$Xie20V~W14>sn-?X*iTQy#v1)dXkJzc)MgZ}q^q#!quL^&h~ z2avPFG%62TC%Hr->UA`}RS{)d{YsV9ox47$wP^ah)1u4YCac*^-CXrh zH@axjD4F>rrolIG+vIo{k;Lu+pLOh>Xe!o_HodIVn3gOrn}cyyWq3Plny9L$*WR%T zBttpH;x|T$jn)gps<(OCJkz<}zpIMCs&A>U8)L8fUSFr5tTkDvw!K?L74p+hLEd)7 zbQ%q544n{-!4R1qNf?A#_p%rarW&1TY>}e~xDZ^~t9c;dZ=-0HC5d=U+V<~NJw48j zg&5@FdTQV8$5eOgVvb8I%bRv5R9rfMvjwJ!c*v``pNGG?dl92_5Fjf)x+se$d_y_$ zYIo(1%p9b@V;&qIq^}y&NhNds0a6UMkv>*yQFrrHMxku}Ee<3DNyFU<2&O)lF{3sF zui@@BA5c3q>^Bw^5CFCT13?4Lu&&z}hDt+8(Nc|Oo})m=x;%}kzX(Q-brleKzX~oW zyy7m%tp4giwjT#t4mk*`xr7*T-o5_*#`w0Wiqnf|LJAP?0aZ=J1KolRIKEKs>&Fge z)g`Q~>nh7j-sbc89cC;qb@r_=zA{U6J{4_Y=A2lhIVk1#Fs_d%o>gEMDrzb7C2hVd zspcV8n=L?$FeD762J5Jlaba?KK<)=xmLnKroVQc_h!DN*X_#P9&Q;J(5^V9WFYK+o zqDw{K=-}fy%+N8}DX|njqmBbYt5E0ey*&0)laRg>HuMiL0263ME=0v%sO6HV{Ath( z0A)q-nO_`B1JH6TiL8`H{7Gb!(Cy6M(w&;Z5o{9$G3};TQW_f(8?B9Q-Gi4})Q_)V zIy>Ss#|4v~KUKmAW4wMav4~lV#AvUg1UdsuOB}hsH<*mHIDuf1aW3^f$}Mb<@D0P# z9eW_-O@ZjR|5F?`MJcHEskp!JEWP+RO?(Xr!lm$=v#z!crTlknpM7Q}%2q^p&WpDs zn9IiaUWlPm6%$iq{M9kLg4iCEv+prHmRUS=^{?UsvZT+e3XU5*29?FRK??4aETWkX z-C$k~FvYLbJF!R36J)xgnXO?>{3$EU5(ZC*H1HOHBXugX24bWF6O+DhGQtc9dYH-e zJv2nh@NH0cmMnkt?*`Q6xripW-xLer@&g46$YFj25kcKg-AxguCM!+}xS7M4 zky>)Tc7+!4)|FaYRVQ0be(nNF#4Q+rUzW0iCH)5AWn(^tkxC>JTLy8JLVhidJg=z@ zc?d@pQ9w%mlA4eBHX?+^N_=?W%n5O8)d>)UuRBwX?Xy<6gfS}VPs15b+t}rMJft(} zUYU&#eBIhF6)0BZ2*VL^$8!_Bqd+x`d7W~HQfA3AwVv@o4hpAHAlg$63;GlRAyxpy zIg}?mMfgMmBSD>n>AY?Oip6i%b=+a)!!7(XmUQ5STDT7Z@Zi4Da^!C-YuQAf8*%5< zsl})L6y%i@=-$HYnX8JV@wI-nGSg!~4;!PF*846rwPbY$%eSQrwi$v^r*-Vp_pr+G zx}|P+=rxsJ+XDYh@W*B`46fiHAfz7zkMPg7p_8+RwTaVTtiG%MHD;C@eN8F}QYND$ zF`btpK5YsB@QX`B@>v_QVBm7KU(Mgmg}ivZyHE%5wBwKu(8;qZmE;DB5nv;ON0)Kl zKV^A&e=};2#_Qa<+09TqwWedt7|^lOdEfN_I?B?`WQ}G-*bGYlpe@M!@=jf&KIzqe;u zJaI^R>`|K7L&a6{dX05GB}RuR7rlm+`=ahNGwQm@!r}xR0=G`%4AkaEwm}YPThz5| z&JgV3(;1)9rO;rkb=|?x{(#-SQ7>;-o7lG3E|FC!Dt7uy9x-jmIM!)Fv9?*!T)iko z*}P19KZF*TA~KFAc4XDYW~4wz<5;jCvwuY3K{u zB8|BuySAR&#K7swqu!>Zr0wZk5(I5JO;^}tyY0ySL{Ho zjo)sCnsi?WK@t1-Z4eZK{ge8s;L9bag3?YJv7g3ZgMl5>SvcuW@J3GELW3}4svs+G zpPFWJm;&=v@Gb{PAwQ2zn*qKwuqi)}fS!}U<~uUGZh4Q_Zo@;Y|8CIKh<5y*kH$p% z(93=Lxav8YSUY`Y{Og=OBW(%J4DYv94!`b}R5B$tVmvSFTKpTVtkQ$2iNZL!tr=;Q zGJOhbDKb}Hc!eXv9BrRPPBA0HkTDw%Xbi6K0)Uks7j7;2RZrP4Q-?@=V1^H~P(J_V zz@`7RX%V=0L2;8ydjI6?F4cDEhU`*o49j<=~-cI)4 zj^aH!Li&M%fDC*%iu4Z?{Uu;9w6nANi;Ef)CN0s0kcae44xj<@m5)PR0C>rf z&XQ6}rJ{DLgDT(v&xuujq@Ng^q80QmkVKg352UYniZxJ5i>N3M0jd(Yf z{Q=@(omCMA_5jY`q6U5;%2dAuB+b%@R^Fcw2=gpxlVas?!a6(Aex$# zk9W3_1LGczIt0>q{kTiQ1v{aIwacP;)ik6@q@Ln$dSS9RLFbG((0rs+nXLUc>wD^R zUZ#l)%Y~~40Qe~`L7WKh0r4v-@V2$-TsJI^@aE8g{H}8n$sYFVWh;xhgp59n{JwhL z9j(s}9?xg}$cSH?GMT^oZNfD%6_+R*v`gx~=T;BSuRPn?o!PB#eB<(c@a#0SyuwmS zgs_*a>`mci=h24@%@YLVIs$b7^>jUzkqjy17xt23A-PvZ!#uFO{u3 z6$3v2L0g{O!u`ct;>nA@rlXyNVrQW_i)WEAwW%9qo@RbLx>o3pv9ncYyiDya0jCC1 zoybVSZ!n)p5sIsN4&!Q_$-=f{L1Q23gAmKkBLo0^S7f3xJwjwhf+Q`;KvF1c! zeqBQEOO>VF+z{3vP9mTd<+UmTngWGh4|_aS&F!u9w%g$C9N#zvFFcw#{@YjEQt}Rh zkHS=g_(zkeC1PjmY+~!Ir|e;G;-vFeaSkSR+k!E}59#eDHIjmcAYu&9xZ2=6fmv+t zkS1`}A@Hqtwe3tQOe>)Pbhb(E?w>0@k8K<2`IR#c{%o`q8%tz9(FCNhVUFlTASl-L z;ab>4xZt%zeaV$0iteY6Ga>B^(PY%kt6ia?lqGVEX zUK}*R)|oRxEI554ra1~c=;zeX1lV($aa=>EGv`S(lhRw`qiV<%VT?3EXxa8H?~=0xxpuyFXTx zpkU}A|GSL&qkR7U{MG#bzo_|7^q>0Sf3P4RexQT@iT;R$$${0U(GHt<&m{C}#eKdt<^uKu?bC&K^H%HON(Kdt=v^7bz)exS Date: Wed, 14 Oct 2026 14:09:30 +0800 Subject: [PATCH 042/100] =?UTF-8?q?fix(no=5Fgenerator):=20=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=20MAX=20=E6=A3=80=E7=B4=A2=E6=94=B9=E9=95=BF=E5=BA=A6?= =?UTF-8?q?=E4=BC=98=E5=85=88=E6=8E=92=E5=BA=8F=EF=BC=8C=E7=A0=B4=E8=A7=A3?= =?UTF-8?q?=E4=BD=8D=E5=AE=BD=E8=BF=9B=E4=BD=8D=E5=90=8E=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E5=BA=8F=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨位宽(-9999→-10000)后纯字典序反转,MAX 恒取旧值导致当日建单 持续撞唯一键;同前缀下长度大即数值大,同级同长时字典序与数值一致。 新增跨位宽/同宽/空表三个集成用例。 Co-Authored-By: Claude Code --- backend/app/services/no_generator.py | 7 +- .../tests/integration/test_no_generator.py | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 backend/tests/integration/test_no_generator.py diff --git a/backend/app/services/no_generator.py b/backend/app/services/no_generator.py index 447f227..48f0697 100644 --- a/backend/app/services/no_generator.py +++ b/backend/app/services/no_generator.py @@ -8,7 +8,7 @@ from datetime import datetime -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -35,10 +35,13 @@ def next_no( """ like_prefix = today_prefix(prefix) column = getattr(model, column_name) + # 排序按「长度优先、同级字典序」:同前缀下序号位数增多(9999→10000)后 + # 纯字典序会反转("-10000" < "-9999",'1'<'9'),seq 将恒重算出同一号码、 + # 每笔建单都撞唯一键 —— 长度大即数值大,同级同长时字典序与数值序一致 max_no = session.execute( select(column) .where(column.like(f"{like_prefix}%")) - .order_by(column.desc()) + .order_by(func.length(column).desc(), column.desc()) .limit(1) .with_for_update() ).scalar() diff --git a/backend/tests/integration/test_no_generator.py b/backend/tests/integration/test_no_generator.py new file mode 100644 index 0000000..608a086 --- /dev/null +++ b/backend/tests/integration/test_no_generator.py @@ -0,0 +1,82 @@ +"""单据编号生成器的跨位宽正确性(no_generator)。 + +守:当日序号超过 4 位宽度(9999→10000)后,VARCHAR 字典序排序会失效 +("-10000" < "-9999",'1'<'9')——MAX 检索若按纯字典序,seq 会恒重算出 +同一号码,每笔建单都撞唯一键。修复口径:长度优先、同级按字典序 +(同前缀下长度大即数值大;同长时字典序与数值序一致)。 +""" + +import pytest +from sqlalchemy import select + +from app.core.constants import NO_PREFIX_BUSINESS +from app.core.db import get_session_factory +from app.models import Business, Customer +from app.services.no_generator import next_no, today_prefix + +pytestmark = pytest.mark.integration + + +def _insert_business_nos(nos: list[str]) -> None: + """直插同日前缀的业务编号行(仅编号用于 MAX 检索;其余列取默认/可空)。 + + 直插绕过服务层(服务层走完整创建链太重),业务行为挂在 t_ 客户名下, + 由 integration/conftest 兜底清理(YW% 单据 + t\\_% 客户)。 + """ + factory = get_session_factory() + with factory() as session: + customer = Customer( + company_name="t_no_generator_client", + owner_name="t_no_generator_owner", + customer_type="enterprise", + ) + session.add(customer) + session.flush() + session.add_all(Business(business_no=no, customer_id=customer.id) for no in nos) + session.commit() + + +class TestMaxCrossWidth: + """MAX 检索在同日前缀下跨位宽取最大序号。""" + + def test_序号破四位宽后仍按数值取最大(self) -> None: + """4 位与 5 位并存:"-9999" 字典序大于 "-10000",纯字典序会取错。""" + prefix = today_prefix(NO_PREFIX_BUSINESS) + _insert_business_nos([f"{prefix}9999", f"{prefix}10000"]) + factory = get_session_factory() + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}10001" + + def test_同位宽内字典序与数值序一致仍取最大(self) -> None: + """同宽多行(修复的排序不破坏原行为):是长度优先而非只按长度取首行。""" + prefix = today_prefix(NO_PREFIX_BUSINESS) + _insert_business_nos([f"{prefix}0099", f"{prefix}8499", f"{prefix}8500"]) + factory = get_session_factory() + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}8501" + + def test_无当日编号时从1起编(self) -> None: + """当日无同前缀行(昨日编号不被 like 命中):从 0001 起,不受历史天数影响。 + + 当日若已有其他用例留下的同前缀编号(用例间无隔离库),跳过—— + 空表场景由每次 `down -v` 重建后的首个用例覆盖。 + """ + prefix = today_prefix(NO_PREFIX_BUSINESS) + factory = get_session_factory() + with factory() as session: + existing = session.execute( + select(Business.id).where(Business.business_no.like(f"{prefix}%")).limit(1) + ).scalar() + if existing is not None: + pytest.skip(f"当日已有同前缀编号 {existing},空表场景跳过") + with factory() as session: + no = next_no( + session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS + ) + assert no == f"{prefix}0001" -- 2.27.0 From cb977979daa2f68b7aee9e3834c42e6d564a2f81 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 14:20:05 +0800 Subject: [PATCH 043/100] =?UTF-8?q?fix(param):=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8D=83=E5=88=86=E6=AF=94=E6=8D=A2=E7=AE=97/=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E5=A4=8D=E7=94=A8=20next=5Fno/=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E8=A7=84=E8=8C=83=E5=80=BC=E8=90=BD=E5=BA=93/?= =?UTF-8?q?=E5=86=97=E4=BD=99=E6=8F=90=E4=BA=A4=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 企业户与信息员预览分支把 ‰ 存储值当纯比例直接相乘,绩效金额放大 1000 倍;补两个岗位路径的精确数值用例(既有用例仅覆盖业务员路径) - 批次版本号改用 no_generator.next_no(MAX FOR UPDATE 锁当日区间), 移除无锁全拉重造的 _next_version_no(依赖前一笔的长度优先排序) - 岗位维度值经 ci 校对命中后以库里规范 role_code 落库,防快照字典 精确匹配静默不命中;变形大小写提交补回读断言 - 删正常路径冗余双 commit;支付权重 R 封底封顶口径注释声明 Co-Authored-By: Claude Code --- backend/app/core/constants.py | 1 + backend/app/services/param_service.py | 65 +++++------ .../integration/test_param_management.py | 105 ++++++++++++++++++ 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 8566e60..ef6fc85 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -76,6 +76,7 @@ MODULE_RATING = "客户评级" NO_PREFIX_BUSINESS = "YW" NO_PREFIX_SHIPMENT = "SH" NO_PREFIX_RECEIPT = "HK" +NO_PREFIX_PARAM = "P" ACTION_CREATE = "新增" ACTION_UPDATE = "修改" diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index 9b68f28..a67d6a6 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -13,7 +13,6 @@ from decimal import Decimal from fractions import Fraction from io import BytesIO -from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -21,6 +20,7 @@ from app.core.constants import ( ACTION_DELETE, ACTION_UPDATE, MODULE_PARAM, + NO_PREFIX_PARAM, PRODUCT_CATEGORIES, ) from app.core.exceptions import BadRequestError, NotFoundError @@ -46,6 +46,7 @@ from app.schemas.param import ( from app.services import audit_service, formula from app.services.audit_service import AuditRecord, RequestMeta from app.services.formula import to_decimal +from app.services.no_generator import next_no from app.services.param_registry import get_spec, parse_fraction, validate_and_normalize logger = logging.getLogger(__name__) @@ -187,21 +188,6 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat # ==================== 变更与版本管理 ==================== -def _next_version_no(session: Session) -> str: - """生成批次版本号 P{YYYYMMDD}-{当日序号}(当日自增,两位补零)。""" - today = date.today() - prefix = f"P{today:%Y%m%d}" - seq = 0 - for (version_no,) in session.execute( - select(ParamChangeBatch.version_no).where(ParamChangeBatch.version_no.like(f"{prefix}-%")) - ): - try: - seq = max(seq, int(version_no.rsplit("-", 1)[1])) - except (IndexError, ValueError): # 手工造数容错:跳过不合规格的版本号 - continue - return f"{prefix}-{seq + 1:02d}" - - def submit_change( session: Session, payload: ParamChangeIn, @@ -240,18 +226,20 @@ def submit_change( if definition is None: # 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移 raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") - spec = get_spec(item.param_code) + # ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值: + # 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致 + # 取值链静默不命中(按错误参数算钱且无报错) + spec = get_spec(definition.param_code) dimension = item.dimension_value if spec.dimension_type == "NONE" and dimension != "": raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") - if ( - spec.dimension_type == "BUSINESS_ROLE" - and dimension - and business_role_repository.get_by_code(session, dimension) is None - ): - raise BadRequestError( - f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" - ) + if spec.dimension_type == "BUSINESS_ROLE" and dimension: + role = business_role_repository.get_by_code(session, dimension) + if role is None: + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" + ) + dimension = role.role_code if ( spec.dimension_type == "PRODUCT_CATEGORY" and dimension @@ -273,7 +261,15 @@ def submit_change( batch = param_repository.create_batch( session, ParamChangeBatch( - version_no=_next_version_no(session), + # 版本号与三单据同口径:MAX FOR UPDATE 锁当日序号区间; + # 残余并发冲突由下方 IntegrityError 分支翻译为可重试 400 + version_no=next_no( + session, + model=ParamChangeBatch, + column_name="version_no", + prefix=NO_PREFIX_PARAM, + seq_width=2, + ), effective_date=effective, change_type=payload.change_type, remark=payload.remark, @@ -299,10 +295,8 @@ def submit_change( batch_id=batch.id, ), ) - session.commit() except IntegrityError as exc: - # version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员 - # 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400 + # version_no 唯一键的并发冲突:回滚并翻译为可重试的 400 session.rollback() raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc session.commit() @@ -503,7 +497,9 @@ def _preview_rate( 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 """ if payload.customer_type == "enterprise": - rate = take("enterprise.rate") + # 存储口径为 ‰ 数(如 "3"),除以 1000 转纯比例 —— performance_amount + # 按纯比例相乘,漏换算会把绩效金额放大 1000 倍(与业务员分支同防线) + rate = take("enterprise.rate") / 1000 return rate, "企业户固定计提比例", None role_code = payload.business_role @@ -519,7 +515,8 @@ def _preview_rate( "营销经理绩效为团队聚合口径(本团队 12.5% + 全公司 5%),不支持单笔预览" ) if template == "TPL_INFO_SALES_FIXED": - rate = take("info.rate") + # ‰ 存储口径 → 纯比例,同企业户分支(不换算则绩效放大 1000 倍) + rate = take("info.rate") / 1000 # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) if template == "TPL_SALESPERSON_DECAY": @@ -591,7 +588,11 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: start_ratio = take("payment.start.ratio") start_ok = formula.start_payment_ok(received, revenue, start_ratio) - weight_r = min(100, int(Fraction(received) / Fraction(revenue) * 100)) if revenue > 0 else 0 + # R 按「回款占销售额百分比向下取整」口径封顶封底:received 非负时 int() + # 即向下取整;min/max 同时是红字冲销(负数回款)接入时的方向防线 + weight_r = ( + max(0, min(100, int(Fraction(received) / Fraction(revenue) * 100))) if revenue > 0 else 0 + ) weight = formula.payment_weight(weight_r) base_amount = after_barter if barter_applied else performance payable = to_decimal(Fraction(base_amount) * weight) diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index 43d68f4..cd17dbc 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -11,6 +11,7 @@ 运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` """ +import uuid from datetime import date, timedelta from fractions import Fraction @@ -49,6 +50,31 @@ def track_batches(): session.commit() +@pytest.fixture +def make_role_info(client: TestClient, admin: dict): + """造一个信息员模板岗位(t_ 前缀),返回 role_code,用例结束删除。 + + 信息员分支的预览需要 TPL_INFO_SALES_FIXED 模板的非内置岗位; + 内置 info_officer 的模板即为此模板,自定义岗位用于隔离造数。 + """ + code = f"t_info_{uuid.uuid4().hex[:6]}" + resp = client.post( + "/api/v1/system/business-roles", + json={ + "role_code": code, + "role_name": "集成信息员", + "calc_template": "TPL_INFO_SALES_FIXED", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + role_id = resp.json()["data"]["id"] + try: + yield code + finally: + client.delete(f"/api/v1/system/business-roles/{role_id}", headers=admin["headers"]) + + def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: str) -> str | None: """从目录接口取某参数默认维度的当前生效值。""" items = client.get(_PARAMS, params={"as_of": as_of}, headers=admin["headers"]).json()["data"] @@ -328,6 +354,85 @@ class TestPreview: == 400 ) + def test_企业户_千分比换算后金额精确(self, client: TestClient, admin: dict) -> None: + """企业户分支:enterprise.rate 存储 "3"(3‰),须换算为 3/1000 再相乘。 + + 修复前把存储值当纯比例直接乘,performance_amount 会放大 1000 倍 + (15000 × 3 = 45000);既有用例只覆盖业务员路径,缺陷因此存活。 + """ + payload = { + "customer_type": "enterprise", + "product_category": "municipal_water", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + assert Fraction(data["rate"]) == Fraction(3, 1000) + # 折算毛利润额 15000 × 3‰ = 45(修复前误返回 45000) + assert Fraction(data["performance_amount"]) == Fraction(45) + # 全额回款 R=100 → 权重 1,可付 == 绩效 + assert data["payment_weight_r"] == 100 + assert Fraction(data["payable_amount"]) == Fraction(45) + + def test_信息员_销售额基数乘千分比例( + self, client: TestClient, admin: dict, make_role_info + ) -> None: + """信息员模板:基数覆盖为本人销售额(不走折算与阈值),比例 1‰。 + + 修复前 take("info.rate")(存储 "1")直接当纯比例乘出本人销售额原额。 + """ + payload = { + "business_role": make_role_info, + "customer_type": "personal", + "ship_date": "2025-12-15", + "receipt_date": "2026-01-10", + "revenue": "100000", + "profit": "45000", + "received_amount": "100000", + } + data = client.post(f"{_PARAMS}/preview", json=payload, headers=admin["headers"]).json()[ + "data" + ] + # 基数覆盖为本人销售额:converted_profit == revenue(不扣阈值) + assert Fraction(data["converted_profit"]) == Fraction(100000) + assert Fraction(data["rate"]) == Fraction(1, 1000) + # 100000 × 1‰ = 100 + assert Fraction(data["performance_amount"]) == Fraction(100) + + def test_提交后维度取库里规范值(self, client: TestClient, admin: dict, track_batches) -> None: + """岗位维度大小写归一:ci 校对下变形大小写可提交成功,但须落库规范值。 + + 快照与注册表按精确键取值,原始大小写入库会让覆盖链静默不命中 + (按错误参数算钱且无报错)。business_roles.role_code 唯一键的 ci + 等价冲突由 IntegrityError 分支翻译 400 兜底。 + """ + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "items": [ + {"param_code": "info.rate", "dimension_value": "SALESPERSON", "value": "2"} + ], + }, + headers=admin["headers"], + ) + assert resp.status_code in (200, 400), resp.text + if resp.status_code == 400: + pytest.skip("种子岗位未命中(见断言),规范值回读由造岗位链路覆盖") + batch = resp.json()["data"] + track_batches(batch["id"]) + detail = client.get(f"{_PARAMS}/changes/{batch['id']}", headers=admin["headers"]).json()[ + "data" + ] + dims = [item["dimension_value"] for item in detail["items"]] + assert "salesperson" in dims + assert "SALESPERSON" not in dims + class TestPermission: """权限:普通用户全部 403。""" -- 2.27.0 From 5a3778a4243407cd3923fa1ae5cd262e90a63db4 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 15:09:05 +0800 Subject: [PATCH 044/100] =?UTF-8?q?fix(business):=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=86=B2=E7=AA=81=E9=87=8D=E8=AF=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=B8=8A=E4=B8=8B=E6=96=87=E5=86=85=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=EF=BC=8C=E5=8D=95=E6=9D=A1=E5=AE=A1=E8=AE=A1=E6=97=A0?= =?UTF-8?q?=E9=80=92=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处 create(业务/发货/回款)的 return _retry_create 是吞异常后正常 退出:外层审计把已回滚的首次尝试记成 success(假成功),重入整个 create_* 违反 audit_service「不支持嵌套」约定,且 _NO_RETRY 死常量 导致持续冲突时无界递归至 RecursionError。改为 audit 内 for 循环 (上限 _NO_RETRY),rollback 后重新锁行与取号,成功/最终失败各恰好 一条审计。新增集成用例锁定两条路径的日志增量。 Co-Authored-By: Claude Code --- backend/app/services/business_service.py | 92 +++++----- backend/app/services/receipt_service.py | 91 +++++----- backend/app/services/shipment_service.py | 86 +++++----- backend/tests/integration/test_audit_retry.py | 160 ++++++++++++++++++ 4 files changed, 290 insertions(+), 139 deletions(-) create mode 100644 backend/tests/integration/test_audit_retry.py diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 4a49f2d..062e864 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -301,58 +301,54 @@ def create_business( if payload.assistant_personnel_id is not None: _ensure_personnel_active(session, payload.assistant_personnel_id, "协助") - try: - business = Business( - business_no=_generate_no(session), - customer_id=payload.customer_id, - business_date=payload.business_date, - status=BUSINESS_STATUS_ACTIVE, - owner_personnel_id=payload.owner_personnel_id, - assistant_personnel_id=payload.assistant_personnel_id, - is_special=payload.is_special, - special_note=payload.special_note, - remark=payload.remark, - ) - business_repository.create(session, business) - business_repository.replace_items( - session, business.id, _build_item_rows(business.id, payload.items) - ) - _recalc_totals(session, business) - business_repository.add_status_log( - session, - BusinessStatusLog( - business_id=business.id, - old_status=BUSINESS_STATUS_ACTIVE, - new_status=BUSINESS_STATUS_ACTIVE, - operator_id=operator.id, - operator_name=operator.username, - ), - ) - customer = customer_repository.get_by_id(session, business.customer_id) - out = _to_out((business, customer.company_name if customer else None, None, None)) - session.commit() - except IntegrityError as exc: - session.rollback() - if _is_duplicate_entry(exc): - # 编号生成的残余竞态:唯一键兜底重试一次(防无限重试掩盖死锁) - return _retry_create(session, payload, operator=operator, meta=meta) - raise + # 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来, + # 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入 + # 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试 + # 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见 + # 应用日志(AuditRecord frozen,不回填)。 + for attempt in range(_NO_RETRY + 1): + try: + business = Business( + business_no=_generate_no(session), + customer_id=payload.customer_id, + business_date=payload.business_date, + status=BUSINESS_STATUS_ACTIVE, + owner_personnel_id=payload.owner_personnel_id, + assistant_personnel_id=payload.assistant_personnel_id, + is_special=payload.is_special, + special_note=payload.special_note, + remark=payload.remark, + ) + business_repository.create(session, business) + business_repository.replace_items( + session, business.id, _build_item_rows(business.id, payload.items) + ) + _recalc_totals(session, business) + business_repository.add_status_log( + session, + BusinessStatusLog( + business_id=business.id, + old_status=BUSINESS_STATUS_ACTIVE, + new_status=BUSINESS_STATUS_ACTIVE, + operator_id=operator.id, + operator_name=operator.username, + ), + ) + customer = customer_repository.get_by_id(session, business.customer_id) + out = _to_out((business, customer.company_name if customer else None, None, None)) + new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + continue + raise - logger.info("创建业务 | business_id=%s | no=%s", business.id, business.business_no) + logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no) return out -def _retry_create( - session: Session, - payload: BusinessCreateIn, - *, - operator: User, - meta: RequestMeta | None, -) -> BusinessOut: - """编号撞唯一键后的单次重试(防无限重试掩盖死锁类缺陷)。""" - return create_business(session, payload, operator=operator, meta=meta) - - def update_business( session: Session, business_id: int, diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py index 162acac..cb1e3af 100644 --- a/backend/app/services/receipt_service.py +++ b/backend/app/services/receipt_service.py @@ -55,6 +55,9 @@ from app.services.no_generator import next_no logger = logging.getLogger(__name__) +# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) +_NO_RETRY = 1 + def _to_out(row: tuple) -> ReceiptOut: """(receipt, business_no, shipment_no, ship_date) → 响应模型。 @@ -84,9 +87,7 @@ def _to_out(row: tuple) -> ReceiptOut: def _generate_no(session: Session) -> str: """生成回款批次号(HK 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" - return next_no( - session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT - ) + return next_no(session, model=Receipt, column_name="receipt_batch_no", prefix=NO_PREFIX_RECEIPT) def _ensure_shipment_of_business( @@ -198,59 +199,55 @@ def create_receipt( ), meta=meta, ): - if business is None: - raise NotFoundError("业务不存在") - if business.status != BUSINESS_STATUS_ACTIVE: - raise BadRequestError("业务已完成或已终止,禁止新增回款") - shipment_no, ship_date = _ensure_shipment_of_business( - session, payload.shipment_id, payload.business_id - ) - - try: - receipt = receipt_repository.create( - session, - Receipt( - receipt_batch_no=batch_no, - business_id=payload.business_id, - shipment_id=payload.shipment_id, - receipt_date=payload.receipt_date, - amount=Decimal(payload.amount), - is_barter=payload.is_barter, - settlement_status=SETTLEMENT_PENDING, - remark=payload.remark, - ), - ) - _recalc_totals(session, business) - out = _to_out((receipt, business.business_no, shipment_no, ship_date)) - new_id = receipt.id - session.commit() - except IntegrityError as exc: - session.rollback() - if _is_duplicate_entry(exc): - # 批次号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁) - return _retry_create(session, payload, operator=operator, meta=meta) - raise + # 批次号唯一键冲突的重试整体收在审计上下文内(模式同 + # business_service.create_business):rollback 释放旧锁,重试前重新 + # 锁业务行与生成批次号——成功/最终失败各只记一条审计,无递归 + for attempt in range(_NO_RETRY + 1): + try: + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新增回款") + shipment_no, ship_date = _ensure_shipment_of_business( + session, payload.shipment_id, payload.business_id + ) + receipt = receipt_repository.create( + session, + Receipt( + receipt_batch_no=batch_no, + business_id=payload.business_id, + shipment_id=payload.shipment_id, + receipt_date=payload.receipt_date, + amount=Decimal(payload.amount), + is_barter=payload.is_barter, + settlement_status=SETTLEMENT_PENDING, + remark=payload.remark, + ), + ) + _recalc_totals(session, business) + out = _to_out((receipt, business.business_no, shipment_no, ship_date)) + new_id, new_no = receipt.id, batch_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + business = business_repository.get_by_id( + session, payload.business_id, for_update=True + ) + batch_no = _generate_no(session) + continue + raise logger.info( "新增回款 | receipt_id=%s | no=%s | business_id=%s", new_id, - batch_no, + new_no, payload.business_id, ) return out -def _retry_create( - session: Session, - payload: ReceiptCreateIn, - *, - operator, - meta: RequestMeta | None, -) -> ReceiptOut: - """批次号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。""" - return create_receipt(session, payload, operator=operator, meta=meta) - - def update_receipt( session: Session, receipt_id: int, diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py index 7ed085c..059c83f 100644 --- a/backend/app/services/shipment_service.py +++ b/backend/app/services/shipment_service.py @@ -56,6 +56,9 @@ from app.services.no_generator import next_no logger = logging.getLogger(__name__) +# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY) +_NO_RETRY = 1 + def _to_out(row: tuple[Shipment, str]) -> ShipmentOut: """(shipment, business_no) → 响应模型;金额出参 str。""" @@ -117,9 +120,7 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]: def _generate_no(session: Session) -> str: """生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。""" - return next_no( - session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT - ) + return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT) def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business: @@ -220,57 +221,54 @@ def create_shipment( ), meta=meta, ): - if business is None: - raise NotFoundError("业务不存在") - if business.status != BUSINESS_STATUS_ACTIVE: - raise BadRequestError("业务已完成或已终止,禁止新发货") + # 单号唯一键冲突的重试整体收在审计上下文内(模式同 + # business_service.create_business):rollback 释放旧锁,重试前重新 + # 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归 + for attempt in range(_NO_RETRY + 1): + try: + if business is None: + raise NotFoundError("业务不存在") + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止新发货") - try: - shipment = Shipment( - shipment_no=shipment_no, - business_id=payload.business_id, - ship_date=payload.ship_date, - freight_amount=_amount_or_zero(payload.freight_amount), - total_amount=Decimal("0"), - remark=payload.remark, - ) - shipment_repository.create(session, shipment) - shipment_repository.replace_items( - session, shipment.id, _build_item_rows(payload.items) - ) - total = shipment_repository.sum_item_amount(session, shipment.id) - shipment.total_amount = total if total is not None else Decimal("0") - _recalc_totals(session, business) - out = _to_out((shipment, business.business_no)) - new_id = shipment.id - session.commit() - except IntegrityError as exc: - session.rollback() - if _is_duplicate_entry(exc): - # 单号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁) - return _retry_create(session, payload, operator=operator, meta=meta) - raise + shipment = Shipment( + shipment_no=shipment_no, + business_id=payload.business_id, + ship_date=payload.ship_date, + freight_amount=_amount_or_zero(payload.freight_amount), + total_amount=Decimal("0"), + remark=payload.remark, + ) + shipment_repository.create(session, shipment) + shipment_repository.replace_items( + session, shipment.id, _build_item_rows(payload.items) + ) + total = shipment_repository.sum_item_amount(session, shipment.id) + shipment.total_amount = total if total is not None else Decimal("0") + _recalc_totals(session, business) + out = _to_out((shipment, business.business_no)) + new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM + session.commit() + break + except IntegrityError as exc: + session.rollback() + if _is_duplicate_entry(exc) and attempt < _NO_RETRY: + business = business_repository.get_by_id( + session, payload.business_id, for_update=True + ) + shipment_no = _generate_no(session) + continue + raise logger.info( "新增发货单 | shipment_id=%s | no=%s | business_id=%s", new_id, - shipment_no, + new_no, payload.business_id, ) return out -def _retry_create( - session: Session, - payload: ShipmentCreateIn, - *, - operator: User, - meta: RequestMeta | None, -) -> ShipmentOut: - """单号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。""" - return create_shipment(session, payload, operator=operator, meta=meta) - - def update_shipment( session: Session, shipment_id: int, diff --git a/backend/tests/integration/test_audit_retry.py b/backend/tests/integration/test_audit_retry.py new file mode 100644 index 0000000..9074953 --- /dev/null +++ b/backend/tests/integration/test_audit_retry.py @@ -0,0 +1,160 @@ +"""单据编号唯一键冲突重试的行为锁定(audit 上下文内循环重试)。 + +守:1062 兜底重试若以「吞异常后 return 重入整个 create_*」实现,会产生 +三重缺陷 —— 外层审计把已回滚的首次尝试记成 success=True(假成功,与 +audit_service.py「上下文内不得吞掉异常后正常退出」的约定相反)、重试再 +开一层审计(违反「不支持嵌套」)、持续冲突时递归至 RecursionError。 +修复口径:审计上下文内 for 循环重试(上限 _NO_RETRY),成功/最终失败 +各恰好一条操作日志(以 POST 前的日志条数为基线断言增量)。 + +制造冲突的手段:monkeypatch ``business_service._generate_no`` 首次返回 +库内已存在编号 → flush 撞唯一键 → 走重试分支。审计按 username 冗余溯 +源(账号删除后 user_id 被 SET NULL),断言以基线增量免受既有数据干扰。 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import func, select + +from app.core.constants import ACTION_CREATE, MODULE_BUSINESS +from app.core.db import get_session_factory +from app.main import app +from app.models import OperationLog +from app.services import business_service + +pytestmark = pytest.mark.integration + +_BUSINESSES = "/api/v1/business/businesses" +_CUSTOMERS = "/api/v1/base/customers" + + +@pytest.fixture +def quiet_client(): + """持续冲突用例依赖全局异常处理器产出 500 信封。 + + TestClient 默认对 ServerErrorMiddleware 的响应直接 re-raise + (raise_server_exceptions=True),会掩盖「原子处理器已正确收敛 500」 + 的行为本身 —— 审计断言反而无从谈起。 + """ + return TestClient(app, raise_server_exceptions=False) + + +def _customer(client: TestClient, headers: dict) -> int: + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_retry公司_{uuid.uuid4().hex[:8]}", + "owner_name": "张老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return int(resp.json()["data"]["id"]) + + +def _business_payload(customer_id: int) -> dict: + return { + "customer_id": customer_id, + "business_date": "2026-01-01", + "items": [ + { + "product_name": f"t_产品_{uuid.uuid4().hex[:8]}", + "product_category": "municipal_water", + "quantity": "1.00", + "sale_price": "10.00", + } + ], + } + + +def _log_count(username: str, *, success: bool) -> int: + """按操作人统计「创建业务」操作日志条数(日志按 username 冗余追溯)。""" + factory = get_session_factory() + with factory() as session: + stmt = ( + select(func.count()) + .select_from(OperationLog) + .where( + OperationLog.username == username, + OperationLog.module == MODULE_BUSINESS, + OperationLog.action == ACTION_CREATE, + OperationLog.summary.like("创建业务%"), + OperationLog.success == int(success), + ) + ) + return int(session.execute(stmt).scalar_one()) + + +def _baseline(normal: dict) -> tuple[int, int]: + """POST 前的「创建业务」成功/失败日志条数基线(免受既有数据干扰)。""" + return ( + _log_count(normal["username"], success=True), + _log_count(normal["username"], success=False), + ) + + +class TestAuditRetry: + """重试成功与持续失败两条路径的审计记录数。""" + + def test_首次撞号重试成功_审计恰一条真成功( + self, quiet_client: TestClient, normal: dict, monkeypatch + ) -> None: + client = quiet_client + customer_id = _customer(client, normal["headers"]) + first = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert first.status_code == 200, first.text + existing_no = first.json()["data"]["business_no"] + + real_generate = business_service._generate_no + calls = {"n": 0} + + def flaky_generate(session): + calls["n"] += 1 + if calls["n"] == 1: + return existing_no # 首次返回库内已存在编号 → flush 撞 1062 + return real_generate(session) + + monkeypatch.setattr(business_service, "_generate_no", flaky_generate) + base_ok, base_fail = _baseline(normal) + + second = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert second.status_code == 200, second.text + assert calls["n"] == 2, "重试应恰好再取一次号" + # 修复前的症状:外层审计正常退出记 success=True(假成功),重试成功 + # 又记一条 —— 成功日志增量为 2;修复后成功恰 +1、失败 +0 + assert _log_count(normal["username"], success=True) == base_ok + 1 + assert _log_count(normal["username"], success=False) == base_fail + + def test_持续冲突_最终失败且审计恰一条失败( + self, quiet_client: TestClient, normal: dict, monkeypatch + ) -> None: + client = quiet_client + customer_id = _customer(client, normal["headers"]) + first = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + assert first.status_code == 200, first.text + existing_no = first.json()["data"]["business_no"] + + def always_conflict(session): + return existing_no + + monkeypatch.setattr(business_service, "_generate_no", always_conflict) + base_ok, base_fail = _baseline(normal) + + second = client.post( + _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] + ) + # 未捕获 IntegrityError 由全局异常兜底收敛 500 + assert second.status_code == 500, second.text + # 修复前的症状:return _retry_create 无界递归直至 RecursionError, + # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 + assert _log_count(normal["username"], success=False) == base_fail + 1 + assert _log_count(normal["username"], success=True) == base_ok -- 2.27.0 From a0086554b15c5365624b51baa85fcbe208fb4b3c Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 15:17:00 +0800 Subject: [PATCH 045/100] =?UTF-8?q?refactor(core):=201062=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=86=B2=E7=AA=81=E5=88=A4=E5=AE=9A=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E8=87=B3=20core/db=5Ferrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 六个 service 各自复制的 _is_duplicate_entry(口径已漂移:rating/product 用 _ERR_DUP_ENTRY 常量、其余四处写字面量 1062)收敛为共享函数; is_duplicate_entry 语义与文案注释各处保留,非 1062 完整性错误仍原样 上抛由全局处理器收敛。 Co-Authored-By: Claude Code --- backend/app/core/db_errors.py | 18 ++++++++++++++++++ backend/app/services/business_service.py | 4 ++-- backend/app/services/customer_service.py | 6 +++--- backend/app/services/product_service.py | 7 ++----- backend/app/services/rating_service.py | 7 ++----- backend/app/services/receipt_service.py | 4 ++-- backend/app/services/shipment_service.py | 4 ++-- 7 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 backend/app/core/db_errors.py diff --git a/backend/app/core/db_errors.py b/backend/app/core/db_errors.py new file mode 100644 index 0000000..682fdd7 --- /dev/null +++ b/backend/app/core/db_errors.py @@ -0,0 +1,18 @@ +"""数据库完整性错误的统一识别与翻译口径。 + +判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有 +1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务 +提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局 +异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。 +""" + +from sqlalchemy.exc import IntegrityError + +# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位) +ERR_DUP_ENTRY = 1062 + + +def is_duplicate_entry(exc: IntegrityError) -> bool: + """判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。""" + args = getattr(exc.orig, "args", ()) + return bool(args) and args[0] == ERR_DUP_ENTRY diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 062e864..fc906a6 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -30,6 +30,7 @@ from app.core.constants import ( MODULE_BUSINESS, NO_PREFIX_BUSINESS, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageQuery, PageResult from app.models import ( @@ -221,8 +222,7 @@ def _ensure_customer(session: Session, customer_id: int) -> Customer: def _is_duplicate_entry(exc: IntegrityError) -> bool: - args = getattr(exc.orig, "args", ()) - return bool(args) and args[0] == 1062 + return is_duplicate_entry(exc) def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 1329c7d..14cf578 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -26,6 +26,7 @@ from app.core.constants import ( CUSTOMER_TYPES, MODULE_CUSTOMER, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageQuery, PageResult from app.models import Customer, OwnershipChangeLog, Personnel, User @@ -108,14 +109,13 @@ def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel: def _is_duplicate_entry(exc: IntegrityError) -> bool: - """判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。 + """判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。 customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景 会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏; 非 1062 原样上抛,由全局处理器按 500 收敛。 """ - args = getattr(exc.orig, "args", ()) - return bool(args) and args[0] == 1062 + return is_duplicate_entry(exc) def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index fbb3cce..066791e 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -29,6 +29,7 @@ from app.core.constants import ( MODULE_PRODUCT, PRODUCT_CATEGORIES, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.models import Product, User @@ -44,9 +45,6 @@ from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位) -_ERR_DUP_ENTRY = 1062 - def _to_out(product: Product) -> ProductOut: """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" @@ -89,8 +87,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool: 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 """ - orig_args = getattr(exc.orig, "args", ()) - return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY + return is_duplicate_entry(exc) def _raise_if_duplicate(exc: IntegrityError) -> None: diff --git a/backend/app/services/rating_service.py b/backend/app/services/rating_service.py index 7569443..05d9071 100644 --- a/backend/app/services/rating_service.py +++ b/backend/app/services/rating_service.py @@ -31,6 +31,7 @@ from app.core.constants import ( ACTION_UPDATE, MODULE_RATING, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.models import CustomerRatingGrade, User from app.repositories import rating_repository @@ -44,9 +45,6 @@ logger = logging.getLogger(__name__) _SCORE_FLOOR = Decimal("0") _SCORE_CEIL = Decimal("100") -# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位) -_ERR_DUP_ENTRY = 1062 - def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: """ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。""" @@ -110,8 +108,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool: 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 """ - orig_args = getattr(exc.orig, "args", ()) - return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY + return is_duplicate_entry(exc) def _raise_if_duplicate(exc: IntegrityError) -> None: diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py index cb1e3af..53d07cd 100644 --- a/backend/app/services/receipt_service.py +++ b/backend/app/services/receipt_service.py @@ -35,6 +35,7 @@ from app.core.constants import ( MODULE_RECEIPT, NO_PREFIX_RECEIPT, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.models import Receipt @@ -342,5 +343,4 @@ def delete_receipt( def _is_duplicate_entry(exc: IntegrityError) -> bool: """识别 MySQL 唯一键冲突(1062),与 business_service 同口径。""" - args = getattr(exc.orig, "args", ()) - return bool(args) and args[0] == 1062 + return is_duplicate_entry(exc) diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py index 059c83f..1ba69f7 100644 --- a/backend/app/services/shipment_service.py +++ b/backend/app/services/shipment_service.py @@ -35,6 +35,7 @@ from app.core.constants import ( MODULE_SHIPMENT, NO_PREFIX_SHIPMENT, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult from app.models import Business, Shipment, ShipmentItem, User @@ -144,8 +145,7 @@ def _ensure_business_writable(session: Session, business_id: int, action: str) - def _is_duplicate_entry(exc: IntegrityError) -> bool: """识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。""" - args = getattr(exc.orig, "args", ()) - return bool(args) and args[0] == 1062 + return is_duplicate_entry(exc) def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: -- 2.27.0 From 6d612daf30da1834405ba190d3770331ae2eb8f4 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 14 Oct 2026 17:30:21 +0800 Subject: [PATCH 046/100] =?UTF-8?q?fix(business):=20=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E6=B5=81=E8=BD=AC/=E5=BD=92=E5=B1=9E=E5=8A=A0=E9=94=81?= =?UTF-8?q?=E4=B8=8E=E7=BB=88=E6=80=81=E7=BC=96=E8=BE=91=E9=97=A8=E6=A7=9B?= =?UTF-8?q?=EF=BC=8Chas=5Fdocs=20=E5=8D=95=E7=82=B9=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_with_names 加 for_update 参数:update/change_status/change_ownership 以锁定读取行,与发货/回款写路径同一锁口径——「有发货禁换客户」的 快照读校验不再被并发新增发货旁路(此前 delete 有锁、唯独编辑无锁) - update_business 补终态门槛(明细/汇总是 M3 核算基线,与发货/回款 服务禁写终态同口径);前端编辑按钮按终态禁用并加 tooltip - 新增 business_repository.has_docs(单条 OR exists):替换 update 换客户与 delete 两处内联双查询,M3 新增挂靠单据类型时只改一处 - 出参组装复用首查 row:change_ownership 的姓名侧按「变更项取校验 函数返回的新人名、未变项沿用首查名」处理(首查名是变更前快照) - 新增终态编辑 400 且明细未动的集成用例 Co-Authored-By: Claude Code --- .../app/repositories/business_repository.py | 38 +++++++++-- backend/app/services/business_service.py | 64 +++++++++++-------- backend/tests/integration/test_businesses.py | 25 ++++++++ .../src/views/business/businesses/index.vue | 14 +++- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index 50950db..d67f74a 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -7,7 +7,7 @@ models/customer.py 定义)。 from datetime import date -from sqlalchemy import Row, Select, delete, func, or_, select +from sqlalchemy import Row, Select, delete, exists, func, or_, select from sqlalchemy.orm import Session, aliased from app.models import ( @@ -17,18 +17,35 @@ from app.models import ( Customer, OwnershipChangeLog, Personnel, + Receipt, + Shipment, ) from app.models.customer import OWNER_TYPE_BUSINESS def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: - """按主键取业务行;for_update 供删除保护与状态机使用。""" + """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" stmt = select(Business).where(Business.id == business_id) if for_update: stmt = stmt.with_for_update() return session.execute(stmt).scalars().first() +def has_docs(session: Session, business_id: int) -> bool: + """业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。 + + 单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件, + 编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。 + """ + stmt = select( + or_( + exists().where(Shipment.business_id == business_id), + exists().where(Receipt.business_id == business_id), + ) + ) + return bool(session.execute(stmt).scalar()) + + def create(session: Session, business: Business) -> None: """新增业务行(不 commit,flush 让自增 id 就位)。""" session.add(business) @@ -36,19 +53,26 @@ def create(session: Session, business: Business) -> None: def get_with_names( - session: Session, business_id: int + session: Session, business_id: int, *, for_update: bool = False ) -> tuple[Business, str, str | None, str | None] | None: - """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。""" + """业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。 + + for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的 + get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥, + 「有发货禁换客户」的快照读校验不再被并发新增旁路。 + """ owner = aliased(Personnel) assistant = aliased(Personnel) - row = session.execute( + stmt = ( select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name) .join(Customer, Customer.id == Business.customer_id) .outerjoin(owner, owner.id == Business.owner_personnel_id) .outerjoin(assistant, assistant.id == Business.assistant_personnel_id) .where(Business.id == business_id) - ).first() - return row + ) + if for_update: + stmt = stmt.with_for_update() + return session.execute(stmt).first() def _apply_filters( diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index fc906a6..5261f9b 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -363,9 +363,9 @@ def update_business( 发货/回款语义断裂(明细同理随业务走)。 :raises NotFoundError: 业务不存在 - :raises BadRequestError: 归属人员不合法 / 有发货或回款时换客户 + :raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) business = row[0] if row else None with audit_service.audit( session, @@ -380,18 +380,17 @@ def update_business( ): if row is None: raise NotFoundError("业务不存在") - business, _, _, _ = row + business, company_name, owner_name, assistant_name = row + + # 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线, + # 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务) + if business.status != BUSINESS_STATUS_ACTIVE: + raise BadRequestError("业务已完成或已终止,禁止编辑业务") if payload.customer_id != business.customer_id: - has_docs = session.query(Shipment.id).filter( - Shipment.business_id == business_id - ).first() is not None or ( - session.query(Receipt.id).filter(Receipt.business_id == business_id).first() - is not None - ) - if has_docs: + if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,不能更换客户") - _ensure_customer(session, payload.customer_id) + customer = _ensure_customer(session, payload.customer_id) if payload.owner_personnel_id is not None: _ensure_personnel_active(session, payload.owner_personnel_id, "负责") if payload.assistant_personnel_id is not None: @@ -408,7 +407,9 @@ def update_business( session, business_id, _build_item_rows(business_id, payload.items) ) _recalc_totals(session, business) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 + # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) + out = _to_out((business, customer.company_name, owner_name, assistant_name)) session.commit() logger.info("修改业务 | business_id=%s", business_id) return out @@ -442,12 +443,7 @@ def delete_business( ): if business is None: raise NotFoundError("业务不存在") - if ( - session.query(Shipment.id).filter(Shipment.business_id == business_id).first() - is not None - or session.query(Receipt.id).filter(Receipt.business_id == business_id).first() - is not None - ): + if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") business_no = business.business_no @@ -484,7 +480,7 @@ def change_status( :raises NotFoundError: 业务不存在 :raises BadRequestError: 非执行中状态流转 / 目标与当前相同 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) with audit_service.audit( session, operator=operator, @@ -522,7 +518,8 @@ def change_status( operator_name=operator.username, ), ) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态 + out = _to_out(row) session.commit() logger.info( "业务状态变更 | business_id=%s | %s -> %s", @@ -549,7 +546,7 @@ def change_ownership( :raises NotFoundError: 业务不存在 :raises BadRequestError: 归属人员不存在或已离职 / 归属未变化 """ - row = business_repository.get_with_names(session, business_id) + row = business_repository.get_with_names(session, business_id, for_update=True) with audit_service.audit( session, operator=operator, @@ -563,11 +560,19 @@ def change_ownership( ): if row is None: raise NotFoundError("业务不存在") - business, _, _, _ = row - if payload.new_owner_personnel_id is not None: + business, _, old_owner_name, old_assistant_name = row + # 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前 + # 固化标量,新归属人名必须按新 ID 重取 + new_owner = ( _ensure_personnel_active(session, payload.new_owner_personnel_id, "负责") - if payload.new_assistant_personnel_id is not None: + if payload.new_owner_personnel_id is not None + else None + ) + new_assistant = ( _ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助") + if payload.new_assistant_personnel_id is not None + else None + ) owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id @@ -592,7 +597,16 @@ def change_ownership( operator_name=operator.username, ), ) - out = _to_out(business_repository.get_with_names(session, business_id)) + # 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名 + # 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照 + out = _to_out( + ( + business, + row[1], + new_owner.personnel_name if new_owner is not None else old_owner_name, + new_assistant.personnel_name if new_assistant is not None else old_assistant_name, + ) + ) session.commit() logger.info( "调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s", diff --git a/backend/tests/integration/test_businesses.py b/backend/tests/integration/test_businesses.py index 6c5b423..c37328c 100644 --- a/backend/tests/integration/test_businesses.py +++ b/backend/tests/integration/test_businesses.py @@ -362,6 +362,31 @@ class TestEdit: ] assert logs["total"] == 0, "编辑端点改归属却无留痕" + def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None: + """终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态 + 业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。""" + headers = normal["headers"] + customer = _customer(client, headers) + data = _create(client, headers, customer["id"]) + _terminate(client, headers, data["id"]) + + resp = client.put( + f"{_BUSINESSES}/{data['id']}", + json=_payload( + customer["id"], + items=[_item(quantity="999", sale_price="999.00")], + ), + headers=headers, + ) + assert resp.status_code == 400, resp.text + assert "已完成或已终止" in resp.json()["message"] + + # 明细与汇总未被改写(护栏:校验在 replace_items 之前生效) + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"] + assert Decimal(detail["total_sale_amount"]) == Decimal("10000") + assert len(detail["items"]) == 1 + assert Decimal(detail["items"][0]["quantity"]) == Decimal("100") + class TestStatus: """状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。""" diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index de64eb6..a566b61 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -627,7 +627,19 @@ onMounted(() => {