diff --git a/backend/alembic/versions/5e8a2d9c7b41_v9_图片版本号破缓存字段.py b/backend/alembic/versions/5e8a2d9c7b41_v9_图片版本号破缓存字段.py new file mode 100644 index 0000000..64d9923 --- /dev/null +++ b/backend/alembic/versions/5e8a2d9c7b41_v9_图片版本号破缓存字段.py @@ -0,0 +1,53 @@ +"""V9 图片版本号破缓存字段 + +Revision ID: 5e8a2d9c7b41 +Revises: 9d2f4c7b1e60 +Create Date: 2026-10-20 11:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "5e8a2d9c7b41" +down_revision: str | Sequence[str] | None = "9d2f4c7b1e60" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。 + + logo/favicon 用固定文件名覆盖落盘,同扩展名替换时路径与 update_time + 都不变,无法用它们破浏览器缓存;改为上传即自增的版本号字段,图片 URL + 携带 ?v={version}。 + """ + op.add_column( + "system_settings", + sa.Column( + "logo_version", + sa.Integer(), + server_default="0", + nullable=False, + comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "favicon_version", + sa.Integer(), + server_default="0", + nullable=False, + comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)", + ), + ) + + +def downgrade() -> None: + """回滚迁移。""" + op.drop_column("system_settings", "favicon_version") + op.drop_column("system_settings", "logo_version") diff --git a/backend/alembic/versions/9d2f4c7b1e60_v8_精简公司档案并增加站点信息.py b/backend/alembic/versions/9d2f4c7b1e60_v8_精简公司档案并增加站点信息.py new file mode 100644 index 0000000..a9cb828 --- /dev/null +++ b/backend/alembic/versions/9d2f4c7b1e60_v8_精简公司档案并增加站点信息.py @@ -0,0 +1,101 @@ +"""V8 精简公司档案并增加站点信息 + +Revision ID: 9d2f4c7b1e60 +Revises: c4e2a86f9b13 +Create Date: 2026-10-20 10:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "9d2f4c7b1e60" +down_revision: str | Sequence[str] | None = "c4e2a86f9b13" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移(2026-10-20 用户确认的方案)。 + + - 删除工资系统用不上的档案字段:系统副标题、信用代码、地址、电话、邮箱 + - 新增站点信息:站点标题(浏览器标签页/侧边栏副标题)与站点图标 + (浏览器标签页 favicon,独立于登录页 logo) + """ + op.drop_column("system_settings", "system_subtitle") + op.drop_column("system_settings", "credit_code") + op.drop_column("system_settings", "company_address") + op.drop_column("system_settings", "contact_phone") + op.drop_column("system_settings", "contact_email") + op.add_column( + "system_settings", + sa.Column( + "site_title", + sa.String(length=64), + nullable=True, + comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "favicon_path", + sa.String(length=255), + nullable=True, + comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)", + ), + ) + + +def downgrade() -> None: + """回滚迁移:恢复 V7 的档案字段并移除站点信息字段。""" + op.drop_column("system_settings", "favicon_path") + op.drop_column("system_settings", "site_title") + op.add_column( + "system_settings", + sa.Column( + "system_subtitle", + sa.String(length=32), + nullable=True, + comment="系统副标题(侧边栏品牌区/浏览器标题;null=内置默认)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "credit_code", + sa.String(length=32), + nullable=True, + comment="统一社会信用代码(预留导出页眉/单据)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "company_address", + sa.String(length=255), + nullable=True, + comment="公司地址(预留导出页眉)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "contact_phone", + sa.String(length=32), + nullable=True, + comment="联系电话(系统支持信息,预留)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "contact_email", + sa.String(length=64), + nullable=True, + comment="联系邮箱(系统支持信息,预留)", + ), + ) diff --git a/backend/alembic/versions/c4e2a86f9b13_v7_客户评级落库与公司信息档案字段.py b/backend/alembic/versions/c4e2a86f9b13_v7_客户评级落库与公司信息档案字段.py new file mode 100644 index 0000000..3afce3f --- /dev/null +++ b/backend/alembic/versions/c4e2a86f9b13_v7_客户评级落库与公司信息档案字段.py @@ -0,0 +1,220 @@ +"""V7 客户评级落库与公司信息档案字段 + +Revision ID: c4e2a86f9b13 +Revises: 79bcc5aa98d4 +Create Date: 2026-10-19 10:00:00.000000 + +""" + +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 = "c4e2a86f9b13" +down_revision: str | Sequence[str] | None = "79bcc5aa98d4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """应用迁移。 + + 双主题合并一次交付(2026-10-19): + - 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照) + - 公司信息补充:system_settings 增加副标题/标语/页脚等企业档案字段 + """ + op.create_table( + "customer_ratings", + sa.Column( + "id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID" + ), + sa.Column( + "customer_id", + mysql.BIGINT(unsigned=True), + nullable=False, + comment="客户ID(唯一:一客户一份当前评级)", + ), + sa.Column( + "score_ship_amount", + sa.Numeric(precision=5, scale=1), + server_default="0", + nullable=False, + comment="发货额评分(0-20)", + ), + sa.Column( + "score_receipt_rate", + sa.Numeric(precision=5, scale=1), + server_default="0", + nullable=False, + comment="回款率评分(0-20)", + ), + sa.Column( + "score_profit_rate", + sa.Numeric(precision=5, scale=1), + server_default="0", + nullable=False, + comment="毛利率评分(0-20)", + ), + sa.Column( + "score_contract_cooperation", + sa.Numeric(precision=5, scale=1), + server_default="0", + nullable=False, + comment="合同履约配合能力评分(0-20)", + ), + sa.Column( + "score_site_construction", + sa.Numeric(precision=5, scale=1), + server_default="0", + nullable=False, + comment="现场规范施工能力评分(0-20)", + ), + sa.Column( + "total_score", + sa.Numeric(precision=5, scale=1), + nullable=False, + comment="总分(服务端重算,五项之和 0-100)", + ), + sa.Column( + "grade_name", + sa.String(length=32), + nullable=True, + comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)", + ), + sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"), + sa.Column( + "scorer_id", + mysql.BIGINT(unsigned=True), + nullable=True, + comment="评级操作人ID(账号删除后置空)", + ), + sa.Column( + "scorer_name", + sa.String(length=64), + nullable=False, + comment="评级操作人用户名(冗余固化)", + ), + sa.Column( + "create_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="创建时间", + ), + sa.Column( + "update_time", + sa.DateTime(), + server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), + nullable=False, + comment="更新时间", + ), + sa.ForeignKeyConstraint( + ["customer_id"], + ["customers.id"], + name=op.f("fk_customer_ratings_customer_id"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["scorer_id"], + ["users.id"], + name=op.f("fk_customer_ratings_scorer_id"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_customer_ratings")), + sa.UniqueConstraint("customer_id", name=op.f("uk_customer_ratings_customer_id")), + comment="客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_general_ci", + mysql_engine="InnoDB", + ) + op.add_column( + "system_settings", + sa.Column( + "system_subtitle", + sa.String(length=32), + nullable=True, + comment="系统副标题(侧边栏品牌区/浏览器标题;null=内置默认)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "brand_slogan", + sa.String(length=64), + nullable=True, + comment="品牌标语(登录页与品牌预览;null=内置默认)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "footer_text", + sa.String(length=64), + nullable=True, + comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "icp_no", + sa.String(length=32), + nullable=True, + comment="ICP 备案号(登录页页脚,空=不显示)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "credit_code", + sa.String(length=32), + nullable=True, + comment="统一社会信用代码(预留导出页眉/单据)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "company_address", + sa.String(length=255), + nullable=True, + comment="公司地址(预留导出页眉)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "contact_phone", + sa.String(length=32), + nullable=True, + comment="联系电话(系统支持信息,预留)", + ), + ) + op.add_column( + "system_settings", + sa.Column( + "contact_email", + sa.String(length=64), + nullable=True, + comment="联系邮箱(系统支持信息,预留)", + ), + ) + + +def downgrade() -> None: + """回滚迁移。 + + 先删评级表(引用 customers/users),再逆序移除公司档案列。 + """ + op.drop_table("customer_ratings") + op.drop_column("system_settings", "contact_email") + op.drop_column("system_settings", "contact_phone") + op.drop_column("system_settings", "company_address") + op.drop_column("system_settings", "credit_code") + op.drop_column("system_settings", "icp_no") + op.drop_column("system_settings", "footer_text") + op.drop_column("system_settings", "brand_slogan") + op.drop_column("system_settings", "system_subtitle") diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py index 26b6d67..09b76a5 100644 --- a/backend/app/api/v1/base/customers.py +++ b/backend/app/api/v1/base/customers.py @@ -24,7 +24,8 @@ from app.schemas.customer import ( CustomerUpdateIn, OwnershipChangeLogOut, ) -from app.services import customer_service +from app.schemas.rating import CustomerRatingOut, CustomerRatingSaveIn +from app.services import customer_service, rating_service router = APIRouter(prefix="/customers", tags=["客户管理"]) @@ -140,3 +141,45 @@ def list_ownership_logs( ) -> ApiResponse[PageResult[OwnershipChangeLogOut]]: """查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。""" return ok(customer_service.list_ownership_logs(session, customer_id, query)) + + +@router.get( + "/{customer_id}/rating", + response_model=ApiResponse[CustomerRatingOut], + summary="客户当前评级", +) +def get_customer_rating( + session: DbSession, + _: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], +) -> ApiResponse[CustomerRatingOut]: + """查某客户当前评级。 + + 未评级返回 ``data: null``(这是正常态而非错误);客户不存在才 404。 + """ + return ok(rating_service.get_customer_rating(session, customer_id)) + + +@router.put( + "/{customer_id}/rating", + response_model=ApiResponse[CustomerRatingOut], + summary="保存客户评级", +) +def save_customer_rating( + session: DbSession, + current_user: CurrentUser, + customer_id: Annotated[int, Path(ge=1)], + payload: CustomerRatingSaveIn, + meta: RequestMetaDep, +) -> ApiResponse[CustomerRatingOut]: + """保存客户评级(首次创建、再次保存整体覆盖)。 + + 五项分值 0-20 由服务层在审计上下文内校验,总分与等级由服务端重算; + 等级按保存时点的映射快照固化,等级表后续增删改不影响该客户结论。 + """ + return ok( + rating_service.save_customer_rating( + session, customer_id, payload, operator=current_user, meta=meta + ), + message="评级已保存", + ) diff --git a/backend/app/api/v1/system/settings.py b/backend/app/api/v1/system/settings.py index d21a677..a22c313 100644 --- a/backend/app/api/v1/system/settings.py +++ b/backend/app/api/v1/system/settings.py @@ -1,11 +1,11 @@ -"""系统设置接口(公司信息)。 +"""系统设置接口(公司信息 + 站点信息)。 -`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时 -尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py), -仅暴露两个非敏感展示字段。 +`GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页 +渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经 +`SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。 -`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径 -(二进制响应套 JSON 信封无意义)。 +`/logo/public` 与 `/favicon/public` 返回文件流而非统一信封——与参数导出 +同一例外口径(二进制响应套 JSON 信封无意义)。 """ from fastapi import APIRouter, Response, UploadFile @@ -19,6 +19,7 @@ from app.services import system_setting_service as service router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"]) _LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"} +_FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"} @router.get("", response_model=ApiResponse[SettingOut], summary="公司信息") @@ -27,14 +28,14 @@ def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]: return ok(service.to_out(service.get_setting(session))) -@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称") +@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司信息") def update_settings( payload: SettingUpdateIn, session: DbSession, current_user: AdminUser, meta: RequestMetaDep, ) -> ApiResponse[SettingOut]: - """更新公司名称(审计留痕)。""" + """更新公司信息(部分更新;审计留痕)。""" return ok( service.update_company(session, payload, operator=current_user, meta=meta), message="已保存", @@ -57,6 +58,22 @@ async def upload_logo( ) +@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标") +async def upload_favicon( + file: UploadFile, + session: DbSession, + current_user: AdminUser, + meta: RequestMetaDep, +) -> ApiResponse[SettingOut]: + """上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。""" + content = await file.read() + ext = (file.filename or "").rsplit(".", 1)[-1] + return ok( + service.save_favicon(session, content, ext, operator=current_user, meta=meta), + message="站点图标已更新", + ) + + @router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)") def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]: """公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。""" @@ -73,3 +90,14 @@ def get_logo_public(session: DbSession) -> FileResponse: media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"), headers={"Cache-Control": "no-cache"}, ) + + +@router.get("/favicon/public", summary="站点图标(匿名)") +def get_favicon_public(session: DbSession) -> FileResponse: + """站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。""" + path, ext = service.read_favicon(session) + return FileResponse( + path, + media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"), + headers={"Cache-Control": "no-cache"}, + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 066c6f8..0f124e5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,6 +14,7 @@ from app.models.base import Base, TimestampMixin from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation from app.models.business_role import BusinessRole from app.models.customer import Customer, OwnershipChangeLog +from app.models.customer_rating import CustomerRating from app.models.login_log import LoginLog from app.models.operation_log import OperationLog from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue @@ -35,6 +36,7 @@ __all__ = [ "BusinessRole", "BusinessStatusLog", "Customer", + "CustomerRating", "CustomerRatingGrade", "LoginLog", "OperationLog", diff --git a/backend/app/models/customer_rating.py b/backend/app/models/customer_rating.py new file mode 100644 index 0000000..644994d --- /dev/null +++ b/backend/app/models/customer_rating.py @@ -0,0 +1,77 @@ +"""客户评级模型(需求 2.2.5)。 + +评级从「纯计算器不落库」升级为**每客户一行落库**(2026-10-19 确认): +客户管理页要罗列评级列并按其排序,评分必须可回溯。设计要点: + +- ``customer_id`` 唯一键 + RESTRICT 外键:一客户一份当前评级(快照语义, + 非流水),删除客户由服务层先删评级行再删主行(RESTRICT 兜底并发窗口) +- ``grade_name`` 为**快照固化、无外键**:等级映射表仍允许自由增删清空 + (rating_service 的既有前提),删等级不能伤及已评级客户的历史结论 —— + 与归属留痕「ID/人名固化、不回溯」同一论证口径;等级未命中时为 NULL +- ``scorer_id`` 外键 SET NULL + ``scorer_name`` 冗余固化:账号删除后 + 记录仍可读(同 ownership_change_logs 的操作人口径) +- 分值列 DECIMAL(5,1):UI 整数 0-20,DECIMAL 留十进制余地;总分由 + **服务端重算**,不采信前端回传 +""" + +from decimal import Decimal + +from sqlalchemy import ForeignKey, Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args + + +class CustomerRating(Base, TimestampMixin): + """客户评级表:每客户一行(uk_customer_ratings_customer_id)。 + + 五项各 0-20 分、总分 = 五项之和(0-100),判定规则同等级映射表 + (按 sort_order 升序首个 min_score ≤ 总分,见 rating_service.resolve_grade)。 + """ + + __tablename__ = "customer_ratings" + __table_args__ = mysql_table_args( + "客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)", + UniqueConstraint("customer_id", name="uk_customer_ratings_customer_id"), + ) + + id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" + ) + customer_id: Mapped[int] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("customers.id", ondelete="RESTRICT"), + nullable=False, + comment="客户ID(唯一:一客户一份当前评级)", + ) + score_ship_amount: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, server_default="0", comment="发货额评分(0-20)" + ) + score_receipt_rate: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, server_default="0", comment="回款率评分(0-20)" + ) + score_profit_rate: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, server_default="0", comment="毛利率评分(0-20)" + ) + score_contract_cooperation: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, server_default="0", comment="合同履约配合能力评分(0-20)" + ) + score_site_construction: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, server_default="0", comment="现场规范施工能力评分(0-20)" + ) + total_score: Mapped[Decimal] = mapped_column( + Numeric(5, 1), nullable=False, comment="总分(服务端重算,五项之和 0-100)" + ) + grade_name: Mapped[str | None] = mapped_column( + String(32), + comment="评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)", + ) + remark: Mapped[str | None] = mapped_column(String(255), comment="备注") + scorer_id: Mapped[int | None] = mapped_column( + BIGINT_UNSIGNED, + ForeignKey("users.id", ondelete="SET NULL"), + comment="评级操作人ID(账号删除后置空)", + ) + scorer_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="评级操作人用户名(冗余固化)" + ) diff --git a/backend/app/models/rating.py b/backend/app/models/rating.py index f0310c3..2610b55 100644 --- a/backend/app/models/rating.py +++ b/backend/app/models/rating.py @@ -1,9 +1,10 @@ """客户评级等级配置模型(需求 2.2.5)。 -评级计算器定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分记录 -不落库,等级**映射关系**可配置由本表承载。判定规则:按 sort_order 升序取 -第一个 min_score ≤ 总分的等级 —— 单边判定避免区间边界(如 75 分)在 -「≥75 且 ≤89」与「>74 且 <75」两套写法间的重叠/遗漏歧义;max_score 仅展示。 +评级定位为「仅打分工具,不影响绩效金额」(对照表 #25):评分自 2026-10-19 +起每客户落库于 customer_ratings(快照固化等级名),本表承载可配置的等级 +**映射关系**。判定规则:按 sort_order 升序取第一个 min_score ≤ 总分的 +等级 —— 单边判定避免区间边界(如 75 分)在「≥75 且 ≤89」与「>74 且 <75」 +两套写法间的重叠/遗漏歧义;max_score 仅展示。 """ from decimal import Decimal diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py index 334e8aa..9accb56 100644 --- a/backend/app/models/system_setting.py +++ b/backend/app/models/system_setting.py @@ -1,31 +1,67 @@ -"""系统设置模型:公司信息单例行。 +"""系统设置模型:公司信息 + 站点信息单例行。 与 M1 计提参数体系刻意分离:公司名称/logo 是「展示事实」,没有按生效日期 时点化演算的语义(参数体系按 effective_date 取值快照),且参数值列 -value_text 为 String(64) 装不下;故独立单行表。logo 只存相对路径——文件在 -`backend/uploads/logo/`,独立于数据库备份体积且可单独替换(见 -system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底,upsert 维护)。 +value_text 为 String(64) 装不下;故独立单行表。logo/favicon 只存相对路径—— +文件在 `backend/uploads/{logo,favicon}/`,独立于数据库备份体积且可单独替换 +(见 system_setting_service)。整个系统恒为单行(迁移种子 id=1 保底)。 + +2026-10-20 精简(用户确认):企业档案字段(信用代码/地址/电话/邮箱)与 +系统副标题删除,新增**站点标题**(浏览器标签页/侧边栏副标题)与**站点图标** +(浏览器标签页 favicon,独立于登录页 logo)。 """ -from sqlalchemy import String +from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args class SystemSetting(Base, TimestampMixin): - """系统设置表:本期仅公司信息(公司名称 / logo 路径),全系统单行 id=1。""" + """系统设置表:公司信息 + 站点信息,全系统单行 id=1。 + + brand_slogan/footer_text/icp_no/site_title 均可空:null 表示前端回退 + 内置默认值,存量行为零变化。 + """ __tablename__ = "system_settings" - __table_args__ = mysql_table_args("系统设置表(公司信息,全系统单行)") + __table_args__ = mysql_table_args("系统设置表(公司信息与站点信息,全系统单行)") id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" ) company_name: Mapped[str] = mapped_column( - String(64), nullable=False, comment="公司名称(登录页与导出页眉展示)" + String(64), nullable=False, comment="公司名称(登录页/侧边栏/浏览器标题展示)" ) logo_path: Mapped[str | None] = mapped_column( String(255), - comment="logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", + comment="公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)", + ) + logo_version: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default="0", + comment="logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)", + ) + site_title: Mapped[str | None] = mapped_column( + String(64), comment="站点标题(浏览器标签页与侧边栏副标题;null=内置默认)" + ) + brand_slogan: Mapped[str | None] = mapped_column( + String(64), comment="品牌标语(登录页与品牌预览;null=内置默认)" + ) + footer_text: Mapped[str | None] = mapped_column( + String(64), comment="页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)" + ) + icp_no: Mapped[str | None] = mapped_column( + String(32), comment="ICP 备案号(登录页页脚,空=不显示)" + ) + favicon_path: Mapped[str | None] = mapped_column( + String(255), + comment="站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)", + ) + favicon_version: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default="0", + comment="站点图标版本号(每次上传 +1;图片 URL 用它破缓存)", ) diff --git a/backend/app/repositories/customer_rating_repository.py b/backend/app/repositories/customer_rating_repository.py new file mode 100644 index 0000000..2b5c03f --- /dev/null +++ b/backend/app/repositories/customer_rating_repository.py @@ -0,0 +1,33 @@ +"""客户评级数据访问(每客户一行)。 + +本层只 flush 不 commit —— 事务边界由 services 层控制。 +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import CustomerRating + +# ==================== 读取 ==================== + + +def get_by_customer_id(session: Session, customer_id: int) -> CustomerRating | None: + """查某客户的当前评级(uk_customer_ratings_customer_id 保证至多一行)。""" + stmt = select(CustomerRating).where(CustomerRating.customer_id == customer_id) + return session.execute(stmt).scalar_one_or_none() + + +# ==================== 写入 ==================== + + +def create(session: Session, rating: CustomerRating) -> CustomerRating: + """新增评级行。只 flush 以取得自增 id。""" + session.add(rating) + session.flush() + return rating + + +def delete(session: Session, rating: CustomerRating) -> None: + """删除评级行(客户物理删除前的联动清理,调用方先校验业务引用)。""" + session.delete(rating) + session.flush() diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index 9e10511..d4dc163 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -6,7 +6,7 @@ from sqlalchemy import ColumnElement, Row, Select, func, or_, select from sqlalchemy.orm import Session -from app.models import Customer, OwnershipChangeLog, Personnel +from app.models import Customer, CustomerRating, OwnershipChangeLog, Personnel from app.models.customer import OWNER_TYPE_CUSTOMER from app.repositories import ownership_log_repository from app.repositories.query_utils import fuzzy @@ -31,7 +31,7 @@ def count_business_refs(session: Session, customer_id: int) -> int: 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() @@ -53,12 +53,14 @@ def list_customers( keyword: str | None = None, customer_type: str | None = None, owner_personnel_id: int | None = None, + order_field: str | None = None, + order_type: str | None = None, ) -> list[Row]: - """分页查询客户,行内容为 (客户, 归属人员姓名)。 + """分页查询客户,行内容为 (客户, 归属人员姓名, 评级总分, 评级等级)。 - 归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据) - 不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。 - 排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。 + 归属人员/评级行均用 outerjoin:关联行不可考(人员被物理删除、客户 + 未评级)不应让客户行整个查不出来(同 personnel_repository 韧性口径)。 + 默认排序按 id 倒序:新增的靠前符合列表页习惯;评级排序见 ``_apply_order``。 """ stmt = _apply_filters( _base_select(), @@ -66,7 +68,8 @@ def list_customers( customer_type=customer_type, owner_personnel_id=owner_personnel_id, ) - stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit) + stmt = _apply_order(stmt, order_field=order_field, order_type=order_type) + stmt = stmt.offset(offset).limit(limit) return list(session.execute(stmt).all()) @@ -120,12 +123,38 @@ def count_ownership_logs(session: Session, *, customer_id: int) -> int: def _base_select() -> Select: - """客户主表外联归属人员姓名。""" - return select(Customer, Personnel.personnel_name).outerjoin( - Personnel, Customer.owner_personnel_id == Personnel.id + """客户主表外联归属人员姓名与评级快照(等级名/总分,列表评级列免 N+1)。""" + return ( + select( + Customer, + Personnel.personnel_name, + CustomerRating.total_score, + CustomerRating.grade_name, + ) + .outerjoin(Personnel, Customer.owner_personnel_id == Personnel.id) + .outerjoin(CustomerRating, CustomerRating.customer_id == Customer.id) ) +def _apply_order(stmt: Select, *, order_field: str | None, order_type: str | None) -> Select: + """应用列表排序。默认 id 倒序;order_field=rating 按评级总分排序。 + + 等级由总分判定、与总分同向,故按总分即等价按等级排序。MySQL 没有 + ``NULLS LAST`` 语法,用布尔键 ``total_score IS NULL`` 升序模拟 —— + 未评级行恒在末尾,正序/倒序一致;同分以 id 倒序兜底保证次序稳定。 + 非法字段/方向不落到 SQL(schema Literal 已在入口拦 400),此处静默 + 回退默认排序作为纵深防御。 + """ + if order_field == "rating" and order_type in ("asc", "desc"): + score_order = ( + CustomerRating.total_score.asc() + if order_type == "asc" + else CustomerRating.total_score.desc() + ) + return stmt.order_by(CustomerRating.total_score.is_(None), score_order, Customer.id.desc()) + return stmt.order_by(Customer.id.desc()) + + def _apply_filters( stmt: Select, *, diff --git a/backend/app/repositories/rating_repository.py b/backend/app/repositories/rating_repository.py index 9a39849..1cb4e04 100644 --- a/backend/app/repositories/rating_repository.py +++ b/backend/app/repositories/rating_repository.py @@ -32,8 +32,9 @@ def list_all(session: Session, *, for_update: bool = False) -> list[CustomerRati def get_by_id(session: Session, grade_id: int) -> CustomerRatingGrade | None: """按主键查询。 - 等级表无下游引用(评级是纯计算器不落评分记录),无需 ``for_update`` - 锁行 —— product_repository 锁行防的是并发引用悬空,此处不存在该风险。 + 等级表对客户评级无**外键**引用(评级行快照固化等级名),无需 + ``for_update`` 锁行 —— product_repository 锁行防的是并发引用悬空, + 此处不存在该风险。 """ return session.get(CustomerRatingGrade, grade_id) @@ -60,6 +61,6 @@ def create(session: Session, grade: CustomerRatingGrade) -> CustomerRatingGrade: def delete(session: Session, grade: CustomerRatingGrade) -> None: - """物理删除等级。等级表无下游引用,调用方无需引用保护校验。""" + """物理删除等级。等级表对客户评级无外键引用,调用方无需引用保护校验。""" session.delete(grade) session.flush() diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index fde79e3..8e77c45 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -1,6 +1,7 @@ """客户管理的请求/响应模型(需求 2.1.3)。""" from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -13,6 +14,10 @@ class CustomerOut(BaseModel): owner_personnel_name 由 LEFT JOIN 带出:归属人员不可考(被物理删除的 脏数据)时为 null,owner_personnel_id 仍返回原值供追溯 —— 与归属留痕 「ID 固化、姓名随联表」同一口径。 + + rating_total / rating_grade_name 由 LEFT JOIN customer_ratings 带出 + (列表评级列免 N+1):未评级时均为 null;总分按分值红线以字符串出参, + 等级为保存时点的快照固化值(见 models/customer_rating.py)。 """ model_config = ConfigDict(from_attributes=True) @@ -28,6 +33,8 @@ class CustomerOut(BaseModel): owner_personnel_id: int | None = None owner_personnel_name: str | None = None remark: str | None = None + rating_total: str | None = None + rating_grade_name: str | None = None class CustomerCreateIn(BaseModel): @@ -97,6 +104,14 @@ class CustomerQuery(PageQuery): ) customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") + # 排序白名单(全项目首个列表排序参数):非法值由 Literal 拦下、经全局 + # 异常处理器转 400 —— 不把排序字段透传给 SQL,避免注入面 + order_field: Literal["rating"] | None = Field( + default=None, description="排序字段白名单:rating=客户评级总分(等级由总分判定、同向)" + ) + order_type: Literal["asc", "desc"] | None = Field( + default=None, description="排序方向:asc 正序 / desc 倒序(未评级恒在末尾)" + ) class CustomerOwnershipIn(BaseModel): diff --git a/backend/app/schemas/rating.py b/backend/app/schemas/rating.py index 18fe979..dbb59f3 100644 --- a/backend/app/schemas/rating.py +++ b/backend/app/schemas/rating.py @@ -77,3 +77,55 @@ class RatingGradeUpdateIn(RatingGradeCreateIn): 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 """ + + +class CustomerRatingOut(BaseModel): + """客户评级响应(每客户一行;总分与分值按红线以字符串出参)。 + + grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续 + 增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级 + (总分低于全部下限或等级表被清空)。 + """ + + customer_id: int + score_ship_amount: str + score_receipt_rate: str + score_profit_rate: str + score_contract_cooperation: str + score_site_construction: str + total_score: str + grade_name: str | None = None + remark: str | None = None + scorer_name: str + create_time: datetime + update_time: datetime + + +class CustomerRatingSaveIn(BaseModel): + """保存客户评级(每客户一行,整体替换语义)。 + + 五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级 + 判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由 + 服务端按五项重算,**不采信前端回传的总分**。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)") + score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)") + score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)") + score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)") + score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)") + remark: str | None = Field(default=None, max_length=255, description="备注") + + @field_validator( + "score_ship_amount", + "score_receipt_rate", + "score_profit_rate", + "score_contract_cooperation", + "score_site_construction", + ) + @classmethod + def _check_score(cls, value: str) -> str: + """单项分值须为有限数字(值域 0-20 由 service 层判定)。""" + return _parse_score(value, label="评分") diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py index 7cd8e2f..0c941d4 100644 --- a/backend/app/schemas/setting.py +++ b/backend/app/schemas/setting.py @@ -1,28 +1,56 @@ -"""系统设置(公司信息)请求/响应模型。 +"""系统设置(公司信息 + 站点信息)请求/响应模型。 `SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权, 响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会 顺着复用链泄漏到匿名端点。 + +2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/ +地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标 +(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者 +恰好同集合,但仍保留独立 schema 作为白名单防线。 """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class SettingOut(BaseModel): - """公司信息(管理端)。""" + """公司信息与站点信息(管理端)。""" company_name: str logo_url: str | None = None + site_title: str | None = None + brand_slogan: str | None = None + footer_text: str | None = None + icp_no: str | None = None + favicon_url: str | None = None class SettingPublicOut(BaseModel): - """公司信息(匿名端点白名单):登录页展示所需的两个字段。""" + """公司信息与站点信息(匿名端点白名单):登录页/浏览器标签页展示所需字段。""" company_name: str logo_url: str | None = None + site_title: str | None = None + brand_slogan: str | None = None + footer_text: str | None = None + icp_no: str | None = None + favicon_url: str | None = None class SettingUpdateIn(BaseModel): - """公司信息更新。""" + """公司信息与站点信息更新(**部分更新**语义)。 - company_name: str = Field(min_length=1, max_length=64, description="公司名称") + 仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持 + 不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。 + company_name 若提交必须非空(DB 非空约束)。 + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + company_name: str | None = Field( + default=None, min_length=1, max_length=64, description="公司名称(提交则须非空)" + ) + site_title: str | None = Field(default=None, max_length=64, description="站点标题") + brand_slogan: str | None = Field(default=None, max_length=64, description="品牌标语") + footer_text: str | None = Field(default=None, max_length=64, description="页脚版权文本") + icp_no: str | None = Field(default=None, max_length=32, description="ICP 备案号") diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 0d4879d..1540395 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -31,7 +31,7 @@ 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.repositories import customer_rating_repository, customer_repository, personnel_repository from app.schemas.customer import ( CustomerCreateIn, CustomerOut, @@ -46,8 +46,17 @@ from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: - """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" +def _to_out( + customer: Customer, + owner_name: str | None, + rating_total: object | None = None, + rating_grade: str | None = None, +) -> CustomerOut: + """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。 + + rating_total 由 LEFT JOIN 带出(Decimal 或 None),按分值红线转字符串 + 出参;新建客户/未评级客户不传,保持 null。 + """ return CustomerOut( id=customer.id, company_name=customer.company_name, @@ -60,6 +69,8 @@ def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: owner_personnel_id=customer.owner_personnel_id, owner_personnel_name=owner_name, remark=customer.remark, + rating_total=str(rating_total) if rating_total is not None else None, + rating_grade_name=rating_grade, ) @@ -106,7 +117,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool: def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: - """分页查询客户(附归属人员姓名)。""" + """分页查询客户(附归属人员姓名与评级快照;支持评级列排序)。""" rows = customer_repository.list_customers( session, offset=query.offset, @@ -114,6 +125,8 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome keyword=query.keyword, customer_type=query.customer_type, owner_personnel_id=query.owner_personnel_id, + order_field=query.order_field, + order_type=query.order_type, ) total = customer_repository.count_customers( session, @@ -121,8 +134,12 @@ def list_customers(session: Session, query: CustomerQuery) -> PageResult[Custome customer_type=query.customer_type, owner_personnel_id=query.owner_personnel_id, ) + records = [ + _to_out(customer, owner_name, rating_total, rating_grade) + for customer, owner_name, rating_total, rating_grade in rows + ] return PageResult[CustomerOut]( - records=[_to_out(customer, owner_name) for customer, owner_name in rows], + records=records, total=total, page_num=query.page_num, page_size=query.page_size, @@ -137,8 +154,8 @@ def get_customer(session: Session, customer_id: int) -> CustomerOut: 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) + customer, owner_name, rating_total, rating_grade = row + return _to_out(customer, owner_name, rating_total, rating_grade) def create_customer( @@ -241,7 +258,7 @@ def update_customer( _ensure_customer_type(payload.customer_type) if row is None: raise NotFoundError("客户不存在") - customer, owner_name = row + customer, owner_name, rating_total, rating_grade = row duplicate = customer_repository.get_by_company_owner( session, payload.company_name, payload.owner_name @@ -260,7 +277,7 @@ def update_customer( try: # 响应在 commit 之前构造(同 user_service 顶部说明) - out = _to_out(customer, owner_name) + out = _to_out(customer, owner_name, rating_total, rating_grade) session.commit() except IntegrityError as exc: # 把自身以外的组合改成已存在的「公司名称+负责人」时,先查后写 @@ -308,6 +325,11 @@ def delete_customer( company_name = customer.company_name # 固化:对象随删除失效 try: + # 评级行 FK RESTRICT:先删评级行再删客户主行(同事务一次 commit; + # 评级是附属快照,不构成「有关联业务」的删除阻碍) + rating = customer_rating_repository.get_by_customer_id(session, customer_id) + if rating is not None: + customer_rating_repository.delete(session, rating) customer_repository.delete(session, customer) session.commit() except IntegrityError as exc: @@ -362,7 +384,7 @@ def change_ownership( ): if row is None: raise NotFoundError("客户不存在") - customer, _ = row + customer, _, rating_total, rating_grade = row if payload.new_owner_personnel_id is not None: _ensure_owner_personnel(session, payload.new_owner_personnel_id) @@ -385,7 +407,12 @@ def change_ownership( ), ) # 响应在 commit 之前构造(同 user_service 顶部说明);归属人名以新值为准 - out = _to_out(customer, new_person.personnel_name if new_person else None) + out = _to_out( + customer, + new_person.personnel_name if new_person else None, + rating_total, + rating_grade, + ) session.commit() logger.info( "调整客户归属 | customer_id=%s | old=%s | new=%s", diff --git a/backend/app/services/rating_service.py b/backend/app/services/rating_service.py index 90110d3..c4814db 100644 --- a/backend/app/services/rating_service.py +++ b/backend/app/services/rating_service.py @@ -1,12 +1,14 @@ -"""客户评级等级配置业务编排。 +"""客户评级业务编排(等级映射配置 + 每客户评级落库)。 -评级计算器定位(对照表 #25):仅打分工具、不影响绩效金额,评分记录不落库 -——本表承载的等级映射是**判定表**,判定规则为按 sort_order 升序取首个 -min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反会算错等级): +评级定位(对照表 #25):**不影响绩效金额**。自 2026-10-19 起评分每客户 +落库(customer_ratings,一行一客户快照),等级映射仍是本服务承载的 +**判定表**,判定规则为按 sort_order 升序取首个 min_score ≤ 总分的等级, +max_score 仅展示。两条硬约束(违反会算错等级): - 分值全程 ``Decimal``:入参出参 str、schema 拦格式,service 层负责字符串 - 到 Decimal 的转换与落库;0≤min≤max≤100 的区间校验在**审计上下文内** - 完成,失败的 400 同样留痕(2026-10-13 定稿口径) + 到 Decimal 的转换与落库;值域校验(等级区间 0≤min≤max≤100、单项评分 + 0≤分≤20)在**审计上下文内**完成,失败的 400 同样留痕(2026-10-13 定稿 + 口径) - 区间互不重叠:重叠会让同一总分命中多个等级(或两段都跳过),判定表 失效。判定为**闭区间两两相交**:任一其他等级满足 ``other.min_score <= 新 max 且 新 min <= other.max_score`` 即重叠。 @@ -14,9 +16,9 @@ min_score ≤ 总分的等级,max_score 仅展示。两条硬约束(违反 相邻铺满 0-100,且判定本身是单边 min_score ≤ 总分,边界归 sort_order 靠前的等级,相邻无歧义 -删除允许清空:等级表无下游引用(评分记录不落库,判定只读本表现值、不 -回溯历史快照),物理删除无悬空风险;全部删空后计算器只显示总分、不出 -等级(前端已对应提示),故不强制保留任何一条。 +删除允许清空:等级表对客户评级**无外键引用**(评级快照固化等级名、不 +回指本表),物理删除无悬空风险,也不伤及已评级客户的历史结论;全部删空 +后新评级只记总分、等级为 NULL(前端已对应提示),故不强制保留任何一条。 """ import logging @@ -33,9 +35,15 @@ from app.core.constants import ( ) 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 -from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn +from app.models import CustomerRating, CustomerRatingGrade, User +from app.repositories import customer_rating_repository, customer_repository, rating_repository +from app.schemas.rating import ( + CustomerRatingOut, + CustomerRatingSaveIn, + RatingGradeCreateIn, + RatingGradeOut, + RatingGradeUpdateIn, +) from app.services import audit_service from app.services.audit_service import AuditRecord, RequestMeta @@ -44,6 +52,8 @@ logger = logging.getLogger(__name__) # 分值域上下界(Decimal 常量,禁用 float 参与比较) _SCORE_FLOOR = Decimal("0") _SCORE_CEIL = Decimal("100") +# 单项评分上界(五项各 0-20,总分 0-100) +_ITEM_SCORE_CEIL = Decimal("20") def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: @@ -252,10 +262,11 @@ def delete_grade( ) -> None: """物理删除评级等级。 - 等级表无下游引用 —— 评级是纯计算器不落评分记录(对照表 #25),判定 - 只读本表现值、不回溯历史快照,故可物理删除且无需锁行防并发引用。 + 等级表对客户评级**无外键引用** —— 评级行快照固化等级名(见 + ``models/customer_rating.py`` 的论证),判定只读本表现值,故可物理删除 + 且无需锁行防并发引用;删除等级不改变已评级客户的历史结论。 - **允许清空**:全部删空后计算器只显示总分、不出等级(前端已对应提示), + **允许清空**:全部删空后新评级只记总分、等级为 NULL(前端已对应提示), 不强制保留任何一条。 :raises NotFoundError: 评级等级不存在 @@ -278,3 +289,164 @@ def delete_grade( rating_repository.delete(session, grade) session.commit() logger.info("删除评级等级 | grade_name=%s", grade_name) + + +# ==================== 客户评级(每客户一行,落库) ==================== + + +def resolve_grade( + grades: list[CustomerRatingGrade], total_score: Decimal +) -> CustomerRatingGrade | None: + """等级判定纯函数:按 sort_order 升序取首个 min_score ≤ 总分的等级。 + + 与前端打分卡的实时判定同规则同序(前端按数值比较,此处 Decimal 比较 + ——同一规则的两端实现,测试锁定边界行为一致)。无命中(总分低于全部 + 下限或等级表为空)返回 None,由调用方决定展示与落库(快照记 NULL)。 + """ + # id 兜底让同序等级次序稳定(纯函数也接受未持久化对象:id 可能为 None) + for grade in sorted(grades, key=lambda g: (g.sort_order, g.id or 0)): + if grade.min_score <= total_score: + return grade + return None + + +def _to_rating_out(rating: CustomerRating) -> CustomerRatingOut: + """客户评级行转响应模型。分值 Decimal → str(分值红线出参口径)。""" + return CustomerRatingOut( + customer_id=rating.customer_id, + score_ship_amount=str(rating.score_ship_amount), + score_receipt_rate=str(rating.score_receipt_rate), + score_profit_rate=str(rating.score_profit_rate), + score_contract_cooperation=str(rating.score_contract_cooperation), + score_site_construction=str(rating.score_site_construction), + total_score=str(rating.total_score), + grade_name=rating.grade_name, + remark=rating.remark, + scorer_name=rating.scorer_name, + create_time=rating.create_time, + update_time=rating.update_time, + ) + + +def _ensure_item_scores(scores: list[Decimal]) -> None: + """单项评分值域:0 ≤ 分 ≤ 20(总分由五项求和自然落在 0-100)。 + + 须在审计上下文内调用,失败也要留痕(同 _ensure_score_range 口径)。 + """ + for score in scores: + if not (_SCORE_FLOOR <= score <= _ITEM_SCORE_CEIL): + raise BadRequestError("单项评分须在 0~20 之间") + + +def get_customer_rating(session: Session, customer_id: int) -> CustomerRatingOut | None: + """查某客户当前评级;未评级返回 None(接口信封 data 为 null)。 + + 「未评级」是正常态而非错误,不用 404 表达;客户不存在才是 NotFound。 + + :raises NotFoundError: 客户不存在 + """ + customer = customer_repository.get_by_id(session, customer_id) + if customer is None: + raise NotFoundError("客户不存在") + rating = customer_rating_repository.get_by_customer_id(session, customer_id) + return _to_rating_out(rating) if rating is not None else None + + +def save_customer_rating( + session: Session, + customer_id: int, + payload: CustomerRatingSaveIn, + *, + operator: User, + meta: RequestMeta | None = None, +) -> CustomerRatingOut: + """保存客户评级(upsert:首次创建,再次保存整体覆盖)。 + + 总分与等级**服务端重算**:总分按五项求和(不采信前端回传),等级按 + 保存时点的等级映射判定后**快照固化**进评级行 —— 等级表后续增删改 + 不影响该行(见 ``models/customer_rating.py``)。 + + :raises NotFoundError: 客户不存在 + :raises BadRequestError: 单项评分须在 0~20 之间(审计上下文内校验,留痕) + """ + # 只读预取在审计上下文之前:客户不存在(NotFound 失败)也能留下可读记录, + # 摘要需要客户名 + customer = customer_repository.get_by_id(session, customer_id) + existing = customer_rating_repository.get_by_customer_id(session, customer_id) + with audit_service.audit( + session, + operator=operator, + record=AuditRecord( + module=MODULE_RATING, + action=ACTION_CREATE if existing is None else ACTION_UPDATE, + summary=f"评定客户「{customer.company_name if customer else f'#{customer_id}'}」评级", + request_data=payload.model_dump(exclude_none=True), + ), + meta=meta, + ): + # 值域校验在审计上下文内:分值非法的 400 也要留痕(同等级配置口径) + item_scores = [ + _to_decimal(payload.score_ship_amount), + _to_decimal(payload.score_receipt_rate), + _to_decimal(payload.score_profit_rate), + _to_decimal(payload.score_contract_cooperation), + _to_decimal(payload.score_site_construction), + ] + _ensure_item_scores(item_scores) + if customer is None: + raise NotFoundError("客户不存在") + + total_score = sum(item_scores, _SCORE_FLOOR) + grade = resolve_grade(rating_repository.list_all(session), total_score) + + def _apply(row: CustomerRating) -> None: + """把五项分值/总分/等级快照写入评级行(创建与并发兜底共用)。""" + ( + row.score_ship_amount, + row.score_receipt_rate, + row.score_profit_rate, + row.score_contract_cooperation, + row.score_site_construction, + ) = item_scores + row.total_score = total_score + row.grade_name = grade.grade_name if grade is not None else None + row.remark = payload.remark + row.scorer_id = operator.id + row.scorer_name = operator.username + + try: + if existing is None: + rating = CustomerRating( + customer_id=customer_id, + scorer_id=operator.id, + scorer_name=operator.username, + ) + _apply(rating) + customer_rating_repository.create(session, rating) + else: + _apply(existing) + rating = existing + out = _to_rating_out(rating) + session.commit() + except IntegrityError as exc: + # 先查后写不是原子操作:并发为同一客户首次评级会撞 + # uk_customer_ratings_customer_id,回滚后转为更新兜底 + session.rollback() + if not is_duplicate_entry(exc): + raise + raced = customer_rating_repository.get_by_customer_id(session, customer_id) + if raced is None: + # 1062 但行不在:数据态异常,交全局 IntegrityError 兜底 + raise + _apply(raced) + rating = raced + out = _to_rating_out(rating) + session.commit() + + logger.info( + "保存客户评级 | customer_id=%s | total=%s | grade=%s", + customer_id, + out.total_score, + out.grade_name, + ) + return out diff --git a/backend/app/services/system_setting_service.py b/backend/app/services/system_setting_service.py index 5bb6665..6018a7b 100644 --- a/backend/app/services/system_setting_service.py +++ b/backend/app/services/system_setting_service.py @@ -1,12 +1,17 @@ -"""系统设置(公司信息)业务编排。 +"""系统设置(公司信息 + 站点信息)业务编排。 -单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径** -(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次 -读取与备份放大传输,且无法独立替换;DB 只持有一行指针。 +单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对 +路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由: +图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。 -logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 +图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与 覆盖任意文件。 + +logo 与 favicon 相互独立(2026-10-20): +- logo 用于登录页品牌位; +- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg + (不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。 """ import logging @@ -24,15 +29,26 @@ from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) -# magic bytes:PNG 固定 8 字节签名;JPEG 以 SOI + APP0/JFIF 起始 +# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始 _MAGIC = { "png": b"\x89PNG\r\n\x1a\n", "jpg": b"\xff\xd8\xff", + "ico": b"\x00\x00\x01\x00", } -LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用 +# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico +LOGO_EXTS = ("png", "jpg") +FAVICON_EXTS = ("png", "jpg", "ico") +ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用 SETTINGS_ID = 1 # 全系统单行主键 +# 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认) +_OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no") + +# 资源种类 → 中文名(错误提示与审计摘要用) +_ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"} +_ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS} + def get_setting(session: Session) -> SystemSetting: """取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。""" @@ -45,10 +61,15 @@ def get_setting(session: Session) -> SystemSetting: def to_out(setting: SystemSetting) -> SettingOut: - """管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。""" + """管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。""" return SettingOut( company_name=setting.company_name, - logo_url=_logo_url(setting), + logo_url=_asset_url(setting, "logo"), + site_title=setting.site_title, + brand_slogan=setting.brand_slogan, + footer_text=setting.footer_text, + icp_no=setting.icp_no, + favicon_url=_asset_url(setting, "favicon"), ) @@ -56,15 +77,26 @@ def to_public_out(setting: SystemSetting) -> SettingPublicOut: """匿名端点出参(白名单字段,独立 schema 见模块说明)。""" return SettingPublicOut( company_name=setting.company_name, - logo_url=_logo_url(setting), + logo_url=_asset_url(setting, "logo"), + site_title=setting.site_title, + brand_slogan=setting.brand_slogan, + footer_text=setting.footer_text, + icp_no=setting.icp_no, + favicon_url=_asset_url(setting, "favicon"), ) -def _logo_url(setting: SystemSetting) -> str | None: - if not setting.logo_path: +def _asset_url(setting: SystemSetting, kind: str) -> str | None: + """资源 URL(kind=logo/favicon);未上传返回 None。 + + 版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变 + (logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做 + ?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。 + """ + if not getattr(setting, f"{kind}_path"): return None - version = int(setting.update_time.timestamp()) if setting.update_time else 0 - return f"/api/v1/system/settings/logo/public?v={version}" + version = getattr(setting, f"{kind}_version") or 0 + return f"/api/v1/system/settings/{kind}/public?v={version}" def update_company( @@ -74,20 +106,34 @@ def update_company( operator: User, meta: RequestMeta | None = None, ) -> SettingOut: - """更新公司名称(审计留痕)。""" + """更新公司信息与站点信息(部分更新 + 审计留痕)。 + + 仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值), + 便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传 + 空串等价清空(落库 null)。 + """ + changes = payload.model_dump(exclude_unset=True, exclude_none=True) + summary = "修改公司信息" + if "company_name" in changes: + summary = f"修改公司信息(公司名称「{changes['company_name']}」)" with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SYSTEM_SETTINGS, action=ACTION_UPDATE, - summary=f"修改公司名称为「{payload.company_name}」", - request_data=payload.model_dump(), + summary=summary, + request_data=changes or None, ), meta=meta, ): setting = get_setting(session) - setting.company_name = payload.company_name.strip() + if "company_name" in changes: + # schema 已保证提交值非空(min_length=1) + setting.company_name = changes["company_name"] + for field in _OPTIONAL_TEXT_FIELDS: + if field in changes: + setattr(setting, field, changes[field].strip() or None) session.commit() return to_out(setting) @@ -100,17 +146,39 @@ def save_logo( operator: User, meta: RequestMeta | None = None, ) -> SettingOut: - """上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 + """上传公司 logo(登录页品牌位;png/jpg ≤2MB)。""" + return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta) + + +def save_favicon( + session: Session, + content: bytes, + ext: str, + *, + operator: User, + meta: RequestMeta | None = None, +) -> SettingOut: + """上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。""" + return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta) + + +def _save_asset( + session: Session, + content: bytes, + ext: str, + *, + kind: str, + operator: User, + meta: RequestMeta | None, +) -> SettingOut: + """校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。 """ + label = _ASSET_LABELS[kind] + allowed = _ASSET_EXTS[kind] ext = ext.lower() - if ext not in _MAGIC: - raise BadRequestError("logo 仅支持 png/jpg 格式") - if len(content) > LOGO_MAX_BYTES: - raise BadRequestError("logo 文件不能超过 2MB") - if not content.startswith(_MAGIC[ext]): - raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)") + _validate_upload(content, ext, allowed=allowed, label=label) with audit_service.audit( session, @@ -118,48 +186,71 @@ def save_logo( record=AuditRecord( module=MODULE_SYSTEM_SETTINGS, action=ACTION_UPDATE, - summary="更新公司 logo", + summary=f"更新{label}", request_data={"ext": ext, "size": len(content)}, ), meta=meta, ): setting = get_setting(session) - old_path = setting.logo_path + old_path = getattr(setting, f"{kind}_path") # 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把 # 刚落盘的新文件一并删掉(回读 404) old_ext = Path(old_path).suffix.lstrip(".") if old_path else None - if old_ext and old_ext != ext: - _cleanup_old_logo(old_ext) + if old_ext and old_ext != ext and old_ext in allowed: + _cleanup_old_asset(kind, old_ext) - target = _logo_dir() / f"logo.{ext}" + target = _asset_dir(kind) / f"{kind}.{ext}" target.write_bytes(content) - setting.logo_path = f"logo/logo.{ext}" + setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}") + # 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存 + setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1) session.commit() - logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) + logger.info("更新%s | size=%s ext=%s", label, len(content), ext) return to_out(setting) def read_logo(session: Session) -> tuple[Path, str]: """按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。""" + return _read_asset(session, "logo") + + +def read_favicon(session: Session) -> tuple[Path, str]: + """按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。""" + return _read_asset(session, "favicon") + + +def _read_asset(session: Session, kind: str) -> tuple[Path, str]: + label = _ASSET_LABELS[kind] setting = get_setting(session) - if not setting.logo_path: - raise NotFoundError("尚未上传 logo") - path = get_settings().upload_dir / setting.logo_path + path_str = getattr(setting, f"{kind}_path") + if not path_str: + raise NotFoundError(f"尚未上传{label}") + path = get_settings().upload_dir / path_str if not path.is_file(): - raise NotFoundError("logo 文件不存在,请重新上传") + raise NotFoundError(f"{label}文件不存在,请重新上传") return path, path.suffix.lstrip(".") -def _logo_dir() -> Path: - directory = get_settings().upload_dir / "logo" +def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None: + """后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。""" + if ext not in allowed: + raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式") + if len(content) > ASSET_MAX_BYTES: + raise BadRequestError(f"{label}文件不能超过 2MB") + if not content.startswith(_MAGIC[ext]): + raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)") + + +def _asset_dir(kind: str) -> Path: + directory = get_settings().upload_dir / kind directory.mkdir(parents=True, exist_ok=True) return directory -def _cleanup_old_logo(old_ext: str) -> None: - if old_ext in _MAGIC: - old_file = _logo_dir() / f"logo.{old_ext}" +def _cleanup_old_asset(kind: str, old_ext: str) -> None: + if old_ext in _ASSET_EXTS[kind]: + old_file = _asset_dir(kind) / f"{kind}.{old_ext}" if old_file.exists(): old_file.unlink(missing_ok=True) diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 13c4601..f07f8f2 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -35,6 +35,7 @@ from app.models import ( BusinessRole, BusinessStatusLog, Customer, + CustomerRating, CustomerRatingGrade, OperationLog, OwnershipChangeLog, @@ -150,6 +151,10 @@ def _purge_test_data() -> None: session.execute( delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) ) + # 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行) + session.execute( + delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids)) + ) session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 session.execute( @@ -176,13 +181,18 @@ def _purge_test_data() -> None: # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # ref_id 固化无外键,业务已在上一步删除,无悬挂影响) session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) - # 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21 - # (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0) - stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars()) - if stray_values: - stray_batches = {v.batch_id for v in stray_values} - session.execute(delete(ParamValue).where(ParamValue.id > 21)) - session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches))) + # 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建, + # 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。 + # 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库 + # 可能补过种子行),按操作人识别测试产物与环境数据分布无关。 + test_batch_ids = list( + session.execute( + select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%")) + ).scalars() + ) + if test_batch_ids: + session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids))) + session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids))) session.commit() diff --git a/backend/tests/integration/test_customers.py b/backend/tests/integration/test_customers.py index 2945a8a..a5fc4b3 100644 --- a/backend/tests/integration/test_customers.py +++ b/backend/tests/integration/test_customers.py @@ -11,6 +11,7 @@ append-only 完整性;「公司名称+负责人」组合唯一缺失会放行 """ import uuid +from decimal import Decimal import pytest from fastapi.testclient import TestClient @@ -376,3 +377,104 @@ class TestAudit: latest = logs[0] assert customer["company_name"] in latest.summary, "摘要应含客户名" assert "→" in latest.summary, "摘要应体现新旧归属走向" + + +# ==================== 客户评级列(V7) ==================== + +_RATING_KEYS = ( + "score_ship_amount", + "score_receipt_rate", + "score_profit_rate", + "score_contract_cooperation", + "score_site_construction", +) + + +def _rate(client: TestClient, headers: dict, customer_id: int, scores: list[str]) -> dict: + """保存客户评级(五项 0-20),返回响应 data。""" + resp = client.put( + f"{_CUSTOMERS}/{customer_id}/rating", + json=dict(zip(_RATING_KEYS, scores, strict=True)), + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _list_by_keyword(client: TestClient, headers: dict, keyword: str, **params) -> list[dict]: + query: dict = {"keyword": keyword, "pageNum": 1, "pageSize": 50, **params} + resp = client.get(_CUSTOMERS, params=query, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"]["list"] + + +class TestRatingColumn: + """客户评级列:列表带出评级快照,列头排序走服务端(未评级恒在末尾)。 + + 列表是服务端分页,排序必须在 SQL 层完成 —— 前端只传 + ``order_field=rating`` + ``order_type``;MySQL 无 NULLS LAST, + 未评级行用布尔键模拟,正序/倒序都在末尾。 + """ + + def _seed_three(self, client: TestClient, headers: dict) -> tuple[str, dict, dict, dict]: + """建三名同批次客户:90 分(A)、0 分(D)、未评级。""" + token = uuid.uuid4().hex[:8] + keyword = f"t_公司_{token}" + high = _create_customer(client, headers, company_name=f"{keyword}_甲") + low = _create_customer(client, headers, company_name=f"{keyword}_乙") + none = _create_customer(client, headers, company_name=f"{keyword}_丙") + _rate(client, headers, high["id"], ["18"] * 5) + _rate(client, headers, low["id"], ["0"] * 5) + return keyword, high, low, none + + def test_列表带出评级字段(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + keyword, high, low, none = self._seed_three(client, headers) + rows = _list_by_keyword(client, headers, keyword) + by_id = {row["id"]: row for row in rows} + + assert Decimal(by_id[high["id"]]["rating_total"]) == Decimal("90") + assert by_id[high["id"]]["rating_grade_name"] == "A" + assert Decimal(by_id[low["id"]]["rating_total"]) == Decimal("0") + assert by_id[low["id"]]["rating_grade_name"] == "D" + assert by_id[none["id"]]["rating_total"] is None + assert by_id[none["id"]]["rating_grade_name"] is None + + def test_评级列正序倒序_未评级恒末尾(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + keyword, high, low, none = self._seed_three(client, headers) + + desc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="desc") + assert [row["id"] for row in desc] == [high["id"], low["id"], none["id"]] + + asc = _list_by_keyword(client, headers, keyword, order_field="rating", order_type="asc") + assert [row["id"] for row in asc] == [low["id"], high["id"], none["id"]] + + def test_非法排序字段_400(self, client: TestClient, normal: dict) -> None: + """排序字段是白名单(Literal):不透传给 SQL,非法值 400。""" + resp = client.get( + _CUSTOMERS, + params={"order_field": "company_name", "order_type": "asc"}, + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_非法排序方向_400(self, client: TestClient, normal: dict) -> None: + resp = client.get( + _CUSTOMERS, + params={"order_field": "rating", "order_type": "sideways"}, + headers=normal["headers"], + ) + assert resp.status_code == 400, resp.text + + def test_删除客户联动删评级行(self, client: TestClient, normal: dict) -> None: + """评级行 FK RESTRICT:删除客户须先清评级行,否则 500。""" + headers = normal["headers"] + customer = _create_customer(client, headers) + _rate(client, headers, customer["id"], ["20"] * 5) + + resp = client.delete(f"{_CUSTOMERS}/{customer['id']}", headers=headers) + assert resp.status_code == 200, resp.text + assert ( + client.get(f"{_CUSTOMERS}/{customer['id']}/rating", headers=headers).status_code == 404 + ) diff --git a/backend/tests/integration/test_ratings.py b/backend/tests/integration/test_ratings.py index 94436f9..9a55256 100644 --- a/backend/tests/integration/test_ratings.py +++ b/backend/tests/integration/test_ratings.py @@ -21,15 +21,17 @@ from decimal import Decimal import pytest from fastapi.testclient import TestClient -from sqlalchemy import select +from sqlalchemy import func, select from app.core.constants import ACTION_CREATE, MODULE_RATING from app.core.db import get_session_factory -from app.models import OperationLog +from app.models import CustomerRating, CustomerRatingGrade, OperationLog +from app.services.rating_service import resolve_grade pytestmark = pytest.mark.integration _GRADES = "/api/v1/business/rating-grades" +_CUSTOMERS = "/api/v1/base/customers" # 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1)) _SEED = [ @@ -77,6 +79,41 @@ def _names(client: TestClient, headers: dict) -> list[str]: return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]] +def _create_customer(client: TestClient, headers: dict) -> dict: + """建一名 t_ 前缀测试客户(评级落库的被引用方,conftest 兜底清理)。""" + resp = client.post( + _CUSTOMERS, + json={ + "company_name": f"t_评级客户_{uuid.uuid4().hex[:8]}", + "owner_name": "t_老板", + "customer_type": "personal", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +_RATING_KEYS = ( + "score_ship_amount", + "score_receipt_rate", + "score_profit_rate", + "score_contract_cooperation", + "score_site_construction", +) + + +def _rating_url(customer_id: int) -> str: + return f"{_CUSTOMERS}/{customer_id}/rating" + + +def _rating_payload(scores: list[str], remark: str | None = None) -> dict: + body: dict = dict(zip(_RATING_KEYS, scores, strict=True)) + if remark is not None: + body["remark"] = remark + return body + + def _logs_of(username: str) -> list[OperationLog]: """取某操作人的审计行(按 id 倒序,最新在前)。""" factory = get_session_factory() @@ -258,8 +295,8 @@ class TestEdit: class TestDelete: - """删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表 - 立即反映,二次删除 404。""" + """删除允许清空(客户评级快照固化等级名、无外键引用)—— 删除后判定表 + 立即反映,二次删除 404,已评级客户的历史等级快照不受影响。""" def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None: headers = normal["headers"] @@ -313,3 +350,146 @@ class TestAudit: ] assert failed, "失败操作未留痕" assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0] + + +class TestResolveGrade: + """判定纯函数边界:与前端打分卡同规则(sort_order 升序首个 min_score ≤ 总分)。 + + 单边判定让边界分归 sort_order 靠前的等级:90→A、75→B、60→C、59.9→D; + 等级表为空或总分低于全部下限时返回 None(落库等级为 NULL)。 + """ + + @staticmethod + def _seed_grades() -> list[CustomerRatingGrade]: + return [ + CustomerRatingGrade( + grade_name=name, + min_score=Decimal(min_score), + max_score=Decimal(max_score), + sort_order=order, + ) + for name, min_score, max_score, order in _SEED + ] + + def test_边界分归高档(self) -> None: + grades = self._seed_grades() + assert resolve_grade(grades, Decimal("90")).grade_name == "A" + assert resolve_grade(grades, Decimal("75")).grade_name == "B" + assert resolve_grade(grades, Decimal("60")).grade_name == "C" + + def test_下限下方归最低档(self) -> None: + assert resolve_grade(self._seed_grades(), Decimal("59.9")).grade_name == "D" + + def test_等级表为空_未命中(self) -> None: + assert resolve_grade([], Decimal("80")) is None + + def test_低于全部下限_未命中(self) -> None: + grades = [ + CustomerRatingGrade( + grade_name="A", + min_score=Decimal("90"), + max_score=Decimal("100"), + sort_order=1, + ) + ] + assert resolve_grade(grades, Decimal("50")) is None + + +class TestCustomerRating: + """客户评级落库(V7):每客户一行、服务端重算总分与等级、等级快照固化。 + + 五项各 0-20 → 总分 0-100;等级按保存时点判定并写入 grade_name(无外键)。 + 未评级返回 data=null(正常态);分数越界 400 且审计留痕。 + """ + + def test_未评级返回null(self, client: TestClient, normal: dict) -> None: + customer = _create_customer(client, normal["headers"]) + resp = client.get(_rating_url(customer["id"]), headers=normal["headers"]) + assert resp.status_code == 200, resp.text + assert resp.json()["data"] is None + + def test_保存评级_服务端重算总分等级并留审计(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _create_customer(client, headers) + put = client.put( + _rating_url(customer["id"]), + json=_rating_payload(["18", "20", "19", "17", "16"], remark="t_评级依据"), + headers=headers, + ) + assert put.status_code == 200, put.text + data = put.json()["data"] + assert Decimal(data["total_score"]) == Decimal("90") + assert data["grade_name"] == "A" + assert data["remark"] == "t_评级依据" + assert data["scorer_name"] == normal["username"] + + # 回读一致(列表评级列与弹窗回显同源) + got = client.get(_rating_url(customer["id"]), headers=headers).json()["data"] + assert Decimal(got["total_score"]) == Decimal("90") + assert got["grade_name"] == "A" + assert Decimal(got["score_ship_amount"]) == Decimal("18") + + logs = [log for log in _logs_of(normal["username"]) if log.module == MODULE_RATING] + assert logs, "保存客户评级未留操作日志" + assert logs[0].success == 1 + assert customer["company_name"] in logs[0].summary + assert "评级" in logs[0].summary + + def test_重复保存覆盖同一客户仅一行(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _create_customer(client, headers) + first = client.put( + _rating_url(customer["id"]), json=_rating_payload(["0"] * 5), headers=headers + ) + assert first.status_code == 200, first.text + assert first.json()["data"]["grade_name"] == "D" + + second = client.put( + _rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers + ) + assert second.status_code == 200, second.text + assert Decimal(second.json()["data"]["total_score"]) == Decimal("100") + assert second.json()["data"]["grade_name"] == "A" + + factory = get_session_factory() + with factory() as session: + count = session.execute( + select(func.count()) + .select_from(CustomerRating) + .where(CustomerRating.customer_id == customer["id"]) + ).scalar_one() + assert count == 1, "同一客户出现多行评级(upsert 失效)" + + def test_单项分数越界_400(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + customer = _create_customer(client, headers) + for bad in (["21", "0", "0", "0", "0"], ["0", "-1", "0", "0", "0"]): + resp = client.put( + _rating_url(customer["id"]), json=_rating_payload(bad), headers=headers + ) + assert resp.status_code == 400, resp.text + assert "0~20" in resp.json()["message"], resp.text + + def test_客户不存在_404(self, client: TestClient, normal: dict) -> None: + headers = normal["headers"] + assert client.get(_rating_url(999_999_999), headers=headers).status_code == 404 + put = client.put(_rating_url(999_999_999), json=_rating_payload(["0"] * 5), headers=headers) + assert put.status_code == 404, put.text + + def test_等级表清空后保存_等级为null(self, client: TestClient, normal: dict) -> None: + """等级映射允许清空(快照设计前提):清空后评分照常保存,仅等级为 NULL。""" + headers = normal["headers"] + for name in ("A", "B", "C", "D"): + assert ( + client.delete( + f"{_GRADES}/{_seed_id(client, headers, name)}", headers=headers + ).status_code + == 200 + ) + + customer = _create_customer(client, headers) + data = client.put( + _rating_url(customer["id"]), json=_rating_payload(["20"] * 5), headers=headers + ).json()["data"] + assert Decimal(data["total_score"]) == Decimal("100") + assert data["grade_name"] is None diff --git a/backend/tests/integration/test_settings.py b/backend/tests/integration/test_settings.py index de1e9b8..4d3cb43 100644 --- a/backend/tests/integration/test_settings.py +++ b/backend/tests/integration/test_settings.py @@ -23,6 +23,7 @@ _SETTINGS = "/api/v1/system/settings" _PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 _JPG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32 +_ICO_BYTES = b"\x00\x00\x01\x00" + b"\x00" * 32 _TXT_BYTES = b"not an image at all" @@ -31,6 +32,81 @@ def client() -> TestClient: return TestClient(app) +_ASSET_KINDS = ("logo", "favicon") + + +def _snapshot_assets() -> dict[str, dict[str, bytes]]: + """快照上传目录里的真实图片(logo/favicon)。""" + from app.core.config import get_settings + + root = get_settings().upload_dir + snapshot: dict[str, dict[str, bytes]] = {} + for kind in _ASSET_KINDS: + directory = root / kind + snapshot[kind] = ( + {path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()} + if directory.is_dir() + else {} + ) + return snapshot + + +def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None: + """还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。 + + 上传用例会用固定文件名覆盖 logo/favicon,并清理旧扩展名文件 —— 只还原 + 数据库路径而不还原文件,会把开发环境里真实上传的 logo 永久删掉。 + """ + from app.core.config import get_settings + + root = get_settings().upload_dir + for kind, files in snapshot.items(): + directory = root / kind + directory.mkdir(parents=True, exist_ok=True) + for path in directory.glob("*"): + if path.is_file() and path.name not in files: + path.unlink(missing_ok=True) + for name, content in files.items(): + (directory / name).write_bytes(content) + + +@pytest.fixture(autouse=True) +def _restore_company_info(): + """系统设置是**真实单行数据 + 真实上传文件**:用例前快照、结束后还原。 + + 与其他表的测试数据不同,公司信息没有 t_ 前缀可供兜底清理 —— 不还原 + 的话,跑完测试后开发环境的侧边栏品牌/登录页/浏览器标题会被改成 + 「测试公司_xxx」,上传用例还会覆盖/删除真实 logo 与站点图标。 + """ + from app.core.db import get_session_factory + from app.models import SystemSetting + + factory = get_session_factory() + with factory() as session: + row = session.get(SystemSetting, 1) + snapshot = ( + {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns} + if row is not None + else None + ) + asset_snapshot = _snapshot_assets() + + yield + + # 文件先还原:数据库路径随后回指同一批文件 + _restore_assets(asset_snapshot) + if snapshot is None: + return + with factory() as session: + row = session.get(SystemSetting, 1) + if row is None: + return + for key, value in snapshot.items(): + if key != "update_time": # 更新时间由数据库 ON UPDATE 维护 + setattr(row, key, value) + session.commit() + + class TestCompanyInfo: """公司名称读取与更新。""" @@ -84,10 +160,30 @@ class TestPublicEndpoint: resp = client.get(f"{_SETTINGS}/public") assert resp.status_code == 200 data = resp.json()["data"] - assert set(data) == {"company_name", "logo_url"}, "public 端点字段越界" + assert set(data) == { + "company_name", + "logo_url", + "site_title", + "brand_slogan", + "footer_text", + "icp_no", + "favicon_url", + }, "public 端点字段越界" assert data["company_name"] == name assert resp.headers.get("cache-control") is not None + def test_已删除的档案字段不再返回(self, client: TestClient) -> None: + """V8 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。""" + data = client.get(f"{_SETTINGS}/public").json()["data"] + for field in ( + "system_subtitle", + "credit_code", + "company_address", + "contact_phone", + "contact_email", + ): + assert field not in data, f"已删除字段仍出现在匿名端点:{field}" + def test_写操作匿名不可用(self, client: TestClient) -> None: assert client.put(f"{_SETTINGS}/public", json={"company_name": "x"}).status_code in { 401, @@ -95,6 +191,41 @@ class TestPublicEndpoint: } +class TestCompanyProfile: + """站点标题/品牌信息(V8):部分更新语义与空串清空。""" + + def test_部分更新_未提交字段保持不变(self, client: TestClient, admin: dict) -> None: + name = f"站点公司_{uuid.uuid4().hex[:6]}" + client.put(_SETTINGS, json={"company_name": name}, headers=admin["headers"]) + + put = client.put(_SETTINGS, json={"site_title": "测试站点标题"}, headers=admin["headers"]) + assert put.status_code == 200, put.text + assert put.json()["data"]["site_title"] == "测试站点标题" + # 未提交的 company_name 保持不变(部分更新语义) + assert put.json()["data"]["company_name"] == name + + got = client.get(_SETTINGS, headers=admin["headers"]).json()["data"] + assert got["company_name"] == name + assert got["site_title"] == "测试站点标题" + + def test_可选文本传空串_清空为null(self, client: TestClient, admin: dict) -> None: + client.put( + _SETTINGS, + json={"brand_slogan": "t_临时标语", "icp_no": "t_备案号"}, + headers=admin["headers"], + ) + put = client.put( + _SETTINGS, json={"brand_slogan": "", "icp_no": ""}, headers=admin["headers"] + ) + assert put.status_code == 200, put.text + assert put.json()["data"]["brand_slogan"] is None + assert put.json()["data"]["icp_no"] is None + + def test_超长字段被拒(self, client: TestClient, admin: dict) -> None: + resp = client.put(_SETTINGS, json={"site_title": "x" * 65}, headers=admin["headers"]) + assert resp.status_code == 400, resp.text + + class TestLogoUpload: """logo 上传校验链。""" @@ -135,3 +266,65 @@ class TestLogoUpload: files={"file": ("logo.png", _PNG_BYTES, "application/octet-stream")}, ) assert resp.status_code == 401 + + def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None: + """同扩展名覆盖时路径与 update_time 都不变 —— 版本号必须递增,否则浏览器用旧图。""" + first = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"] + second = self._upload(client, admin, "logo.png", _PNG_BYTES).json()["data"]["logo_url"] + assert first and second + assert first != second, "同扩展名替换后图片 URL 未变化,浏览器会继续使用旧缓存" + + +class TestFaviconUpload: + """站点图标上传校验链(独立于 logo;favicon 额外接受 ico)。""" + + def _upload(self, client: TestClient, admin: dict, filename: str, content: bytes): + return client.post( + f"{_SETTINGS}/favicon", + files={"file": (filename, content, "application/octet-stream")}, + headers=admin["headers"], + ) + + def test_合法ico上传成功且回读(self, client: TestClient, admin: dict) -> None: + resp = self._upload(client, admin, "favicon.ico", _ICO_BYTES) + assert resp.status_code == 200, resp.text + out = resp.json()["data"] + assert out["favicon_url"] and "/favicon/public" in out["favicon_url"] + + img = client.get(f"{_SETTINGS}/favicon/public") + assert img.status_code == 200 + assert img.headers["content-type"] == "image/x-icon" + + def test_合法png上传成功(self, client: TestClient, admin: dict) -> None: + resp = self._upload(client, admin, "favicon.png", _PNG_BYTES) + assert resp.status_code == 200, resp.text + assert "/favicon/public" in resp.json()["data"]["favicon_url"] + + def test_伪造扩展名被magic_bytes拦截(self, client: TestClient, admin: dict) -> None: + resp = self._upload(client, admin, "fake.ico", _TXT_BYTES) + assert resp.status_code == 400, resp.text + assert "不符" in resp.json()["message"] + + def test_同扩展名替换_url版本号变化(self, client: TestClient, admin: dict) -> None: + first = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"]["favicon_url"] + second = self._upload(client, admin, "favicon.png", _PNG_BYTES).json()["data"][ + "favicon_url" + ] + assert first and second + assert first != second, "同扩展名替换后站点图标 URL 未变化" + + def test_非法格式被拒(self, client: TestClient, admin: dict) -> None: + assert self._upload(client, admin, "x.gif", _PNG_BYTES).status_code == 400 + + def test_超限被拒(self, client: TestClient, admin: dict) -> None: + big = _PNG_BYTES + b"\x00" * (2 * 1024 * 1024) + resp = self._upload(client, admin, "big.png", big) + assert resp.status_code == 400 + assert "2MB" in resp.json()["message"] + + def test_匿名上传被拒(self, client: TestClient) -> None: + resp = client.post( + f"{_SETTINGS}/favicon", + files={"file": ("favicon.png", _PNG_BYTES, "application/octet-stream")}, + ) + assert resp.status_code == 401 diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 23f6cf6..1e178f7 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -40,6 +40,7 @@ EXPECTED_TABLES = { "shipment_items", "receipts", "customer_rating_grades", + "customer_ratings", } # 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束 diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index e5b6076..b0e71a4 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -19,6 +19,7 @@ SET NAMES utf8mb4; DROP TABLE IF EXISTS `approval_steps`; DROP TABLE IF EXISTS `approval_orders`; DROP TABLE IF EXISTS `business_secondary_allocations`; +DROP TABLE IF EXISTS `customer_ratings`; DROP TABLE IF EXISTS `system_settings`; DROP TABLE IF EXISTS `param_values`; DROP TABLE IF EXISTS `param_change_batches`; @@ -679,17 +680,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order ('C', 60, 74, 3, '合格'), ('D', 0, 59, 4, '待改进'); +-- --------------------------------------------------------------------------- +-- 客户评级落库(V7 迁移;每客户一行,等级名为快照固化无外键) +-- --------------------------------------------------------------------------- +CREATE TABLE customer_ratings ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)', + score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0', + score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0', + score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0', + score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0', + score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0', + total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)', + grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)', + remark VARCHAR(255) COMMENT '备注', + scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)', + scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id), + CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci; + -- --------------------------------------------------------------------------- -- 系统设置/二次分配/审批(V6 迁移;公司信息单行;分摊计算待澄清另批) +-- V7 迁移:增加公司档案字段;V8 迁移:删除用不上的档案字段(副标题/信用代码/ +-- 地址/电话/邮箱),保留名称/标语/页脚/备案号/logo,并新增站点标题与站点图标 -- --------------------------------------------------------------------------- CREATE TABLE system_settings ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, - company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页与导出页眉展示)', - logo_path VARCHAR(255) COMMENT 'logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)', + company_name VARCHAR(64) NOT NULL COMMENT '公司名称(登录页/侧边栏/浏览器标题展示)', + logo_path VARCHAR(255) COMMENT '公司 logo 相对路径(文件存 uploads/logo/,null=未上传使用默认)', + logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0', + site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)', + brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)', + footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)', + icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)', + favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=跟随公司 logo / 内置 favicon.svg)', + favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' 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_system_settings PRIMARY KEY (id) -)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息,全系统单行)' COLLATE utf8mb4_general_ci; +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统设置表(公司信息与站点信息,全系统单行)' COLLATE utf8mb4_general_ci; CREATE TABLE approval_orders ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, @@ -753,4 +787,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 ('79bcc5aa98d4'); +INSERT INTO alembic_version (version_num) VALUES ('5e8a2d9c7b41'); diff --git a/db/migration/V7__add_customer_ratings_and_company_profile.sql b/db/migration/V7__add_customer_ratings_and_company_profile.sql new file mode 100644 index 0000000..f6f5dd7 --- /dev/null +++ b/db/migration/V7__add_customer_ratings_and_company_profile.sql @@ -0,0 +1,41 @@ +-- Running upgrade 79bcc5aa98d4 -> c4e2a86f9b13 + +CREATE TABLE customer_ratings ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID(唯一:一客户一份当前评级)', + score_ship_amount NUMERIC(5, 1) NOT NULL COMMENT '发货额评分(0-20)' DEFAULT '0', + score_receipt_rate NUMERIC(5, 1) NOT NULL COMMENT '回款率评分(0-20)' DEFAULT '0', + score_profit_rate NUMERIC(5, 1) NOT NULL COMMENT '毛利率评分(0-20)' DEFAULT '0', + score_contract_cooperation NUMERIC(5, 1) NOT NULL COMMENT '合同履约配合能力评分(0-20)' DEFAULT '0', + score_site_construction NUMERIC(5, 1) NOT NULL COMMENT '现场规范施工能力评分(0-20)' DEFAULT '0', + total_score NUMERIC(5, 1) NOT NULL COMMENT '总分(服务端重算,五项之和 0-100)', + grade_name VARCHAR(32) COMMENT '评级等级(保存时按等级映射快照固化,无外键;未命中/等级表空为 NULL)', + remark VARCHAR(255) COMMENT '备注', + scorer_id BIGINT UNSIGNED COMMENT '评级操作人ID(账号删除后置空)', + scorer_name VARCHAR(64) 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_customer_ratings PRIMARY KEY (id), + CONSTRAINT fk_customer_ratings_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_customer_ratings_scorer_id FOREIGN KEY(scorer_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT uk_customer_ratings_customer_id UNIQUE (customer_id) +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='客户评级表(每客户一行;等级名为快照固化无外键,删等级不伤已评级记录)' COLLATE utf8mb4_general_ci; + +ALTER TABLE system_settings ADD COLUMN system_subtitle VARCHAR(32) COMMENT '系统副标题(侧边栏品牌区/浏览器标题;null=内置默认)'; + +ALTER TABLE system_settings ADD COLUMN brand_slogan VARCHAR(64) COMMENT '品牌标语(登录页与品牌预览;null=内置默认)'; + +ALTER TABLE system_settings ADD COLUMN footer_text VARCHAR(64) COMMENT '页脚版权文本(登录页;null=自动生成 © 公司名 · 内部系统)'; + +ALTER TABLE system_settings ADD COLUMN icp_no VARCHAR(32) COMMENT 'ICP 备案号(登录页页脚,空=不显示)'; + +ALTER TABLE system_settings ADD COLUMN credit_code VARCHAR(32) COMMENT '统一社会信用代码(预留导出页眉/单据)'; + +ALTER TABLE system_settings ADD COLUMN company_address VARCHAR(255) COMMENT '公司地址(预留导出页眉)'; + +ALTER TABLE system_settings ADD COLUMN contact_phone VARCHAR(32) COMMENT '联系电话(系统支持信息,预留)'; + +ALTER TABLE system_settings ADD COLUMN contact_email VARCHAR(64) COMMENT '联系邮箱(系统支持信息,预留)'; + +UPDATE alembic_version SET version_num='c4e2a86f9b13' WHERE alembic_version.version_num = '79bcc5aa98d4'; + diff --git a/db/migration/V8__trim_company_profile_and_add_site_info.sql b/db/migration/V8__trim_company_profile_and_add_site_info.sql new file mode 100644 index 0000000..d5a6cbc --- /dev/null +++ b/db/migration/V8__trim_company_profile_and_add_site_info.sql @@ -0,0 +1,18 @@ +-- Running upgrade c4e2a86f9b13 -> 9d2f4c7b1e60 + +ALTER TABLE system_settings DROP COLUMN system_subtitle; + +ALTER TABLE system_settings DROP COLUMN credit_code; + +ALTER TABLE system_settings DROP COLUMN company_address; + +ALTER TABLE system_settings DROP COLUMN contact_phone; + +ALTER TABLE system_settings DROP COLUMN contact_email; + +ALTER TABLE system_settings ADD COLUMN site_title VARCHAR(64) COMMENT '站点标题(浏览器标签页与侧边栏副标题;null=内置默认)'; + +ALTER TABLE system_settings ADD COLUMN favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)'; + +UPDATE alembic_version SET version_num='9d2f4c7b1e60' WHERE alembic_version.version_num = 'c4e2a86f9b13'; + diff --git a/db/migration/V9__add_asset_versions.sql b/db/migration/V9__add_asset_versions.sql new file mode 100644 index 0000000..59ff607 --- /dev/null +++ b/db/migration/V9__add_asset_versions.sql @@ -0,0 +1,8 @@ +-- Running upgrade 9d2f4c7b1e60 -> 5e8a2d9c7b41 + +ALTER TABLE system_settings ADD COLUMN logo_version INT NOT NULL COMMENT 'logo 版本号(每次上传 +1;图片 URL 用它破缓存——同扩展名覆盖时路径不变,update_time 也不变)' DEFAULT '0'; + +ALTER TABLE system_settings ADD COLUMN favicon_version INT NOT NULL COMMENT '站点图标版本号(每次上传 +1;图片 URL 用它破缓存)' DEFAULT '0'; + +UPDATE alembic_version SET version_num='5e8a2d9c7b41' WHERE alembic_version.version_num = '9d2f4c7b1e60'; + diff --git a/frontend/index.html b/frontend/index.html index a90b6a4..b9ef025 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,8 @@ + + 衍水集团营销人员工资管理系统 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..fa7f40d --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + 衍 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 82c4abe..c43b10b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,31 @@ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index a420563..f2178cd 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -4,12 +4,13 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getCaptcha } from '@/api/auth' -import { getPublicSettings } from '@/api/settings' +import { useSettingsStore } from '@/store/settings' import { useUserStore } from '@/store/user' const router = useRouter() const route = useRoute() const userStore = useUserStore() +const settingsStore = useSettingsStore() const formRef = ref() const submitting = ref(false) @@ -24,20 +25,6 @@ const form = reactive({ const captchaId = ref('') const captchaImage = ref('') -/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */ -const brandName = ref('衍水集团') -const brandLogo = ref(null) - -async function loadBrand(): Promise { - try { - const data = await getPublicSettings() - if (data.company_name) brandName.value = data.company_name - brandLogo.value = data.logo_url - } catch { - // 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录 - } -} - const rules: FormRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }], @@ -89,7 +76,8 @@ async function handleSubmit(): Promise { onMounted(() => { refreshCaptcha() - loadBrand() + // 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存) + void settingsStore.ensureLoaded() }) @@ -103,9 +91,14 @@ onMounted(() => { diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue index ce1c76d..9bd6285 100644 --- a/frontend/src/views/system/params/index.vue +++ b/frontend/src/views/system/params/index.vue @@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import * as businessRoleApi from '@/api/businessRoles' import * as paramsApi from '@/api/params' +import { paramEnumLabel } from '@/api/params' import type { ParamBatch, ParamBatchDetail, @@ -37,9 +38,10 @@ async function loadCatalog(): Promise { catalogLoading.value = true try { catalog.value = await paramsApi.getCatalog() - // 预览「所用参数」按编码取中文名;目录加载后填充映射 + // 目录加载后填充映射:预览「所用参数」按编码取中文名;值翻译按编码查类型 for (const item of catalog.value) { PARAM_NAME_BY_CODE[item.param_code] = item.param_name + PARAM_VALUE_TYPES[item.param_code] = item.value_type } } catch (error) { handledError(error) @@ -77,20 +79,24 @@ function paramNameByCode(code: string): string { return PARAM_NAME_BY_CODE[code] ?? code } -/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */ -const ENUM_VALUE_LABELS: Record = { - FULL: '当月全额', - BALANCE: '余额', - INCLUDE: '含基本工资', - DEDUCT: '扣除基本工资', - EXCLUDE: '不计基本工资', - '0': '关', - '1': '开', +/** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */ +const PARAM_VALUE_TYPES: Record = {} + +/** + * 按参数编码翻译枚举值(旧值/新值等宽表用)。 + * + * **必须先判参数类型**:枚举映射含 0=关/1=开,若无差别套用,值为 0 或 1 + * 的普通数值参数会被误译成「关/开」。非枚举(或目录尚未加载)原样返回。 + */ +function enumValueLabel(paramCode: string, value: string | null | undefined): string { + if (value === null || value === undefined) return '' + return PARAM_VALUE_TYPES[paramCode] === 'ENUM' ? paramEnumLabel(value) : value } -function valueLabel(value: string | null | undefined): string { - if (value === null || value === undefined) return '' - return ENUM_VALUE_LABELS[value] ?? value +/** ENUM 下拉选项文案:中文主显 + 括号保留原码(当月全额(FULL)),便于与审计记录对上 */ +function enumOptionLabel(option: string): string { + const label = paramEnumLabel(option) + return label === option ? option : `${label}(${option})` } /** 带单位的值展示 */ @@ -461,7 +467,14 @@ onMounted(() => {