Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4ba7514b | ||
|
|
27533eb465 | ||
|
|
4c10475228 | ||
|
|
2e0c64849b | ||
|
|
33b9b00bb0 | ||
|
|
d03ddadd1a | ||
|
|
c319a6a428 |
No files matched your search
@@ -0,0 +1,212 @@
|
||||
"""V11 客户评级落库与站点信息
|
||||
|
||||
Revision ID: c4e2a86f9b13
|
||||
Revises: 90653814a421
|
||||
Create Date: 2026-10-19 10:00:00.000000
|
||||
|
||||
与上游 V7–V10(台账/月结/调整单/口径/唯一键/预警参数)合并时重编号为 V11:
|
||||
本分支三个迁移(原 V7 客户评级 + V8 公司档案精简 + V9 图片版本号)从未发布,
|
||||
压成单条,最终结构 = 评级表 + 最终版站点/公司信息字段(不含中途增删的
|
||||
副标题/信用代码/地址/电话/邮箱)。
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c4e2a86f9b13"
|
||||
down_revision: str | Sequence[str] | None = "90653814a421"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""应用迁移(双主题一次交付)。
|
||||
|
||||
- 客户评级弹窗化:评分每客户落库(customer_ratings,一行一客户快照)
|
||||
- 公司信息补充与精简:标语/页脚/备案号/站点标题/站点图标 + 图片版本号
|
||||
"""
|
||||
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(
|
||||
"site_title",
|
||||
sa.String(length=64),
|
||||
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(
|
||||
"favicon_path",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)",
|
||||
),
|
||||
)
|
||||
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:
|
||||
"""回滚迁移:先删评级表(引用 customers/users),再逆序移除站点信息列。"""
|
||||
op.drop_table("customer_ratings")
|
||||
op.drop_column("system_settings", "favicon_version")
|
||||
op.drop_column("system_settings", "logo_version")
|
||||
op.drop_column("system_settings", "favicon_path")
|
||||
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", "site_title")
|
||||
@@ -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="评级已保存",
|
||||
)
|
||||
@@ -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
|
||||
@@ -20,6 +20,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="公司信息")
|
||||
@@ -28,14 +29,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="已保存",
|
||||
@@ -55,7 +56,7 @@ def upload_logo(
|
||||
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||||
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||||
"""
|
||||
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
||||
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
content = file.file.read()
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||||
@@ -65,6 +66,27 @@ def upload_logo(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标")
|
||||
def upload_favicon(
|
||||
file: UploadFile,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||||
|
||||
同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。
|
||||
"""
|
||||
if file.size is not None and file.size > service.ASSET_MAX_BYTES:
|
||||
raise BadRequestError("站点图标文件不能超过 2MB")
|
||||
content = file.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]:
|
||||
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||||
@@ -81,3 +103,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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -38,6 +39,7 @@ __all__ = [
|
||||
"BusinessRole",
|
||||
"BusinessStatusLog",
|
||||
"Customer",
|
||||
"CustomerRating",
|
||||
"CustomerRatingGrade",
|
||||
"LoginLog",
|
||||
"MonthlySettlement",
|
||||
|
||||
@@ -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="评级操作人用户名(冗余固化)"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 用它破缓存)",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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="评分")
|
||||
@@ -1,34 +1,59 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
"""系统设置(公司信息 + 站点信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
|
||||
2026-10-20 精简(用户确认):删除系统副标题与企业档案字段(信用代码/
|
||||
地址/电话/邮箱);新增站点标题(浏览器标签页/侧边栏副标题)与站点图标
|
||||
(浏览器标签页 favicon)。public 端点只需登录页/标签页展示字段,两者
|
||||
恰好同集合,但仍保留独立 schema 作为白名单防线。
|
||||
"""
|
||||
|
||||
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):
|
||||
"""公司信息更新。
|
||||
"""公司信息与站点信息更新(**部分更新**语义)。
|
||||
|
||||
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
|
||||
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
|
||||
|
||||
仅提交的字段生效(``exclude_unset``),未提交或显式 null 的字段保持
|
||||
不变;可选文本传空串等价清空(落库为 null、前端回退内置默认)。
|
||||
company_name 若提交必须非空(DB 非空约束)。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
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 备案号")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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,54 +186,79 @@ 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")
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
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()
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
if old_ext and old_ext != ext and old_ext in allowed:
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
_cleanup_old_asset(kind, old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
# 成功的更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning(
|
||||
"旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True
|
||||
)
|
||||
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)
|
||||
@@ -21,7 +21,6 @@
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select, update
|
||||
@@ -37,6 +36,7 @@ from app.models import (
|
||||
BusinessRole,
|
||||
BusinessStatusLog,
|
||||
Customer,
|
||||
CustomerRating,
|
||||
CustomerRatingGrade,
|
||||
MonthlySettlement,
|
||||
OperationLog,
|
||||
@@ -164,6 +164,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(
|
||||
@@ -190,32 +194,103 @@ def _purge_test_data() -> None:
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 28))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
session.execute(
|
||||
update(SystemSetting)
|
||||
.where(SystemSetting.id == 1)
|
||||
.values(company_name="衍水集团", logo_path=None)
|
||||
# 开关参数恢复种子基线:测试提交的变更批次由 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()
|
||||
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
|
||||
if logo_dir.is_dir():
|
||||
for f in logo_dir.iterdir():
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ==================== 系统设置与上传文件的快照还原 ====================
|
||||
#
|
||||
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
|
||||
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
|
||||
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
|
||||
|
||||
_ASSET_KINDS = ("logo", "favicon")
|
||||
|
||||
|
||||
def _snapshot_company_info() -> dict | None:
|
||||
"""快照系统设置单行(无行时返回 None)。"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.get(SystemSetting, 1)
|
||||
if row is None:
|
||||
return None
|
||||
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
|
||||
|
||||
|
||||
def _restore_company_info(snapshot: dict | None) -> None:
|
||||
if snapshot is None:
|
||||
return
|
||||
factory = get_session_factory()
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
|
||||
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 _purge_after_each_test():
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
|
||||
def _preserve_company_info():
|
||||
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
|
||||
info = _snapshot_company_info()
|
||||
assets = _snapshot_assets()
|
||||
yield
|
||||
# 先还原文件:数据库路径随后回指同一批文件
|
||||
_restore_assets(assets)
|
||||
_restore_company_info(info)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _purge_after_each_test(_preserve_company_info):
|
||||
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
|
||||
|
||||
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
|
||||
清理**之后**还原(夹具 teardown 为逆序)。
|
||||
"""
|
||||
yield
|
||||
_purge_test_data()
|
||||
|
||||
|
||||
@@ -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, "摘要应体现新旧归属走向"
|
||||
|
||||
|
||||
# ==================== 客户评级列(V11) ====================
|
||||
|
||||
_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
|
||||
)
|
||||
@@ -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:
|
||||
"""客户评级落库(V11):每客户一行、服务端重算总分与等级、等级快照固化。
|
||||
|
||||
五项各 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
|
||||
@@ -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,10 @@ def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# 系统设置单行与上传文件的快照/还原已上移到 integration/conftest.py,
|
||||
# 对所有集成用例生效(不再只保护本模块)。
|
||||
|
||||
|
||||
class TestCompanyInfo:
|
||||
"""公司名称读取与更新。"""
|
||||
|
||||
@@ -84,10 +89,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:
|
||||
"""V11 精简:副标题/信用代码/地址/电话/邮箱已从模型与契约中删除。"""
|
||||
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 +120,41 @@ class TestPublicEndpoint:
|
||||
}
|
||||
|
||||
|
||||
class TestCompanyProfile:
|
||||
"""站点标题/品牌信息(V11):部分更新语义与空串清空。"""
|
||||
|
||||
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 +195,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
|
||||
@@ -40,6 +40,7 @@ EXPECTED_TABLES = {
|
||||
"shipment_items",
|
||||
"receipts",
|
||||
"customer_rating_grades",
|
||||
"customer_ratings",
|
||||
"performance_ledgers",
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
|
||||
+38
-4
@@ -23,6 +23,7 @@ DROP TABLE IF EXISTS `monthly_settlements`;
|
||||
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`;
|
||||
@@ -695,17 +696,50 @@ INSERT INTO customer_rating_grades (grade_name, min_score, max_score, sort_order
|
||||
('C', 60, 74, 3, '合格'),
|
||||
('D', 0, 59, 4, '待改进');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 客户评级落库(V11 迁移;每客户一行,等级名为快照固化无外键)
|
||||
-- ---------------------------------------------------------------------------
|
||||
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 迁移;公司信息单行;分摊计算待澄清另批)
|
||||
-- V11 迁移:增加品牌标语/页脚/备案号/站点标题/站点图标与图片版本号
|
||||
-- (公司名称/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,
|
||||
@@ -882,4 +916,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 ('90653814a421');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c4e2a86f9b13');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Running upgrade 90653814a421 -> 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 site_title VARCHAR(64) 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 favicon_path VARCHAR(255) COMMENT '站点图标相对路径(文件存 uploads/favicon/,null=使用内置 favicon.svg)';
|
||||
|
||||
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='c4e2a86f9b13' WHERE alembic_version.version_num = '90653814a421';
|
||||
@@ -0,0 +1,289 @@
|
||||
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
|
||||
|
||||
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
|
||||
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
|
||||
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
|
||||
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
|
||||
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
|
||||
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
|
||||
|
||||
## 1. 编制说明与证据等级
|
||||
|
||||
| 标记 | 含义 |
|
||||
| --- | --- |
|
||||
| `D` | 字典明确 |
|
||||
| `D?` | 候选待确认 |
|
||||
| `IT` | 需 IT |
|
||||
| `B` | 需业务 |
|
||||
| `S` | 系统自维护 |
|
||||
| `N` | 未检出 |
|
||||
|
||||
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
|
||||
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
|
||||
|
||||
## 2. 公共字段与同步元数据
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
|
||||
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
|
||||
|
||||
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
|
||||
|
||||
## 3. 人员档案
|
||||
|
||||
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
|
||||
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
|
||||
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
|
||||
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
|
||||
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
|
||||
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
|
||||
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
|
||||
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
|
||||
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
|
||||
|
||||
## 4. 产品档案
|
||||
|
||||
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
|
||||
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
|
||||
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
|
||||
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
|
||||
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
|
||||
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
|
||||
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
|
||||
|
||||
## 5. 业务单据(销售订单)
|
||||
|
||||
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
|
||||
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
|
||||
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
|
||||
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
|
||||
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
|
||||
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
|
||||
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
|
||||
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
|
||||
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
|
||||
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
|
||||
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
|
||||
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
|
||||
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
|
||||
|
||||
## 6. 发货单据(销售出库/发货)
|
||||
|
||||
发货日期有四个候选字段;运费未在发货单据检出。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
|
||||
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
|
||||
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
|
||||
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
|
||||
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
|
||||
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
|
||||
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
|
||||
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
|
||||
|
||||
## 7. 回款单据(收款/核销)
|
||||
|
||||
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
|
||||
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
|
||||
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
|
||||
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
|
||||
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
|
||||
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
|
||||
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
|
||||
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
|
||||
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
|
||||
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
|
||||
|
||||
## 8. 客户档案
|
||||
|
||||
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
|
||||
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
|
||||
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
|
||||
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
|
||||
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
|
||||
|
||||
## 9. 同步完整性与补充字段
|
||||
|
||||
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
|
||||
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
|
||||
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
|
||||
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
|
||||
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
|
||||
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
|
||||
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
|
||||
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
|
||||
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
|
||||
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
|
||||
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
|
||||
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
|
||||
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
|
||||
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
|
||||
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
|
||||
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
|
||||
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
|
||||
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
|
||||
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
|
||||
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
|
||||
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
|
||||
|
||||
## 10. 跨表关联与键
|
||||
|
||||
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
|
||||
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
|
||||
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
|
||||
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
|
||||
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
|
||||
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
|
||||
|
||||
## 11. 枚举与状态
|
||||
|
||||
| 枚举 | T+ 来源 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
|
||||
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
|
||||
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
|
||||
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
|
||||
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
|
||||
|
||||
## 12. 系统侧目标表缺口
|
||||
|
||||
| 缺口 | 说明 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
|
||||
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
|
||||
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
|
||||
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
|
||||
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
|
||||
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
|
||||
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
|
||||
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
|
||||
|
||||
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
|
||||
|
||||
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
|
||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
|
||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
|
||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
|
||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
|
||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
|
||||
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
|
||||
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
|
||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
|
||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
|
||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
|
||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
|
||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
|
||||
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
|
||||
|
||||
### 原 18 项对照
|
||||
|
||||
| 原# | 事项 | 原优先级 | 收敛去向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
|
||||
| 2 | 发货日期口径 | 高 | 2 |
|
||||
| 3 | 到账日期口径 | 高 | 2 |
|
||||
| 4 | 幂等唯一键 | 高 | 3 |
|
||||
| 5 | 三单关联键 | 高 | 4 |
|
||||
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
|
||||
| 7 | 对接方式与安全 | 高 | 6 |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
|
||||
| 9 | 存量范围与增量口径 | 中 | 8 |
|
||||
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
|
||||
| 11 | 收款方式枚举 | 中 | 10 |
|
||||
| 12 | 产品「数量」语义 | 中 | 11 |
|
||||
| 13 | 发货单价口径 | 中 | 11 |
|
||||
| 14 | 特殊业务标记 | 低 | 12 |
|
||||
| 15 | 同步窗口 | 低 | 13 |
|
||||
| 16 | 回款确认 / 核销状态 | 高 | 10 |
|
||||
| 17 | 市场拓展计划数据 | 低 | 12 |
|
||||
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
|
||||
|
||||
## 14. 待确认清单
|
||||
|
||||
**IT(13 项)**:见第 13 节新# 1–13。
|
||||
|
||||
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
|
||||
|
||||
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
|
||||
|
||||
## 15. 验证记录
|
||||
|
||||
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
|
||||
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
|
||||
- 编码:逐页检测,GB18030(无替换字符);
|
||||
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
|
||||
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
|
||||
|
||||
## 附录 A:六类主表索引
|
||||
|
||||
| 类别 | T+ 表 | 字典页 GUID |
|
||||
| --- | --- | --- |
|
||||
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
|
||||
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
|
||||
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
|
||||
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
|
||||
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
|
||||
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
|
||||
|
||||
## 附录 B:75 项覆盖矩阵
|
||||
|
||||
| 节 | 项数 | 已映射 |
|
||||
| --- | --- | --- |
|
||||
| 公共要求 | 2 | 2 |
|
||||
| 人员 | 9 | 9 |
|
||||
| 产品 | 7 | 7 |
|
||||
| 业务 | 13 | 13 |
|
||||
| 发货 | 8 | 8 |
|
||||
| 回款 | 10 | 10 |
|
||||
| 客户 | 5 | 5 |
|
||||
| 同步完整性 | 21 | 21 |
|
||||
|
||||
## 附录 C:失败页
|
||||
|
||||
无(`failures.csv` 为空)。
|
||||
|
||||
## 附录 D:复现方法
|
||||
|
||||
```text
|
||||
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
|
||||
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
|
||||
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
|
||||
Binary file not shown.
@@ -56,8 +56,9 @@
|
||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||
|
||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
||||
## 三、待 IT / T+ 侧答复(18 项 → 收敛为 13 项 + 1 组业务裁决)
|
||||
|
||||
> **2026-10-20 更新**:T+12.3 数据字典已解析(1,457 页 / 931 张表 / 21,019 个字段),字段级映射已产出:`docs/T+字段映射草案_v0.1_2026-10-20.md`、`docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx`。原 18 项收敛为 13 项 IT 待确认(每项附字典候选字段)+ 1 组业务裁决,详见催办清单第二节与 v1.5「待IT确认事项」sheet。以下原表保留为 2026-10-19 基线。
|
||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
||||
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||
@@ -93,6 +94,6 @@
|
||||
## 五、文档待办(低优)
|
||||
|
||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||
- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
|
||||
- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.5**(2026-10-20,含 75 项映射 + 13 项 IT 收敛 + 不覆盖 12 条),映射草案 v0.1 已产出
|
||||
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
||||
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
||||
+23
-19
@@ -39,31 +39,35 @@
|
||||
|
||||
## 二、IT / T+ 侧(建议回复期限:____)
|
||||
|
||||
> 2026-10-15 回稿收到后,v1.3「待IT确认事项」18 项**未逐项答复**(回稿仅做了必要性升级与第七节补充),机制性内容(同步频率/幂等/格式要求)未包含,「同步不覆盖字段」表被引用但未附。现有 v1.4 清单中「系统侧目标表.字段」列**全表留空待回填**。
|
||||
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
|
||||
|
||||
### A. 高优先(9 项,阻塞核算口径与同步程序开工)
|
||||
### A. 待确认(按优先级)
|
||||
|
||||
| # | 事项 | 为什么高 |
|
||||
| --- | --- | --- |
|
||||
| 1 | **成本额取数口径**:成本类型/毛利计算方式/更新时间/精度 | 毛利润 = 销售额 − 成本额,是绩效金额全部源头;缺此则同步无法自动跑,只能人工录入 |
|
||||
| 2 | 发货日期口径:出库日 / 签收日 / 开票日 | 回款天数基准日(发货日首日计第 1 天),直接决定计提比例 |
|
||||
| 3 | 到账日期口径:收款日 / 银行到账日 / 核销日 | 计提参数版本匹配日,直接决定适用哪一版比例 |
|
||||
| 4 | 幂等唯一键规则:作废重开是否换号 | 重复同步不重复入库的前提 |
|
||||
| 5 | 三单关联键:订单 / 发货 / 回款如何互相关联 | 一笔业务可分批发货;回款↔批次系统内人工指定,但订单↔发货关联仍必须由 T+ 提供 |
|
||||
| 6 | **人员唯一编码(工号)** + 业务员↔营销经理隶属关系 | 业绩归属唯一锚点(重名即错钱);经理 12.5% 团队聚合依隶属关系展开 |
|
||||
| 7 | 对接方式与安全:REST / 数据库视图 / 中间表 / 文件交换 | 未定则同步程序无法开工(一期人工录入可闭环) |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则与原单关联 | 决定第七节补充字段如何建列;如不提供则只能靠人工补录 |
|
||||
| 16 | 回款确认 / 核销状态字段与枚举 | 月度结算按「确认回款」生成,需 T+ 核销/确认类字段作人工确认依据 |
|
||||
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
|
||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
|
||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
|
||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
|
||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
|
||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
|
||||
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
|
||||
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
|
||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
|
||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/cancelState/voucherstate | 完整枚举与以物抵顶代码;核销状态含义与人工确认依据 |
|
||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量取现存量/在途/累计销量;发货单价口径与含税 |
|
||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
|
||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
|
||||
|
||||
### B. 回稿三缺口(请补)
|
||||
### B. 口径裁决(1 组,转业务方)
|
||||
|
||||
1. v1.3 第七节曾漏「人员唯一编码(工号)」——v1.4 数据清单第一节已补★项并注明「待IT确认 #19:T+ 档案编码是否即工号,若为自动内部编码请指明工号字段」;
|
||||
2. 「客户档案是否同步」(待IT确认 #10)未点名答复——v1.4 第六节已列客户档案 5 项,请确认可提供;
|
||||
3. 「系统侧目标表.字段(T+ 确认映射)」列全表留空——请回填,这是同步程序的字段契约。
|
||||
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
|
||||
|
||||
### C. 中低优先(9 项,模块实现期内答复即可)
|
||||
### C. 已由字典补齐、仅需确认可用性
|
||||
|
||||
存量数据起始日期与增量口径(影响能出多早的报表)、客户匹配与唯一键、收款方式完整枚举清单、产品「数量」语义(库存量/在途/累计销量)、发货单价口径(订单价/发货时价)、特殊业务标记字段、同步窗口时间、市场拓展计划数据、待IT确认 #18(口径裁决 5 项已转业务方,见第一部分)。
|
||||
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
|
||||
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
|
||||
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
|
||||
|
||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 衍水集团营销人员工资管理系统项目阶段报告
|
||||
|
||||
- **报告日期**:2026-10-20
|
||||
- **报告范围**:M4 ⑤ 报表统计、⑧ 工作台完成情况与 M4 收尾结论
|
||||
- **项目分支**:`feat/m3-increments`(已合并 `main`,merge commit `3611474`)
|
||||
- **阶段结论**:除 ⑥ T+ 数据同步受外部答复阻塞外,十大功能块全部交付;后端全量 **862 用例通过**,报表、工作台、核算月结三条 e2e 链路实跑通过。
|
||||
|
||||
## 一、3P 阶段摘要
|
||||
|
||||
### Progress(进展)
|
||||
|
||||
M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review 修复。报表侧交付个人绩效明细、部门汇总、业务统计、回款统计四张报表、Excel 签字导出与独立打印视图;工作台侧交付经营总览、待办事项、风险预警、最近动态四区块,待办与预警实时派生并参数化。本阶段同时完成 M4 修复批次的提交、全量回归与 e2e 实跑,开发计划 3.3 答复状态已回填。
|
||||
|
||||
### Plans(计划)
|
||||
|
||||
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;其次补齐 code-review 遗留的方案版本承载与审批单快照;需规 V2.2 修订与对照表回填择期进行。
|
||||
|
||||
### Problems(问题)
|
||||
|
||||
⑥ T+ 数据同步仍受 IT 18 项答复阻塞,同步失败类待办/预警暂以「模块未启用」显式降级。业务方仍有 #1–#3 二次分摊、#20 含税口径、#4/#6/#12 补值与两处勘误待确认;审批角色权限细目未给出,当前按 `role_code` 粗粒度运行。需规仍含已作废条款,对外引用需注明以对照表/计划第三章为准。
|
||||
|
||||
## 二、阶段定位与完成度
|
||||
|
||||
| 维度 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 项目阶段 | 开发收尾期 | M1–M3 与 M4 ⑤⑧ 已交付,仅 ⑥ 待外部输入 |
|
||||
| 整体完成度 | 90% | 十大功能块交付;⑥ 按 0% 计入 |
|
||||
| 交付主线 | M4 报表统计 + 工作台 | 前后端闭环,已合并 main |
|
||||
| 质量状态 | 全绿 | 后端 862 passed(11:32);e2e role_home 4/4、report 12/12、calc_settlement 8/8 |
|
||||
| 主要风险 | 外部对接与口径变更 | T+ 映射未定;含税口径未答复可能触发历史绩效重算 |
|
||||
|
||||
## 三、已完成成果
|
||||
|
||||
### 1. 报表统计(M4 ⑤)
|
||||
|
||||
- 四表接口 `/api/v1/reports/*`:个人绩效明细、部门绩效汇总、业务统计、回款区间统计。
|
||||
- 部门归属按 `personnel.manager_id` 实时上溯;回款区间 8 档(≤0 / 1-30 / 31-60 / 61-90 / 91-180 / 181-360 / 361+ / 无天数)。
|
||||
- 全公司 5% 按「有聚合行经理各计一次」计算,并与月结 `total_payable` 对拍;对账块单列聚合行舍入差。
|
||||
- Excel 签字导出(openpyxl + StreamingResponse,ASCII 文件名)与独立打印视图(五块结构、每页表头、签字栏)。
|
||||
- 权限 `admin/finance/leader` 前后端双层。
|
||||
|
||||
### 2. 工作台(M4 ⑧)
|
||||
|
||||
- 单端点 `/api/v1/dashboard` 一次返回四区块:经营总览、待办事项、风险预警、最近动态。
|
||||
- 待办与预警**实时派生**(不落表、不引调度器);待办按角色过滤;逾期预警排除已终止业务。
|
||||
- 预警阈值参数化:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10,参数注册表 22 项)。
|
||||
- 前端 `/dashboard` 四区块 + ECharts 5.6 按需引入(SVG,显式思源黑体,规避 Windows 默认雅黑)。
|
||||
- 普通用户落地页统一由 `router/access.ts` 解析(工作台 → 业务数据 → 回款),修复无限重定向。
|
||||
|
||||
### 3. 质量与收尾
|
||||
|
||||
- 后端全量 `uv run pytest -q` → **862 passed(11:32)**;`pnpm type-check` 与 `pnpm build` 通过。
|
||||
- e2e 实跑:`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8;`report_flow` 回款统计改为按本次业务编号下钻断言,脚本可重复运行。
|
||||
- 运行后核对:月结审批开关恢复为 1,无 `e2e_role_*` 残留账号。
|
||||
- 开发计划 3.3.1 与《待确认事项汇总》已按 24 项答复分组回填(13 默认 / 4 按答复 / 3 部分 / 4 开放)。
|
||||
|
||||
## 四、当前进行中工作
|
||||
|
||||
无。M4 代码与文档已提交并合并 `main`(`3611474`)。
|
||||
|
||||
## 五、下一步计划
|
||||
|
||||
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
|
||||
2. **code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。
|
||||
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
|
||||
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
|
||||
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
|
||||
|
||||
## 六、风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| IT 答复持续缺失 | ⑥ 无法开工,同步类待办/预警无法真实展示 | 先做数据字典映射草案;催办清单已含高优 9 项与三缺口 |
|
||||
| 含税口径(#20)改变 | 历史绩效可能需要重算 | 当前按不含税实现;答复后评估重算范围并授权留痕 |
|
||||
| 二次分摊口径(#1–#3)改变 | 已生成台账/月结可能需要调整 | 暂定口径已标注;变更走口径调整,重算需授权留痕 |
|
||||
| 需规含作废条款 | 对外交付物误导 | 引用以对照表/计划第三章为准;V2.2 修订前排期 |
|
||||
|
||||
## 七、报告结论
|
||||
|
||||
M4 报表统计与工作台已完成交付并合并 `main`,系统除 ⑥ T+ 数据同步外具备完整业务闭环。质量证据充分(全量测试与三条 e2e 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 默认站点图标:未配置公司 logo 时使用(配置后由 settings store 动态替换) -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>衍水集团营销人员工资管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#4a66c4" />
|
||||
<stop offset="1" stop-color="#2e4ba8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#brand)" />
|
||||
<text
|
||||
x="32"
|
||||
y="43"
|
||||
text-anchor="middle"
|
||||
font-family="'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif"
|
||||
font-size="32"
|
||||
font-weight="600"
|
||||
fill="#ffffff"
|
||||
>衍</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -1,5 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useSettingsStore } from '@/store/settings'
|
||||
|
||||
// 根组件只承载路由出口;布局由各页面自行决定
|
||||
const route = useRoute()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
/**
|
||||
* 浏览器标题 =「页面名 - 公司名 系统副标题」,跟随公司信息设置动态变化。
|
||||
*
|
||||
* 用 watch 而非 router.afterEach:品牌信息是异步载入的(匿名端点),
|
||||
* afterEach 执行时只能取到当时的旧值;watch 在公司信息载入/设置页保存
|
||||
* 后自动重算标题,无需等下一次路由跳转。
|
||||
*/
|
||||
watch(
|
||||
() => [route.meta.title, settingsStore.brandTitle] as const,
|
||||
() => {
|
||||
const title = route.meta.title as string | undefined
|
||||
document.title = title ? `${title} - ${settingsStore.brandTitle}` : settingsStore.brandTitle
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 品牌信息全局只载入一次(登录页/布局另有调用,内部去重)
|
||||
void settingsStore.ensureLoaded()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface CustomerItem {
|
||||
owner_personnel_id: number | null
|
||||
owner_personnel_name: string | null
|
||||
remark: string | null
|
||||
/** 评级总分(字符串直出,未评级为 null;等级由总分判定、同向) */
|
||||
rating_total: string | null
|
||||
/** 评级等级快照(保存时点固化;未评级/未命中为 null) */
|
||||
rating_grade_name: string | null
|
||||
}
|
||||
|
||||
export interface CustomerQueryParams {
|
||||
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
|
||||
keyword?: string
|
||||
customer_type?: string
|
||||
owner_personnel_id?: number
|
||||
/** 排序字段白名单:rating = 客户评级总分(后端校验,非法值 400) */
|
||||
order_field?: 'rating'
|
||||
/** 排序方向;未评级恒在末尾(后端 NULLS LAST 模拟) */
|
||||
order_type?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
|
||||
|
||||
@@ -23,6 +23,26 @@ export interface ParamCatalogItem {
|
||||
values: ParamDimensionValue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 枚举参数值 → 中文(展示层翻译;库与接口仍存英文码,审计口径不变)。
|
||||
*
|
||||
* 业界惯例(RuoYi 字典标签同款):映射单一出处,避免页内复制漂移;
|
||||
* 未知码原样回显 —— 后端新增枚举项而前端未跟上时不会显示成空。
|
||||
*/
|
||||
export const PARAM_ENUM_LABELS: Record<string, string> = {
|
||||
FULL: '当月全额',
|
||||
BALANCE: '余额',
|
||||
INCLUDE: '含基本工资',
|
||||
DEDUCT: '扣除基本工资',
|
||||
EXCLUDE: '不计基本工资',
|
||||
'0': '关',
|
||||
'1': '开',
|
||||
}
|
||||
|
||||
export function paramEnumLabel(code: string): string {
|
||||
return PARAM_ENUM_LABELS[code] ?? code
|
||||
}
|
||||
|
||||
/** 参数目录(asOf 缺省为今日) */
|
||||
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
|
||||
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
|
||||
|
||||
@@ -54,7 +54,57 @@ export function updateRatingGrade(
|
||||
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
|
||||
/**
|
||||
* 删除评级等级(物理删除;客户评级行快照固化等级名、无外键引用,删等级
|
||||
* 不伤已评级客户的历史结论;允许清空,新评级等级记为 null)。
|
||||
*/
|
||||
export function deleteRatingGrade(gradeId: number): Promise<null> {
|
||||
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// ==================== 客户评级(每客户一行,落库) ====================
|
||||
|
||||
/**
|
||||
* 客户当前评级(对应后端 `CustomerRatingOut`)。
|
||||
*
|
||||
* 分值按红线以字符串直出(比较前显式 parseFloat);total_score 由服务端
|
||||
* 按五项重算。grade_name 是保存时点的等级**快照**(无外键),可能为 null
|
||||
* (总分未命中任何等级 / 当时等级表为空)。
|
||||
*/
|
||||
export interface CustomerRatingItem {
|
||||
customer_id: number
|
||||
score_ship_amount: string
|
||||
score_receipt_rate: string
|
||||
score_profit_rate: string
|
||||
score_contract_cooperation: string
|
||||
score_site_construction: string
|
||||
total_score: string
|
||||
grade_name: string | null
|
||||
remark: string | null
|
||||
scorer_name: string
|
||||
create_time: string
|
||||
update_time: string
|
||||
}
|
||||
|
||||
/** 保存评级入参:五项 0-20 数字字符串;总分与等级由服务端重算 */
|
||||
export interface CustomerRatingParams {
|
||||
score_ship_amount: string
|
||||
score_receipt_rate: string
|
||||
score_profit_rate: string
|
||||
score_contract_cooperation: string
|
||||
score_site_construction: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 查某客户当前评级;未评级时 data 为 null(正常态,不是错误) */
|
||||
export function getCustomerRating(customerId: number): Promise<CustomerRatingItem | null> {
|
||||
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'get' })
|
||||
}
|
||||
|
||||
/** 保存客户评级(upsert:首次创建、再次保存整体覆盖) */
|
||||
export function saveCustomerRating(
|
||||
customerId: number,
|
||||
data: CustomerRatingParams,
|
||||
): Promise<CustomerRatingItem> {
|
||||
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
|
||||
}
|
||||
@@ -1,37 +1,64 @@
|
||||
import service, { request } from './request'
|
||||
import { request } from './request'
|
||||
|
||||
/** 公司信息(管理端) */
|
||||
/**
|
||||
* 公司信息与站点信息(管理端)。
|
||||
*
|
||||
* 2026-10-20 精简:删除系统副标题与企业档案字段(信用代码/地址/电话/邮箱),
|
||||
* 新增站点标题(浏览器标签页/侧边栏副标题)与站点图标(标签页 favicon)。
|
||||
* 展示字段全部可空:null 表示未配置,前端回退内置默认值。
|
||||
*/
|
||||
export interface CompanySettings {
|
||||
company_name: string
|
||||
/** 未上传时为 null */
|
||||
/** 登录页品牌位 logo;未上传时为 null */
|
||||
logo_url: string | null
|
||||
site_title: string | null
|
||||
brand_slogan: string | null
|
||||
footer_text: string | null
|
||||
icp_no: string | null
|
||||
/** 浏览器标签页图标;未上传时为 null(前端用内置 favicon.svg) */
|
||||
favicon_url: string | null
|
||||
}
|
||||
|
||||
/** 匿名端点出参(登录页消费;与后端 SettingPublicOut 白名单一致) */
|
||||
export interface PublicSettings {
|
||||
company_name: string
|
||||
logo_url: string | null
|
||||
/** 匿名端点出参(登录页/浏览器标签页消费;与后端 SettingPublicOut 白名单一致) */
|
||||
export type PublicSettings = CompanySettings
|
||||
|
||||
/**
|
||||
* 更新入参:**部分更新**语义 —— 仅携带的字段生效,未携带字段保持不变。
|
||||
* 可选文本传空串等价清空(后端落库 null、前端回退默认)。
|
||||
*/
|
||||
export interface SettingsUpdatePayload {
|
||||
company_name?: string
|
||||
site_title?: string
|
||||
brand_slogan?: string
|
||||
footer_text?: string
|
||||
icp_no?: string
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<CompanySettings> {
|
||||
return request({ url: '/v1/system/settings', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateSettings(companyName: string): Promise<CompanySettings> {
|
||||
return request({
|
||||
url: '/v1/system/settings',
|
||||
method: 'put',
|
||||
data: { company_name: companyName },
|
||||
})
|
||||
export function updateSettings(payload: SettingsUpdatePayload): Promise<CompanySettings> {
|
||||
return request({ url: '/v1/system/settings', method: 'put', data: payload })
|
||||
}
|
||||
|
||||
/** logo 上传走独立的 FormData(multipart,非 JSON) */
|
||||
/**
|
||||
* 图片上传走独立的 FormData(multipart,非 JSON)。
|
||||
*
|
||||
* 经统一 request 封装解包信封(响应形如 {code,message,data});Content-Type
|
||||
* 交给浏览器/axios 为 FormData 自动生成(含 boundary),不手工设置。
|
||||
*/
|
||||
export function uploadLogo(file: File): Promise<CompanySettings> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return service.post('/v1/system/settings/logo', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return request({ url: '/v1/system/settings/logo', method: 'post', data: form })
|
||||
}
|
||||
|
||||
/** 站点图标上传(浏览器标签页;png/jpg/ico ≤2MB) */
|
||||
export function uploadFavicon(file: File): Promise<CompanySettings> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request({ url: '/v1/system/settings/favicon', method: 'post', data: form })
|
||||
}
|
||||
|
||||
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import AppMenu from '@/components/layout/AppMenu.vue'
|
||||
import { firstAccessiblePath } from '@/router/access'
|
||||
import { useSettingsStore } from '@/store/settings'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 品牌区(名称/副标题/logo)来自公司信息设置;App 已全局载入一次,这里兜底
|
||||
onMounted(() => {
|
||||
void settingsStore.ensureLoaded()
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
@@ -44,8 +51,8 @@ async function handleLogout(): Promise<void> {
|
||||
<el-container class="app-layout">
|
||||
<el-aside class="app-aside" width="220px">
|
||||
<div class="app-brand">
|
||||
<span class="app-brand__name">衍水集团</span>
|
||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
|
||||
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
|
||||
<span class="app-brand__line"></span>
|
||||
</div>
|
||||
<AppMenu class="app-aside__menu" />
|
||||
@@ -99,8 +106,8 @@ async function handleLogout(): Promise<void> {
|
||||
:with-header="false"
|
||||
>
|
||||
<div class="app-brand">
|
||||
<span class="app-brand__name">衍水集团</span>
|
||||
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||
<span class="app-brand__name">{{ settingsStore.companyName }}</span>
|
||||
<span class="app-brand__sub">{{ settingsStore.siteTitle }}</span>
|
||||
<span class="app-brand__line"></span>
|
||||
</div>
|
||||
<AppMenu />
|
||||
|
||||
@@ -143,14 +143,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/business/receipts/index.vue'),
|
||||
meta: { title: '回款管理', group: '业务数据', order: 15 },
|
||||
},
|
||||
{
|
||||
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
|
||||
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
|
||||
path: '/business/rating',
|
||||
name: 'business-rating',
|
||||
component: () => import('@/views/business/rating/index.vue'),
|
||||
meta: { title: '客户评级计算器', group: '业务数据', order: 16 },
|
||||
},
|
||||
// 客户评级已改为「客户管理」页行内弹窗(2026-10-19):删除独立页面与
|
||||
// 菜单入口(菜单由路由表派生,删路由即删菜单);旧 URL 落入兜底重定向
|
||||
{
|
||||
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
|
||||
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
|
||||
@@ -282,9 +276,7 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = to.meta.title as string | undefined
|
||||
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
|
||||
})
|
||||
// 浏览器标题在 App.vue 中由「当前页 + 公司品牌」响应式计算
|
||||
// (品牌异步载入/管理端改名后要自动更新,afterEach 只能取到旧值)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,125 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getPublicSettings } from '@/api/settings'
|
||||
import { setFavicon } from '@/utils/favicon'
|
||||
|
||||
/**
|
||||
* 品牌与站点信息全局状态。
|
||||
*
|
||||
* 数据来自匿名 public 端点(登录页无令牌也需要),设置未配置/接口失败时
|
||||
* 回退内置默认值 —— 与改造前硬编码的品牌文案一致,存量行为零变化。
|
||||
*
|
||||
* 两个关键设计:
|
||||
* - **乐观更新**:管理端保存/上传后直接用回包调 ``apply``,而不是重新请求
|
||||
* public 端点 —— 匿名端点带 300s 浏览器缓存,管理员改完自己刷新会先看到
|
||||
* 旧值(与登录页历史口径一致)
|
||||
* - **站点图标优先使用自定义图标,未配置时跟随公司 logo**(2026-10-20 用户
|
||||
* 确认):换 logo 后标签页图标随之变化;两者都没有时才用内置 SVG
|
||||
*/
|
||||
export const DEFAULT_COMPANY_NAME = '衍水集团'
|
||||
export const DEFAULT_SITE_TITLE = '营销人员工资管理系统'
|
||||
export const DEFAULT_BRAND_SLOGAN = '把每一笔回款,算成一份踏实。'
|
||||
const DEFAULT_FAVICON = '/favicon.svg'
|
||||
|
||||
/** 品牌/站点相关字段(public 白名单 / 管理端出参的公共子集) */
|
||||
interface BrandInfo {
|
||||
company_name?: string
|
||||
logo_url?: string | null
|
||||
site_title?: string | null
|
||||
brand_slogan?: string | null
|
||||
footer_text?: string | null
|
||||
icp_no?: string | null
|
||||
favicon_url?: string | null
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const companyName = ref(DEFAULT_COMPANY_NAME)
|
||||
const siteTitle = ref(DEFAULT_SITE_TITLE)
|
||||
const brandSlogan = ref(DEFAULT_BRAND_SLOGAN)
|
||||
const footerText = ref<string | null>(null)
|
||||
const icpNo = ref<string | null>(null)
|
||||
const logoUrl = ref<string | null>(null)
|
||||
const faviconUrl = ref<string | null>(null)
|
||||
|
||||
/** public 端点是否已成功载入(失败不置位,下次挂载自然重试) */
|
||||
const loaded = ref(false)
|
||||
let pending: Promise<void> | null = null
|
||||
|
||||
/** 浏览器标题后缀:公司名 + 站点标题(站点标题为空则只有公司名) */
|
||||
const brandTitle = computed(() =>
|
||||
siteTitle.value ? `${companyName.value} ${siteTitle.value}` : companyName.value,
|
||||
)
|
||||
|
||||
/** 登录页页脚:未配置版权文本时用「© 公司名 · 内部系统」 */
|
||||
const footerLine = computed(() => footerText.value || `© ${companyName.value} · 内部系统`)
|
||||
|
||||
/**
|
||||
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
|
||||
*
|
||||
* 未单独上传站点图标时跟随 logo —— 管理员换 logo,浏览器标签页图标同步变化。
|
||||
*/
|
||||
const effectiveFavicon = computed(
|
||||
() => faviconUrl.value ?? logoUrl.value ?? DEFAULT_FAVICON,
|
||||
)
|
||||
|
||||
/**
|
||||
* 应用品牌/站点信息(public 回包与设置页保存回包共用)。
|
||||
*
|
||||
* 可选字段为 null/空串表示**清空**(回退默认);undefined 表示回包未
|
||||
* 携带该字段(保持不变)。company_name 为必填,空值忽略。
|
||||
*/
|
||||
function apply(info: BrandInfo): void {
|
||||
if (info.company_name) companyName.value = info.company_name
|
||||
if (info.logo_url !== undefined) logoUrl.value = info.logo_url
|
||||
if (info.favicon_url !== undefined) faviconUrl.value = info.favicon_url
|
||||
if (info.site_title !== undefined) {
|
||||
siteTitle.value = info.site_title || DEFAULT_SITE_TITLE
|
||||
}
|
||||
if (info.brand_slogan !== undefined) {
|
||||
brandSlogan.value = info.brand_slogan || DEFAULT_BRAND_SLOGAN
|
||||
}
|
||||
if (info.footer_text !== undefined) footerText.value = info.footer_text || null
|
||||
if (info.icp_no !== undefined) icpNo.value = info.icp_no || null
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保品牌/站点信息已载入(App/布局/登录页启动时调用;并发去重)。
|
||||
*
|
||||
* 失败静默回退内置默认值 —— 品牌不是关键路径,不能阻断登录或业务页面。
|
||||
*/
|
||||
function ensureLoaded(): Promise<void> {
|
||||
if (loaded.value) return Promise.resolve()
|
||||
if (pending) return pending
|
||||
pending = (async () => {
|
||||
try {
|
||||
apply(await getPublicSettings())
|
||||
} catch {
|
||||
// 静默:保持内置默认品牌(登录页必须可用)
|
||||
} finally {
|
||||
pending = null
|
||||
}
|
||||
})()
|
||||
return pending
|
||||
}
|
||||
|
||||
// favicon 跟随生效图标(自定义 → logo → 内置);换图由 ?v= 版本参数破缓存
|
||||
watch(effectiveFavicon, (url) => setFavicon(url), { immediate: true })
|
||||
|
||||
return {
|
||||
companyName,
|
||||
siteTitle,
|
||||
brandSlogan,
|
||||
footerText,
|
||||
icpNo,
|
||||
logoUrl,
|
||||
faviconUrl,
|
||||
effectiveFavicon,
|
||||
loaded,
|
||||
brandTitle,
|
||||
footerLine,
|
||||
apply,
|
||||
ensureLoaded,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 动态站点图标。
|
||||
*
|
||||
* 手写同款实现(参照 vueuse `useFavicon`):修改已有 `<link rel="icon">`
|
||||
* 的 href 即可让浏览器重新加载图标;没有 link 时补建一个。项目为保持
|
||||
* 直接依赖克制(5 个)未引入 @vueuse/core,故按同口径手写。
|
||||
*
|
||||
* 缓存:logo 地址自带 `?v=更新时间戳` 版本参数,换图后不会被浏览器缓存拖住。
|
||||
*/
|
||||
const FAVICON_SELECTOR = 'link[rel~="icon"]'
|
||||
|
||||
export function setFavicon(href: string): void {
|
||||
if (typeof document === 'undefined') return
|
||||
let link = document.querySelector<HTMLLinkElement>(FAVICON_SELECTOR)
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = href
|
||||
}
|
||||
+243
-138
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
createRatingGrade,
|
||||
deleteRatingGrade,
|
||||
getCustomerRating,
|
||||
listRatingGrades,
|
||||
saveCustomerRating as saveCustomerRatingApi,
|
||||
updateRatingGrade,
|
||||
type RatingGradeItem,
|
||||
type RatingGradeParams,
|
||||
@@ -14,13 +16,29 @@ import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
|
||||
* 客户评级弹窗(2026-10-19 由独立「客户评级计算器」页面迁移而来)。
|
||||
*
|
||||
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
|
||||
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
|
||||
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
|
||||
* 重载判定用列表——判定与抽屉表格共用同一份数据。
|
||||
* 双 Tab 设计避免三层弹层:
|
||||
* - Tab1「客户评级」:五项打分 → 总分/等级实时判定 → 保存落库(每客户一行,
|
||||
* 等级按保存时点的映射快照固化);口径提示「评分不参与绩效金额计算」
|
||||
* (对照表 #25)常驻
|
||||
* - Tab2「等级映射设置」:等级表增删改(原页面抽屉整体迁入),判定规则与
|
||||
* 打分区共用同一份列表,配置变更后实时生效
|
||||
*
|
||||
* 判定与后端 ``rating_service.resolve_grade`` 同规则:按 sort_order 升序取
|
||||
* 首个 min_score ≤ 总分的等级;无命中显示未命中提示、保存后等级为 null。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 目标客户(未打开弹窗时可为 0,仅打开后读取) */
|
||||
customerId: number
|
||||
customerName: string
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const activeTab = ref<'score' | 'grades'>('score')
|
||||
|
||||
// ==================== 等级判定(纯函数) ====================
|
||||
|
||||
@@ -29,10 +47,9 @@ import { handledError } from '@/utils/error'
|
||||
*
|
||||
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
|
||||
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
|
||||
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
|
||||
*/
|
||||
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
|
||||
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
|
||||
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id)
|
||||
for (const grade of ordered) {
|
||||
if (totalScore >= parseFloat(grade.min_score)) {
|
||||
return grade
|
||||
@@ -41,7 +58,7 @@ function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGrad
|
||||
return null
|
||||
}
|
||||
|
||||
// ==================== 评分计算(纯前端实时计算) ====================
|
||||
// ==================== 评分计算 ====================
|
||||
|
||||
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
|
||||
const SCORE_ITEMS = [
|
||||
@@ -67,21 +84,49 @@ const totalScore = computed(() =>
|
||||
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
|
||||
)
|
||||
|
||||
/** 无保存动作(不落库),重置即把五项清零 */
|
||||
function resetScores(): void {
|
||||
for (const item of SCORE_ITEMS) {
|
||||
scores[item.key] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 等级映射(判定用列表) ====================
|
||||
const remark = ref('')
|
||||
/** 已存在的评级(用于回显打分与「上次评级」信息) */
|
||||
const hasExisting = ref(false)
|
||||
const existingInfo = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadRating(): Promise<void> {
|
||||
try {
|
||||
const rating = await getCustomerRating(props.customerId)
|
||||
if (!rating) {
|
||||
hasExisting.value = false
|
||||
existingInfo.value = null
|
||||
resetScores()
|
||||
remark.value = ''
|
||||
return
|
||||
}
|
||||
hasExisting.value = true
|
||||
// 后端字符串直出,回填 parseFloat;保存时再 String() 转回
|
||||
scores.ship_amount = parseFloat(rating.score_ship_amount)
|
||||
scores.receipt_rate = parseFloat(rating.score_receipt_rate)
|
||||
scores.profit_rate = parseFloat(rating.score_profit_rate)
|
||||
scores.contract_cooperation = parseFloat(rating.score_contract_cooperation)
|
||||
scores.site_construction = parseFloat(rating.score_site_construction)
|
||||
remark.value = rating.remark ?? ''
|
||||
existingInfo.value = `上次由 ${rating.scorer_name} 评定于 ${rating.update_time.replace('T', ' ').slice(0, 16)}`
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 等级映射(判定用列表 + 设置表共用) ====================
|
||||
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
const grades = ref<RatingGradeItem[]>([])
|
||||
const gradesLoading = ref(false)
|
||||
|
||||
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
|
||||
async function loadGrades(): Promise<void> {
|
||||
gradesLoading.value = true
|
||||
try {
|
||||
@@ -95,17 +140,47 @@ async function loadGrades(): Promise<void> {
|
||||
|
||||
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
|
||||
|
||||
// ==================== 等级映射设置抽屉 ====================
|
||||
// ==================== 打开即初始化 ====================
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
/** 打开时重新拉取,避免展示陈旧数据 */
|
||||
function openDrawer(): void {
|
||||
drawerVisible.value = true
|
||||
void loadGrades()
|
||||
async function init(): Promise<void> {
|
||||
activeTab.value = 'score'
|
||||
hasExisting.value = false
|
||||
existingInfo.value = null
|
||||
resetScores()
|
||||
remark.value = ''
|
||||
if (!props.customerId) return
|
||||
await Promise.all([loadGrades(), loadRating()])
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑等级弹窗 ====================
|
||||
watch(visible, (open) => {
|
||||
if (open) void init()
|
||||
})
|
||||
|
||||
// ==================== 保存评级 ====================
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!props.customerId) return
|
||||
saving.value = true
|
||||
try {
|
||||
await saveCustomerRatingApi(props.customerId, {
|
||||
score_ship_amount: String(scores.ship_amount ?? 0),
|
||||
score_receipt_rate: String(scores.receipt_rate ?? 0),
|
||||
score_profit_rate: String(scores.profit_rate ?? 0),
|
||||
score_contract_cooperation: String(scores.contract_cooperation ?? 0),
|
||||
score_site_construction: String(scores.site_construction ?? 0),
|
||||
remark: remark.value.trim() || undefined,
|
||||
})
|
||||
ElMessage.success('评级已保存')
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑等级(二层弹窗) ====================
|
||||
|
||||
const editVisible = ref(false)
|
||||
/** null = 新增,否则为编辑目标 id */
|
||||
@@ -150,7 +225,6 @@ function editFormError(minScore: number | undefined, maxScore: number | undefine
|
||||
}
|
||||
|
||||
async function saveGrade(): Promise<void> {
|
||||
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
|
||||
const minScore = editForm.min_score
|
||||
const maxScore = editForm.max_score
|
||||
const error = editFormError(minScore, maxScore)
|
||||
@@ -165,7 +239,7 @@ async function saveGrade(): Promise<void> {
|
||||
max_score: String(maxScore),
|
||||
sort_order: editForm.sort_order ?? 0,
|
||||
}
|
||||
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
|
||||
// 后端为全量替换语义;remark 本弹窗不维护,编辑时原样带回避免误清
|
||||
if (editingId.value !== null) {
|
||||
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
|
||||
}
|
||||
@@ -180,6 +254,7 @@ async function saveGrade(): Promise<void> {
|
||||
editVisible.value = false
|
||||
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
|
||||
await loadGrades()
|
||||
emit('saved')
|
||||
} catch (requestError) {
|
||||
handledError(requestError)
|
||||
} finally {
|
||||
@@ -187,116 +262,134 @@ async function saveGrade(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
|
||||
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级,已评级客户快照不受影响) */
|
||||
function removeGrade(row: RatingGradeItem): void {
|
||||
void confirmAndRun({
|
||||
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
|
||||
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后新评级在该分数区间将无法映射等级,已评级客户的等级快照不受影响。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '评级等级已删除',
|
||||
run: () => deleteRatingGrade(row.id),
|
||||
after: () => loadGrades(),
|
||||
after: async () => {
|
||||
await loadGrades()
|
||||
emit('saved')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGrades()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rating-page">
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`客户评级 · ${customerName}`"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
>
|
||||
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
|
||||
<el-alert
|
||||
class="page-alert"
|
||||
title="本页为评分计算工具,评分结果不参与绩效金额计算"
|
||||
class="rating-alert"
|
||||
title="评级为评分工具,评分结果不参与绩效金额计算"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
|
||||
<el-card shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
|
||||
<span>
|
||||
<el-button @click="resetScores">重置</el-button>
|
||||
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="calc-grid">
|
||||
<div class="calc-form">
|
||||
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||
<span class="score-label">{{ item.label }}</span>
|
||||
<el-input-number
|
||||
v-model="scores[item.key]"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="score-unit">分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calc-result">
|
||||
<div>
|
||||
<span class="result-label">总分</span>
|
||||
<b class="total-num">{{ totalScore }}</b>
|
||||
<span class="total-max">/ 100</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="result-label">评级等级</span>
|
||||
<template v-if="grades.length === 0">
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">未配置等级映射</div>
|
||||
</template>
|
||||
<template v-else-if="matchedGrade">
|
||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||
<div class="grade-hint">
|
||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 等级映射设置抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
|
||||
<div class="drawer-hint">
|
||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||
</div>
|
||||
<div class="drawer-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||
</div>
|
||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="客户评级" name="score">
|
||||
<div class="calc-grid">
|
||||
<div class="calc-form">
|
||||
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
|
||||
<span class="score-label">{{ item.label }}</span>
|
||||
<el-input-number
|
||||
v-model="scores[item.key]"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="score-unit">分</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<p v-if="existingInfo" class="existing-hint">{{ existingInfo }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑等级 -->
|
||||
<div class="calc-result">
|
||||
<div>
|
||||
<span class="result-label">总分</span>
|
||||
<b class="total-num">{{ totalScore }}</b>
|
||||
<span class="total-max">/ 100</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="result-label">评级等级</span>
|
||||
<template v-if="grades.length === 0">
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">未配置等级映射,保存后等级为空</div>
|
||||
</template>
|
||||
<template v-else-if="matchedGrade">
|
||||
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
|
||||
<div class="grade-hint">
|
||||
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grade-name">—</div>
|
||||
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-width="70px" class="remark-form" @submit.prevent>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="remark"
|
||||
maxlength="255"
|
||||
placeholder="选填,如评级依据说明"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="score-tip">
|
||||
五项各 0-20 分,总分 0-100;保存后等级按当前映射快照固化,
|
||||
之后调整等级映射不影响该客户已保存的等级。
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="resetScores">重置</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ hasExisting ? '更新评级' : '保存评级' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="等级映射设置" name="grades">
|
||||
<div class="drawer-hint">
|
||||
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
|
||||
</div>
|
||||
<div class="grades-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
|
||||
</div>
|
||||
<el-table v-loading="gradesLoading" :data="grades" size="small">
|
||||
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
|
||||
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
|
||||
<el-table-column prop="max_score" label="上限" width="90" align="center" />
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 新增 / 编辑等级(二层弹窗;append-to-body 避免嵌套弹层层级与裁剪问题) -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
|
||||
width="440px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@closed="editingId = null"
|
||||
>
|
||||
@@ -342,49 +435,36 @@ onMounted(() => {
|
||||
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
.rating-alert {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.calc-grid {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.calc-form {
|
||||
flex: 1 1 380px;
|
||||
min-width: 280px;
|
||||
flex: 1 1 320px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
line-height: 2.7;
|
||||
line-height: 2.6;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
width: 140px;
|
||||
width: 132px;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink);
|
||||
text-align: right;
|
||||
@@ -395,17 +475,24 @@ onMounted(() => {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.existing-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.calc-result {
|
||||
flex: 0 0 250px;
|
||||
min-width: 220px;
|
||||
flex: 0 0 220px;
|
||||
min-width: 200px;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
@@ -416,7 +503,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.total-num {
|
||||
font-size: 40px;
|
||||
font-size: 36px;
|
||||
color: var(--ys-indigo);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-right: 4px;
|
||||
@@ -429,7 +516,7 @@ onMounted(() => {
|
||||
|
||||
.grade-name {
|
||||
font-family: var(--ys-font-brand);
|
||||
font-size: 28px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
line-height: 1.3;
|
||||
@@ -442,6 +529,25 @@ onMounted(() => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.remark-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.score-tip {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
@@ -451,7 +557,7 @@ onMounted(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drawer-toolbar {
|
||||
.grades-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
@@ -471,7 +577,6 @@ onMounted(() => {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||
@media (max-width: 768px) {
|
||||
.score-label {
|
||||
width: auto;
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage, type FormInstance, type FormRules, type TableInstance } from 'element-plus'
|
||||
|
||||
import * as customerApi from '@/api/customers'
|
||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||
import * as personnelApi from '@/api/personnel'
|
||||
import { listRatingGrades } from '@/api/ratings'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { handledError } from '@/utils/error'
|
||||
import CustomerRatingDialog from './CustomerRatingDialog.vue'
|
||||
|
||||
/**
|
||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||
@@ -20,6 +22,9 @@ import { handledError } from '@/utils/error'
|
||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||
* (对照表 #19 的业务口径)
|
||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||
* - 客户评级列(2026-10-19):每客户一行快照落库;评级配置走行内弹窗
|
||||
* (原独立评级页删除);列头支持正序/倒序 —— 列表是服务端分页,
|
||||
* 排序必须带参数回后端(`order_field=rating`),且未评级恒在末尾
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
@@ -31,6 +36,10 @@ const filters = reactive({
|
||||
owner_personnel_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
/** 评级列排序方向(undefined = 默认 id 倒序;后端白名单只接受 rating) */
|
||||
const ratingOrder = ref<'asc' | 'desc' | undefined>(undefined)
|
||||
const tableRef = ref<TableInstance>()
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||||
customerApi.listCustomers,
|
||||
@@ -38,16 +47,58 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
||||
keyword: filters.keyword,
|
||||
customer_type: filters.customer_type,
|
||||
owner_personnel_id: filters.owner_personnel_id,
|
||||
order_field: ratingOrder.value ? 'rating' : undefined,
|
||||
order_type: ratingOrder.value,
|
||||
}),
|
||||
)
|
||||
|
||||
/** 评级列头排序:custom 模式只触发事件,排序交给后端(服务端分页必须如此) */
|
||||
function onRatingSortChange({
|
||||
prop,
|
||||
order,
|
||||
}: {
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}): void {
|
||||
if (prop !== 'rating_total') return
|
||||
ratingOrder.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
|
||||
search()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.customer_type = undefined
|
||||
filters.owner_personnel_id = undefined
|
||||
ratingOrder.value = undefined
|
||||
tableRef.value?.clearSort()
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 评级列展示 ====================
|
||||
|
||||
/** 等级名按判定位次(sort_order 升序)排列,用于 tag 语义色映射 */
|
||||
const gradeNames = ref<string[]>([])
|
||||
|
||||
async function loadGradeNames(): Promise<void> {
|
||||
try {
|
||||
const grades = await listRatingGrades()
|
||||
gradeNames.value = grades.map((grade) => grade.grade_name)
|
||||
} catch {
|
||||
// 配色不是关键路径:失败时所有等级走默认 info 色,列表照常可用
|
||||
gradeNames.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const RATING_TAG_TYPES = ['success', 'primary', 'warning', 'danger'] as const
|
||||
|
||||
/** 第 1 档 success → 第 2 档 primary → 第 3 档 warning → 其余 danger;未知等级 info */
|
||||
function ratingTagType(gradeName: string | null): (typeof RATING_TAG_TYPES)[number] | 'info' {
|
||||
if (!gradeName) return 'info'
|
||||
const index = gradeNames.value.indexOf(gradeName)
|
||||
if (index === -1) return 'info'
|
||||
return RATING_TAG_TYPES[Math.min(index, RATING_TAG_TYPES.length - 1)]
|
||||
}
|
||||
|
||||
// ==================== 归属人员下拉数据源 ====================
|
||||
|
||||
interface PersonnelOption {
|
||||
@@ -302,7 +353,7 @@ function ownerTransition(row: OwnershipLogItem): string {
|
||||
|
||||
async function handleDelete(row: CustomerItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
|
||||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕与评级记录将一并失去展示主体。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '客户已删除',
|
||||
@@ -311,12 +362,29 @@ async function handleDelete(row: CustomerItem): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 客户评级弹窗 ====================
|
||||
|
||||
const ratingVisible = ref(false)
|
||||
const ratingTarget = ref<CustomerItem | null>(null)
|
||||
|
||||
function openRating(row: CustomerItem): void {
|
||||
ratingTarget.value = row
|
||||
ratingVisible.value = true
|
||||
}
|
||||
|
||||
/** 弹窗内保存评级/调整等级映射后:刷新列表行与 tag 配色 */
|
||||
async function handleRatingSaved(): Promise<void> {
|
||||
await load()
|
||||
void loadGradeNames()
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
// 三个请求各自容错(内部已兜底),互不拖累
|
||||
void load()
|
||||
void loadOwnerOptions()
|
||||
void loadGradeNames()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -369,16 +437,54 @@ onMounted(() => {
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
@sort-change="onRatingSortChange"
|
||||
>
|
||||
<!-- 列宽约定(2026-10-20 用户确认):8 列**等宽平分** —— 所有列共用同一
|
||||
min-width,el-table 按 min-width 比例分配剩余宽度,等值即等宽;
|
||||
全列居中后相邻两列的间距处处一致。不设 width/fixed:固定列会打破
|
||||
等分(min-width 与 width 混排是按比例瓜分的根源)。窄屏下列宽收缩,
|
||||
操作按钮自然换行,优先保证间距一致 -->
|
||||
<el-table-column
|
||||
prop="company_name"
|
||||
label="公司名称"
|
||||
min-width="140"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="rating_total"
|
||||
label="客户评级"
|
||||
min-width="140"
|
||||
align="center"
|
||||
sortable="custom"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.rating_total !== null">
|
||||
<el-tag
|
||||
:type="ratingTagType(row.rating_grade_name)"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
{{ row.rating_grade_name ?? '未定级' }}
|
||||
</el-tag>
|
||||
<span class="rating-score">{{ row.rating_total }} 分</span>
|
||||
</template>
|
||||
<span v-else class="rating-none">未评级</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner_name" label="负责人" min-width="140" align="center" />
|
||||
<el-table-column prop="contact_person" label="联系人" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||||
<el-table-column prop="contact_phone" label="电话" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="110" align="center">
|
||||
<el-table-column label="分类" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
@@ -395,18 +501,23 @@ onMounted(() => {
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属人员" min-width="110">
|
||||
<el-table-column label="归属人员" min-width="140" align="center">
|
||||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<el-table-column label="操作" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<!-- 五项按钮单行均分:两端对齐让相邻按钮间距处处相等;
|
||||
「配置客户评级」最长(6 字),在等宽列内无法再做 5 等分不换行,
|
||||
故用等间距平分整列宽度 -->
|
||||
<div class="ops-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openRating(row)">
|
||||
配置客户评级
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)">
|
||||
归属变更
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -530,6 +641,14 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 客户评级(打分 + 等级映射设置双 Tab;原独立评级页已删除) -->
|
||||
<CustomerRatingDialog
|
||||
v-model="ratingVisible"
|
||||
:customer-id="ratingTarget?.id ?? 0"
|
||||
:customer-name="ratingTarget?.company_name ?? ''"
|
||||
@saved="handleRatingSaved"
|
||||
/>
|
||||
|
||||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
||||
@@ -564,4 +683,31 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 操作列:五个按钮单行、等间距平分整列(两端对齐);
|
||||
去掉 Element Plus 相邻按钮的默认 12px 外边距,间距完全由 flex 控制 */
|
||||
.ops-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops-actions :deep(.el-button) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 评级列:等级 tag + 总分灰字;未评级灰字 */
|
||||
.rating-score {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rating-none {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,13 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { canAccessPath, firstAccessiblePath } from '@/router/access'
|
||||
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<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
@@ -24,32 +26,6 @@ const form = reactive({
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
/** 品牌名:默认衍水集团,公司信息设置页改了以后登录页同步生效(public 端点) */
|
||||
const brandName = ref('衍水集团')
|
||||
const brandLogo = ref<string | null>(null)
|
||||
|
||||
async function loadBrand(): Promise<void> {
|
||||
try {
|
||||
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
|
||||
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
|
||||
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
|
||||
const resp = await fetch(
|
||||
`/api/v1/system/settings/public?_=${Date.now()}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
if (!resp.ok) throw new Error(String(resp.status))
|
||||
const body = (await resp.json()) as {
|
||||
code: number
|
||||
data: { company_name?: string; logo_url?: string | null }
|
||||
}
|
||||
const data = body.data
|
||||
if (data.company_name) brandName.value = data.company_name
|
||||
brandLogo.value = data.logo_url ?? null
|
||||
} catch {
|
||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
@@ -108,7 +84,8 @@ async function handleSubmit(): Promise<void> {
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
loadBrand()
|
||||
// 品牌信息失败静默回退内置默认值(不阻断登录;内部去重/缓存)
|
||||
void settingsStore.ensureLoaded()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -122,9 +99,14 @@ onMounted(() => {
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="card-brand">
|
||||
<img v-if="brandLogo" :src="brandLogo" alt="" class="card-brand__logo" />
|
||||
<div class="card-brand__name">{{ brandName }}</div>
|
||||
<p class="card-brand__slogan">把每一笔回款,算成一份踏实。</p>
|
||||
<img
|
||||
v-if="settingsStore.logoUrl"
|
||||
:src="settingsStore.logoUrl"
|
||||
alt=""
|
||||
class="card-brand__logo"
|
||||
/>
|
||||
<div class="card-brand__name">{{ settingsStore.companyName }}</div>
|
||||
<p class="card-brand__slogan">{{ settingsStore.brandSlogan }}</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +171,10 @@ onMounted(() => {
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<p class="login-foot">© {{ brandName }} · 内部系统</p>
|
||||
<p class="login-foot">
|
||||
{{ settingsStore.footerLine
|
||||
}}<template v-if="settingsStore.icpNo"> · {{ settingsStore.icpNo }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,
|
||||
@@ -43,9 +44,10 @@ async function loadCatalog(): Promise<void> {
|
||||
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)
|
||||
@@ -83,20 +85,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<string, string> = {
|
||||
FULL: '当月全额',
|
||||
BALANCE: '余额',
|
||||
INCLUDE: '含基本工资',
|
||||
DEDUCT: '扣除基本工资',
|
||||
EXCLUDE: '不计基本工资',
|
||||
'0': '关',
|
||||
'1': '开',
|
||||
/** 参数编码 → 值类型(目录加载后填充;未知参数不翻译,避免误译数值) */
|
||||
const PARAM_VALUE_TYPES: Record<string, string> = {}
|
||||
|
||||
/**
|
||||
* 按参数编码翻译枚举值(旧值/新值等宽表用)。
|
||||
*
|
||||
* **必须先判参数类型**:枚举映射含 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})`
|
||||
}
|
||||
|
||||
/** 带单位的值展示 */
|
||||
@@ -467,7 +473,14 @@ onMounted(() => {
|
||||
<template v-if="item.values.length">
|
||||
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
||||
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
||||
<b class="num">{{ formatValue(item, v.value_text) }}</b>
|
||||
<b class="num">
|
||||
{{ item.value_type === 'ENUM' ? paramEnumLabel(v.value_text) : formatValue(item, v.value_text) }}
|
||||
</b>
|
||||
<!-- 灰色小码:与库内/审计记录里的原始码对上;未知码(中文缺映射)不重复显示 -->
|
||||
<span
|
||||
v-if="item.value_type === 'ENUM' && paramEnumLabel(v.value_text) !== v.value_text"
|
||||
class="enum-code"
|
||||
>{{ v.value_text }}</span>
|
||||
<span class="eff">{{ v.effective_date }} 起</span>
|
||||
<el-tag
|
||||
v-if="pendingOf(item, v.dimension_value)"
|
||||
@@ -475,7 +488,7 @@ onMounted(() => {
|
||||
type="warning"
|
||||
disable-transitions
|
||||
>
|
||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
||||
待改为 {{ enumValueLabel(item.param_code, pendingOf(item, v.dimension_value)!.new_value) }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
@@ -527,10 +540,12 @@ onMounted(() => {
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="110">
|
||||
<template #default="{ row }">{{ row.old_value || '—' }}</template>
|
||||
<template #default="{ row }">{{ enumValueLabel(row.item.param_code, row.old_value) || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新值" min-width="110">
|
||||
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
|
||||
<template #default="{ row }">
|
||||
<b class="num">{{ enumValueLabel(row.item.param_code, row.new_value) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
@@ -578,7 +593,9 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前值">
|
||||
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
|
||||
<span class="num">
|
||||
{{ enumValueLabel(editTarget.item.param_code, currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—') }}
|
||||
</span>
|
||||
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新值">
|
||||
@@ -587,7 +604,12 @@ onMounted(() => {
|
||||
v-model="editValue"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
|
||||
<el-option
|
||||
v-for="opt in editTarget.item.options"
|
||||
:key="opt"
|
||||
:label="enumOptionLabel(opt)"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
||||
</el-form-item>
|
||||
@@ -632,11 +654,15 @@ onMounted(() => {
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="90">
|
||||
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
|
||||
<template #default="{ row }">
|
||||
{{ enumValueLabel(row.param_code, row.old_value) || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新值" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
|
||||
<span :class="{ changed: row.changed }">
|
||||
{{ enumValueLabel(row.param_code, row.new_value) || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变化" width="70" align="center">
|
||||
@@ -658,7 +684,10 @@ onMounted(() => {
|
||||
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
||||
<span class="param-name">{{ item.param_name }}</span>
|
||||
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
||||
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
||||
<span class="num">
|
||||
{{ enumValueLabel(item.param_code, item.old_value_text) || '—' }} →
|
||||
<b>{{ enumValueLabel(item.param_code, item.value_text) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
||||
@@ -707,7 +736,10 @@ onMounted(() => {
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值 → 新值" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
|
||||
<span class="num">
|
||||
{{ enumValueLabel(historyItem?.param_code ?? '', row.old_value_text) || '—' }} →
|
||||
{{ enumValueLabel(historyItem?.param_code ?? '', row.value_text) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version_no" label="版本" width="115" />
|
||||
@@ -799,7 +831,7 @@ onMounted(() => {
|
||||
<div class="pr-used">
|
||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
||||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || u.value_text }}
|
||||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ enumValueLabel(u.param_code, u.value_text) }}
|
||||
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -875,6 +907,13 @@ onMounted(() => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 枚举值旁的灰色原始码(如「当月全额 FULL」的 FULL),与审计记录对账用 */
|
||||
.enum-code {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.eff {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
|
||||
@@ -1,42 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import type { UploadRawFile } from 'element-plus'
|
||||
|
||||
import * as settingsApi from '@/api/settings'
|
||||
import type { CompanySettings } from '@/api/settings'
|
||||
import { useSettingsStore } from '@/store/settings'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 公司信息设置(2026-10-15 补充需求:公司名称、logo)。
|
||||
* 公司信息与站点信息设置(2026-10-20 定稿)。
|
||||
*
|
||||
* 签名元素是右侧「登录页实景预览」:品牌名与 logo 改动即时反映在一张
|
||||
* 缩比登录卡上 —— 所见即所得,避免「保存后去登录页核对」的往返。
|
||||
* logo 校验(png/jpg ≤2MB)与后端三重校验同口径,前端先拦一道。
|
||||
* 页面按「左侧设置 → 右侧预览」成对排列,四张卡两行:
|
||||
* - 第一行:登录页信息(名称/标语/页脚/备案号/logo)→ 登录页预览
|
||||
* - 第二行:网页站点信息(站点标题/站点图标)→ 浏览器标签页预览
|
||||
*
|
||||
* 进入页面即加载当前设置(设置页即所见即所得,无需手动刷新)。
|
||||
* 两张设置卡**各自独立保存**(后端为部分更新语义):
|
||||
* 改登录页信息不必触碰站点标题,反之亦然;保存后把回包写入 settings store,
|
||||
* 侧边栏/浏览器标题/favicon/登录页立即同步(不重新请求 public 端点 ——
|
||||
* 匿名端点带 300s 浏览器缓存)。
|
||||
*/
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const route = useRoute()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const form = reactive({
|
||||
// ==================== 登录页信息 ====================
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const savingLogin = ref(false)
|
||||
|
||||
const loginForm = reactive({
|
||||
company_name: '',
|
||||
brand_slogan: '',
|
||||
footer_text: '',
|
||||
icp_no: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
const loginRules: FormRules = {
|
||||
company_name: [
|
||||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||||
{ max: 64, message: '公司名称最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
brand_slogan: [{ max: 64, message: '标语最长 64 个字符', trigger: 'blur' }],
|
||||
footer_text: [{ max: 64, message: '页脚文本最长 64 个字符', trigger: 'blur' }],
|
||||
icp_no: [{ max: 32, message: '备案号最长 32 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ==================== 网页站点信息 ====================
|
||||
|
||||
const siteFormRef = ref<FormInstance>()
|
||||
const savingSite = ref(false)
|
||||
|
||||
const siteForm = reactive({
|
||||
site_title: '',
|
||||
})
|
||||
|
||||
const siteRules: FormRules = {
|
||||
site_title: [{ max: 64, message: '站点标题最长 64 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ==================== 图片资源 ====================
|
||||
|
||||
const logoUrl = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const faviconUrl = ref<string | null>(null)
|
||||
const uploadingLogo = ref(false)
|
||||
const uploadingFavicon = ref(false)
|
||||
|
||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 生效的站点图标:自定义图标 → 公司 logo → 内置 SVG。
|
||||
* 与 settings store 同口径 —— 未单独上传图标时,换 logo 标签页图标跟着变。
|
||||
*/
|
||||
const effectiveFavicon = computed(() => faviconUrl.value ?? logoUrl.value ?? '/favicon.svg')
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
try {
|
||||
const data = await settingsApi.getSettings()
|
||||
form.company_name = data.company_name
|
||||
logoUrl.value = data.logo_url
|
||||
fillAll(data)
|
||||
// 管理端打开设置页即用最新值校准全局品牌/站点信息
|
||||
settingsStore.apply(data)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
@@ -44,118 +87,309 @@ async function loadSettings(): Promise<void> {
|
||||
|
||||
loadSettings()
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
function fillAll(data: CompanySettings): void {
|
||||
loginForm.company_name = data.company_name
|
||||
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||||
loginForm.footer_text = data.footer_text ?? ''
|
||||
loginForm.icp_no = data.icp_no ?? ''
|
||||
siteForm.site_title = data.site_title ?? ''
|
||||
logoUrl.value = data.logo_url
|
||||
faviconUrl.value = data.favicon_url
|
||||
}
|
||||
|
||||
/** 保存登录页信息(只提交本卡字段,不触碰站点标题) */
|
||||
async function saveLoginInfo(): Promise<void> {
|
||||
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
savingLogin.value = true
|
||||
try {
|
||||
const data = await settingsApi.updateSettings(form.company_name.trim())
|
||||
form.company_name = data.company_name
|
||||
ElMessage.success('已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function beforeUpload(file: UploadRawFile): boolean {
|
||||
const okType = ['image/png', 'image/jpeg'].includes(file.type)
|
||||
if (!okType) {
|
||||
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
||||
return false
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
ElMessage.warning('logo 文件不能超过 2MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||
uploading.value = true
|
||||
try {
|
||||
const data = await settingsApi.uploadLogo(options.file)
|
||||
const data = await settingsApi.updateSettings({
|
||||
company_name: loginForm.company_name.trim(),
|
||||
brand_slogan: loginForm.brand_slogan.trim(),
|
||||
footer_text: loginForm.footer_text.trim(),
|
||||
icp_no: loginForm.icp_no.trim(),
|
||||
})
|
||||
// 只回填本卡字段:另一张卡未保存的编辑不被覆盖
|
||||
loginForm.company_name = data.company_name
|
||||
loginForm.brand_slogan = data.brand_slogan ?? ''
|
||||
loginForm.footer_text = data.footer_text ?? ''
|
||||
loginForm.icp_no = data.icp_no ?? ''
|
||||
logoUrl.value = data.logo_url
|
||||
ElMessage.success('logo 已更新')
|
||||
faviconUrl.value = data.favicon_url
|
||||
settingsStore.apply(data)
|
||||
ElMessage.success('登录页信息已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
savingLogin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览品牌名:跟随输入(空时回退占位,与登录页默认一致) */
|
||||
const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
/** 保存网页站点信息(只提交站点标题;图标由上传接口即时保存) */
|
||||
async function saveSiteInfo(): Promise<void> {
|
||||
const valid = await siteFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
savingSite.value = true
|
||||
try {
|
||||
const data = await settingsApi.updateSettings({
|
||||
site_title: siteForm.site_title.trim(),
|
||||
})
|
||||
siteForm.site_title = data.site_title ?? ''
|
||||
logoUrl.value = data.logo_url
|
||||
faviconUrl.value = data.favicon_url
|
||||
settingsStore.apply(data)
|
||||
ElMessage.success('站点信息已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
savingSite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图片上传(logo / 站点图标) ====================
|
||||
|
||||
function handleLogoUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||
const file = options.file
|
||||
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
||||
ElMessage.warning('logo 仅支持 png / jpg 格式')
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
ElMessage.warning('logo 文件不能超过 2MB')
|
||||
return Promise.resolve()
|
||||
}
|
||||
uploadingLogo.value = true
|
||||
return settingsApi
|
||||
.uploadLogo(file)
|
||||
.then((data) => {
|
||||
logoUrl.value = data.logo_url
|
||||
faviconUrl.value = data.favicon_url
|
||||
settingsStore.apply(data)
|
||||
ElMessage.success('logo 已更新')
|
||||
})
|
||||
.catch(handledError)
|
||||
.finally(() => {
|
||||
uploadingLogo.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 浏览器对 .ico 的 MIME 常为空,改用扩展名判断(后端仍有 magic bytes 兜底) */
|
||||
function handleFaviconUpload(options: { file: UploadRawFile }): Promise<void> {
|
||||
const file = options.file
|
||||
if (!/\.(png|jpe?g|ico)$/i.test(file.name)) {
|
||||
ElMessage.warning('站点图标仅支持 png / jpg / ico 格式')
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
ElMessage.warning('站点图标文件不能超过 2MB')
|
||||
return Promise.resolve()
|
||||
}
|
||||
uploadingFavicon.value = true
|
||||
return settingsApi
|
||||
.uploadFavicon(file)
|
||||
.then((data) => {
|
||||
faviconUrl.value = data.favicon_url
|
||||
logoUrl.value = data.logo_url
|
||||
settingsStore.apply(data)
|
||||
ElMessage.success('站点图标已更新')
|
||||
})
|
||||
.catch(handledError)
|
||||
.finally(() => {
|
||||
uploadingFavicon.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 预览 ====================
|
||||
|
||||
/** 当前页面名(本页路由标题为「公司信息」),用于标签页标题预览 */
|
||||
const previewPageTitle = computed(() => (route.meta.title as string | undefined) ?? '公司信息')
|
||||
|
||||
const previewName = computed(() => loginForm.company_name.trim() || '衍水集团')
|
||||
const previewSlogan = computed(
|
||||
() => loginForm.brand_slogan.trim() || '把每一笔回款,算成一份踏实。',
|
||||
)
|
||||
const previewFooter = computed(
|
||||
() => loginForm.footer_text.trim() || `© ${previewName.value} · 内部系统`,
|
||||
)
|
||||
const previewIcp = computed(() => loginForm.icp_no.trim())
|
||||
const previewSiteTitle = computed(
|
||||
() => siteForm.site_title.trim() || '营销人员工资管理系统',
|
||||
)
|
||||
const previewTabTitle = computed(
|
||||
() => `${previewPageTitle.value} - ${previewName.value} ${previewSiteTitle.value}`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="settings-grid">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span class="card-title">公司信息</span>
|
||||
</template>
|
||||
<!-- 左一:登录页信息 -->
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
label-width="110px"
|
||||
class="settings-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span class="card-title">登录页信息</span>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="88px"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input
|
||||
v-model="form.company_name"
|
||||
v-model="loginForm.company_name"
|
||||
maxlength="64"
|
||||
placeholder="展示在登录页与导出页眉"
|
||||
placeholder="展示在登录页、侧边栏与浏览器标题"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="品牌标语" prop="brand_slogan">
|
||||
<el-input
|
||||
v-model="loginForm.brand_slogan"
|
||||
maxlength="64"
|
||||
placeholder="留空使用默认:把每一笔回款,算成一份踏实。"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="页脚版权" prop="footer_text">
|
||||
<el-input
|
||||
v-model="loginForm.footer_text"
|
||||
maxlength="64"
|
||||
placeholder="留空自动生成:© 公司名 · 内部系统"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="ICP 备案号" prop="icp_no">
|
||||
<el-input
|
||||
v-model="loginForm.icp_no"
|
||||
maxlength="32"
|
||||
placeholder="选填,展示在登录页页脚"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="logo">
|
||||
<div class="logo-field">
|
||||
<div class="asset-field">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="handleLogoUpload"
|
||||
accept="image/png,image/jpeg"
|
||||
>
|
||||
<div v-loading="uploading" class="logo-box" title="点击上传 png / jpg(≤2MB)">
|
||||
<div v-loading="uploadingLogo" class="asset-box" title="点击上传 png / jpg(≤2MB)">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="公司 logo" />
|
||||
<span v-else class="logo-plus">+</span>
|
||||
<span v-else class="asset-plus">+</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<p class="logo-hint">png / jpg,不超过 2MB;<br />展示于登录页品牌位与导出页眉。</p>
|
||||
<p class="asset-hint">
|
||||
png / jpg,不超过 2MB;<br />展示于登录页品牌位。
|
||||
</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" :loading="savingLogin" @click="saveLoginInfo">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-form>
|
||||
|
||||
<!-- 签名元素:登录页实景预览(缩比登录卡,品牌名/logo 即时反映) -->
|
||||
<el-card shadow="never" class="preview-card">
|
||||
<!-- 右一:登录页预览(由左侧「登录页信息」控制) -->
|
||||
<el-card shadow="never" class="settings-card preview-card">
|
||||
<template #header>
|
||||
<span class="card-title">登录页预览</span>
|
||||
<span class="preview-note">保存后对所有人生效</span>
|
||||
<span class="preview-note">对应左侧「登录页信息」</span>
|
||||
</template>
|
||||
<div class="login-mock">
|
||||
<div class="mock-brand">
|
||||
<img v-if="logoUrl" :src="logoUrl" alt="" class="mock-logo" />
|
||||
<div class="mock-name">{{ previewName }}</div>
|
||||
<p class="mock-slogan">把每一笔回款,算成一份踏实。</p>
|
||||
<p class="mock-slogan">{{ previewSlogan }}</p>
|
||||
<span class="mock-waterline"></span>
|
||||
</div>
|
||||
<div class="mock-field"></div>
|
||||
<div class="mock-field"></div>
|
||||
<div class="mock-submit">登 录</div>
|
||||
</div>
|
||||
<p class="mock-foot">
|
||||
{{ previewFooter }}<template v-if="previewIcp"> · {{ previewIcp }}</template>
|
||||
</p>
|
||||
<p class="preview-caption">登录页品牌位实景(示意,非交互)</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 左二:网页站点信息 -->
|
||||
<el-form
|
||||
ref="siteFormRef"
|
||||
:model="siteForm"
|
||||
:rules="siteRules"
|
||||
label-width="110px"
|
||||
class="settings-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span class="card-title">网页站点信息</span>
|
||||
</template>
|
||||
|
||||
<el-form-item label="站点标题" prop="site_title">
|
||||
<el-input
|
||||
v-model="siteForm.site_title"
|
||||
maxlength="64"
|
||||
placeholder="留空使用默认:营销人员工资管理系统"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="站点图标">
|
||||
<div class="asset-field">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="handleFaviconUpload"
|
||||
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,.ico"
|
||||
>
|
||||
<div
|
||||
v-loading="uploadingFavicon"
|
||||
class="asset-box asset-box--small"
|
||||
title="点击上传 png / jpg / ico(≤2MB)"
|
||||
>
|
||||
<!-- 未自定义时显示公司 logo(跟随 logo 变化) -->
|
||||
<img :src="effectiveFavicon" alt="站点图标" />
|
||||
</div>
|
||||
</el-upload>
|
||||
<p class="asset-hint">
|
||||
png / jpg / ico,不超过 2MB;<br />
|
||||
未上传时跟随公司 logo,展示于浏览器标签页。
|
||||
</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" :loading="savingSite" @click="saveSiteInfo">保存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-form>
|
||||
|
||||
<!-- 右二:浏览器标签页预览(由左侧「网页站点信息」控制) -->
|
||||
<el-card shadow="never" class="settings-card preview-card tab-preview-card">
|
||||
<template #header>
|
||||
<span class="card-title">浏览器标签页预览</span>
|
||||
<span class="preview-note">对应左侧「网页站点信息」</span>
|
||||
</template>
|
||||
<div class="tab-bar">
|
||||
<div class="tab-bar__tab">
|
||||
<img :src="effectiveFavicon" alt="" class="tab-bar__icon" />
|
||||
<span class="tab-bar__title">{{ previewTabTitle }}</span>
|
||||
<span class="tab-bar__close">×</span>
|
||||
</div>
|
||||
<span class="tab-bar__plus">+</span>
|
||||
</div>
|
||||
<p class="preview-caption">浏览器标签页实景(标题与图标)</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -163,9 +397,41 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
<style scoped>
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 460px) minmax(300px, 380px);
|
||||
grid-template-columns: minmax(420px, 560px) minmax(300px, 380px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
/* 同一行左右卡片等高:右侧预览卡拉伸到与左侧设置卡相同的底边 */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-form :deep(.el-card) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 预览卡铺满行高,内容区可纵向伸展 */
|
||||
.preview-card,
|
||||
.tab-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-card :deep(.el-card__body),
|
||||
.tab-preview-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 登录页实景卡随可用高度放大:两个模拟输入框吃掉多余高度,
|
||||
整张登录卡纵向撑满预览卡,底边与左侧「登录页信息」对齐 */
|
||||
.login-mock {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -178,13 +444,19 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-field {
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.asset-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
.asset-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -198,24 +470,29 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.logo-box:hover {
|
||||
.asset-box--small {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.asset-box:hover {
|
||||
border-color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.logo-box img {
|
||||
.asset-box img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-plus {
|
||||
.asset-plus {
|
||||
font-size: 26px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.logo-hint {
|
||||
.asset-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
@@ -268,7 +545,8 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
}
|
||||
|
||||
.mock-field {
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
margin-top: 12px;
|
||||
background: #f4f5f8;
|
||||
border: 1px solid rgba(28, 35, 51, 0.07);
|
||||
@@ -287,13 +565,74 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-caption {
|
||||
margin: 10px 0 0;
|
||||
.mock-foot {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.preview-caption {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* ── 浏览器标签页预览(示意;与登录页预览分成两张卡,对应左侧第二张卡) ── */
|
||||
.tab-preview-card :deep(.el-card__body) {
|
||||
/* 行高由左侧「网页站点信息」决定:标签页示意在卡内垂直居中 */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 0;
|
||||
background: #e9ebf1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tab-bar__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 -1px 2px rgba(28, 35, 51, 0.06);
|
||||
}
|
||||
|
||||
.tab-bar__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tab-bar__title {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.tab-bar__close {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.tab-bar__plus {
|
||||
padding: 0 6px 10px;
|
||||
font-size: 14px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in new issue
Block a user