|
|
@@ -0,0 +1,696 @@
|
|
|
+# CRM 产品/客户拼音首字母检索 Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 为 `crm_product` / `crm_customer` 维护小写拼音首字母字段,并在列表与业务选择器检索中支持大小写不敏感的拼音模糊匹配。
|
|
|
+
|
|
|
+**Architecture:** 后端 `pypinyin` 统一生成工具函数;表内冗余 `name_pinyin_abbr`;create/update 按「空才自动填、手改不覆盖」写入;list/options 的 keyword(及客户 `customerName`)OR `LOWER(name_pinyin_abbr) LIKE`;前端表单可编辑 +「重新生成」;上线一次回填空字段。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2 + `pypinyin`;Vue 3 + Element Plus;pytest。
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-07-crm-name-pinyin-abbr-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `backend/app/utils/pinyin_abbr.py` | 生成 / 归一化拼音首字母 |
|
|
|
+| `backend/tests/test_pinyin_abbr.py` | 生成规则单测 |
|
|
|
+| `docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql` | DDL |
|
|
|
+| `docs/sql/shanxia2.0.sql` | 基线表结构同步加列 |
|
|
|
+| `backend/app/models/crm_models.py` | `CrmProduct` / `CrmCustomer` 加列 |
|
|
|
+| `backend/requirements.txt` | 增加 `pypinyin` |
|
|
|
+| `backend/app/schemas/crm/product.py` | Create/Update 加 `namePinyinAbbr` |
|
|
|
+| `backend/app/schemas/crm/customer.py` | 同上 |
|
|
|
+| `backend/app/crud/crm/product.py` | dict / create / update / list / options |
|
|
|
+| `backend/app/crud/crm/customer.py` | 同上 |
|
|
|
+| `backend/tests/test_crm_product_options_keyword.py` | options 仍不匹配编号,但可匹配拼音 |
|
|
|
+| `backend/tests/test_crm_name_pinyin_search.py` | list keyword 含拼音 OR 的断言 |
|
|
|
+| `backend/scripts/backfill_name_pinyin_abbr.py` | 存量空字段回填 |
|
|
|
+| `web-admin/src/api/crm/product.ts` | `ProductFormData` 类型 |
|
|
|
+| `web-admin/src/components/business/product-form-dialog/index.vue` | 表单字段 + 重新生成 |
|
|
|
+| `web-admin/src/views/crm/product/index.vue` | placeholder / 查看弹窗 |
|
|
|
+| `web-admin/src/components/business/product-selector/index.vue` | placeholder |
|
|
|
+| `web-admin/src/components/business/db/DbCrmProductSelect.vue` | placeholder(若有) |
|
|
|
+| `web-admin/src/views/crm/customer/CustomerFormDialog.vue` | 表单字段 + 重新生成 |
|
|
|
+| `web-admin/src/views/crm/customer/CustomerDetailDialog.vue` | 只读展示 |
|
|
|
+| `web-admin/src/views/crm/customer/index.vue` | placeholder / hint |
|
|
|
+| `web-admin/src/components/business/db/DbCrmCustomerSelect.vue` | placeholder |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 拼音工具函数(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/app/utils/pinyin_abbr.py`
|
|
|
+- Create: `backend/tests/test_pinyin_abbr.py`
|
|
|
+- Modify: `backend/requirements.txt`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写入失败单测**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/tests/test_pinyin_abbr.py
|
|
|
+from app.utils.pinyin_abbr import build_name_pinyin_abbr, normalize_name_pinyin_abbr
|
|
|
+
|
|
|
+
|
|
|
+def test_empty_and_whitespace():
|
|
|
+ assert build_name_pinyin_abbr("") == ""
|
|
|
+ assert build_name_pinyin_abbr(" ") == ""
|
|
|
+ assert normalize_name_pinyin_abbr(None) == ""
|
|
|
+ assert normalize_name_pinyin_abbr(" AbC ") == "abc"
|
|
|
+
|
|
|
+
|
|
|
+def test_chinese_and_alnum():
|
|
|
+ assert build_name_pinyin_abbr("55% 工业硫酸") == "55gylls"
|
|
|
+ assert build_name_pinyin_abbr("苯骈三氮唑BTA") == "bstnzbta"
|
|
|
+ assert build_name_pinyin_abbr("PAC") == "pac"
|
|
|
+
|
|
|
+
|
|
|
+def test_truncate_to_100():
|
|
|
+ long_name = "测" * 120
|
|
|
+ out = build_name_pinyin_abbr(long_name)
|
|
|
+ assert len(out) == 100
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 安装依赖并确认测试失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate
|
|
|
+pip install 'pypinyin>=0.53.0'
|
|
|
+# 先把 pypinyin>=0.53.0 写入 requirements.txt 末尾
|
|
|
+pytest tests/test_pinyin_abbr.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(模块不存在)或 import error。
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现工具函数**
|
|
|
+
|
|
|
+```python
|
|
|
+# backend/app/utils/pinyin_abbr.py
|
|
|
+"""名称拼音首字母缩写:库内存小写,检索时大小写不敏感。"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import re
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+from pypinyin import Style, lazy_pinyin
|
|
|
+
|
|
|
+_MAX_LEN = 100
|
|
|
+_ALNUM = re.compile(r"[a-zA-Z0-9]")
|
|
|
+
|
|
|
+
|
|
|
+def normalize_name_pinyin_abbr(value: Optional[str]) -> str:
|
|
|
+ raw = (value or "").strip().lower()
|
|
|
+ if not raw:
|
|
|
+ return ""
|
|
|
+ return raw[:_MAX_LEN]
|
|
|
+
|
|
|
+
|
|
|
+def build_name_pinyin_abbr(name: Optional[str]) -> str:
|
|
|
+ text = (name or "").strip()
|
|
|
+ if not text:
|
|
|
+ return ""
|
|
|
+ parts: list[str] = []
|
|
|
+ for ch in text:
|
|
|
+ if "\u4e00" <= ch <= "\u9fff":
|
|
|
+ pys = lazy_pinyin(ch, style=Style.FIRST_LETTER)
|
|
|
+ if pys and pys[0]:
|
|
|
+ parts.append(str(pys[0]).lower()[0])
|
|
|
+ elif _ALNUM.match(ch):
|
|
|
+ parts.append(ch.lower())
|
|
|
+ # else: drop punctuation / spaces / % / brackets
|
|
|
+ return "".join(parts)[:_MAX_LEN]
|
|
|
+
|
|
|
+
|
|
|
+def resolve_name_pinyin_abbr(
|
|
|
+ *,
|
|
|
+ name: Optional[str],
|
|
|
+ submitted: Optional[str] = None,
|
|
|
+ existing: Optional[str] = None,
|
|
|
+ submitted_provided: bool = False,
|
|
|
+) -> Optional[str]:
|
|
|
+ """保存时解析拼音字段。
|
|
|
+
|
|
|
+ - submitted_provided=True:用提交值(空则按 name 生成)
|
|
|
+ - 否则:已有非空 existing 则保留;existing 空则按 name 生成
|
|
|
+ """
|
|
|
+ if submitted_provided:
|
|
|
+ normalized = normalize_name_pinyin_abbr(submitted)
|
|
|
+ if normalized:
|
|
|
+ return normalized
|
|
|
+ generated = build_name_pinyin_abbr(name)
|
|
|
+ return generated or None
|
|
|
+ existing_norm = normalize_name_pinyin_abbr(existing)
|
|
|
+ if existing_norm:
|
|
|
+ return existing_norm
|
|
|
+ generated = build_name_pinyin_abbr(name)
|
|
|
+ return generated or None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑通单测**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && pytest tests/test_pinyin_abbr.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS。若 `苯骈` 默认读音导致与 `bstnzbta` 不完全一致,以 `pypinyin` 实际首字母为准,**修正断言为实际结果**,并在测试注释中写明。
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/requirements.txt backend/app/utils/pinyin_abbr.py backend/tests/test_pinyin_abbr.py
|
|
|
+git commit -m "feat: add pinyin initial abbr helper for CRM names."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: DDL + ORM 模型
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql`
|
|
|
+- Modify: `docs/sql/shanxia2.0.sql`(`crm_product` / `crm_customer` 的 CREATE TABLE 定义中,在 `product_name` / `customer_name` 后插入列)
|
|
|
+- Modify: `backend/app/models/crm_models.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写迁移 SQL**
|
|
|
+
|
|
|
+```sql
|
|
|
+-- docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql
|
|
|
+-- 产品/客户名称拼音首字母(小写);幂等可重复执行(MySQL 5.7 无 IF NOT EXISTS 加列时用存储过程或先检查)
|
|
|
+
|
|
|
+ALTER TABLE `crm_product`
|
|
|
+ ADD COLUMN `name_pinyin_abbr` varchar(100) DEFAULT NULL COMMENT '产品名称拼音首字母(小写)' AFTER `product_name`;
|
|
|
+
|
|
|
+ALTER TABLE `crm_customer`
|
|
|
+ ADD COLUMN `name_pinyin_abbr` varchar(100) DEFAULT NULL COMMENT '客户名称拼音首字母(小写)' AFTER `customer_name`;
|
|
|
+```
|
|
|
+
|
|
|
+若目标库可能已加列,实现时用「先查 `information_schema` 再 ALTER」的安全脚本包装,或文档注明仅执行一次。推荐在同文件加注释说明手动跳过 duplicate column。
|
|
|
+
|
|
|
+- [ ] **Step 2: 同步基线 `docs/sql/shanxia2.0.sql`**
|
|
|
+
|
|
|
+在 `crm_product` 的 `` `product_name` varchar(200) NOT NULL COMMENT '产品名称', `` 后增加:
|
|
|
+
|
|
|
+```sql
|
|
|
+ `name_pinyin_abbr` varchar(100) DEFAULT NULL COMMENT '产品名称拼音首字母(小写)',
|
|
|
+```
|
|
|
+
|
|
|
+在 `crm_customer` 的 `` `customer_name` varchar(200) NOT NULL COMMENT '客户名称', `` 后增加同样列。
|
|
|
+
|
|
|
+- [ ] **Step 3: ORM**
|
|
|
+
|
|
|
+在 `CrmProduct` 中 `product_name` 后:
|
|
|
+
|
|
|
+```python
|
|
|
+name_pinyin_abbr = Column(String(100), nullable=True, comment="产品名称拼音首字母(小写)")
|
|
|
+```
|
|
|
+
|
|
|
+在 `CrmCustomer` 中 `customer_name` 后:
|
|
|
+
|
|
|
+```python
|
|
|
+name_pinyin_abbr = Column(String(100), nullable=True, comment="客户名称拼音首字母(小写)")
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql docs/sql/shanxia2.0.sql backend/app/models/crm_models.py
|
|
|
+git commit -m "feat: add name_pinyin_abbr columns for CRM product and customer."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 产品 CRUD + 检索
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/schemas/crm/product.py`
|
|
|
+- Modify: `backend/app/crud/crm/product.py`
|
|
|
+- Modify: `backend/tests/test_crm_product_options_keyword.py`
|
|
|
+- Create: `backend/tests/test_crm_name_pinyin_search.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: Schema**
|
|
|
+
|
|
|
+在 `ProductCreateBody` / `ProductUpdateBody` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+name_pinyin_abbr: Optional[str] = Field(
|
|
|
+ default=None, alias="namePinyinAbbr", description="产品名称拼音首字母(小写)"
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: `_product_row_to_dict` 增加返回**
|
|
|
+
|
|
|
+在 `"productName": row.product_name,` 后:
|
|
|
+
|
|
|
+```python
|
|
|
+"namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: create_product / update_product**
|
|
|
+
|
|
|
+`create_product` 构造 `CrmProduct(...)` 时:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.utils.pinyin_abbr import resolve_name_pinyin_abbr
|
|
|
+
|
|
|
+# kwargs 可能来自 camelCase 已转 snake;路由层若传 name_pinyin_abbr / namePinyinAbbr,
|
|
|
+# 在 create 入口统一:
|
|
|
+submitted = kwargs.get("name_pinyin_abbr")
|
|
|
+if submitted is None and "namePinyinAbbr" in kwargs:
|
|
|
+ submitted = kwargs.get("namePinyinAbbr")
|
|
|
+ submitted_provided = "name_pinyin_abbr" in kwargs or "namePinyinAbbr" in kwargs
|
|
|
+else:
|
|
|
+ submitted_provided = "name_pinyin_abbr" in kwargs
|
|
|
+
|
|
|
+name_pinyin_abbr = resolve_name_pinyin_abbr(
|
|
|
+ name=kwargs.get("product_name"),
|
|
|
+ submitted=submitted,
|
|
|
+ submitted_provided=True, # 新增:空则生成
|
|
|
+)
|
|
|
+# 传入 CrmProduct(..., name_pinyin_abbr=name_pinyin_abbr)
|
|
|
+```
|
|
|
+
|
|
|
+`update_product` 的可更新字段列表加入 `"name_pinyin_abbr"`,并在字段循环**之前或之后**统一:
|
|
|
+
|
|
|
+```python
|
|
|
+name_keys = ("name_pinyin_abbr", "namePinyinAbbr")
|
|
|
+submitted_provided = any(k in kwargs for k in name_keys)
|
|
|
+submitted = kwargs.get("name_pinyin_abbr")
|
|
|
+if submitted is None:
|
|
|
+ submitted = kwargs.get("namePinyinAbbr")
|
|
|
+new_name = kwargs.get("product_name") if "product_name" in kwargs and kwargs.get("product_name") is not None else obj.product_name
|
|
|
+obj.name_pinyin_abbr = resolve_name_pinyin_abbr(
|
|
|
+ name=new_name,
|
|
|
+ submitted=submitted,
|
|
|
+ existing=obj.name_pinyin_abbr,
|
|
|
+ submitted_provided=submitted_provided,
|
|
|
+)
|
|
|
+# 若走通用 setattr 循环,避免对 name_pinyin_abbr 再覆盖一次:从循环字段列表去掉,或先 pop kwargs
|
|
|
+```
|
|
|
+
|
|
|
+推荐:从 `_updatable`/field 循环中单独处理 `name_pinyin_abbr`,不要双重赋值。
|
|
|
+
|
|
|
+- [ ] **Step 4: list_products / list_product_options keyword**
|
|
|
+
|
|
|
+```python
|
|
|
+from sqlalchemy import func, or_
|
|
|
+
|
|
|
+kw = (keyword or "").strip()
|
|
|
+if kw:
|
|
|
+ like = f"%{kw}%"
|
|
|
+ kw_lower = kw.lower()
|
|
|
+ pinyin_like = f"%{kw_lower}%"
|
|
|
+ q = q.filter(
|
|
|
+ or_(
|
|
|
+ CrmProduct.product_name.like(like),
|
|
|
+ CrmProduct.product_no.like(like), # list 保留编号;options 不要编号
|
|
|
+ func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(pinyin_like),
|
|
|
+ )
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+`list_product_options`:**不要**加 `product_no`,仅:
|
|
|
+
|
|
|
+```python
|
|
|
+q = q.filter(
|
|
|
+ or_(
|
|
|
+ CrmProduct.product_name.like(f"%{kw}%"),
|
|
|
+ func.lower(func.coalesce(CrmProduct.name_pinyin_abbr, "")).like(f"%{kw.lower()}%"),
|
|
|
+ )
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 更新 / 新增测试**
|
|
|
+
|
|
|
+改写 `test_crm_product_options_keyword.py`:断言 keyword 表达式含 `product_name` 与 `name_pinyin_abbr`,**不含** `product_no`。
|
|
|
+
|
|
|
+新增 `test_crm_name_pinyin_search.py`:对 `list_products` 用 MagicMock 断言 filter 字符串同时含 `product_name` 与 `name_pinyin_abbr`。
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && pytest tests/test_pinyin_abbr.py tests/test_crm_product_options_keyword.py tests/test_crm_name_pinyin_search.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/schemas/crm/product.py backend/app/crud/crm/product.py \
|
|
|
+ backend/tests/test_crm_product_options_keyword.py backend/tests/test_crm_name_pinyin_search.py
|
|
|
+git commit -m "feat: store and search product name pinyin initials."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 客户 CRUD + 检索
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/schemas/crm/customer.py`
|
|
|
+- Modify: `backend/app/crud/crm/customer.py`
|
|
|
+- Modify: `backend/tests/test_crm_name_pinyin_search.py`(追加客户用例)
|
|
|
+
|
|
|
+- [ ] **Step 1: Schema**
|
|
|
+
|
|
|
+`CustomerCreateBody` / `CustomerUpdateBody` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+name_pinyin_abbr: Optional[str] = Field(
|
|
|
+ default=None, alias="namePinyinAbbr", description="客户名称拼音首字母(小写)"
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: `_row_to_dict`**
|
|
|
+
|
|
|
+在 `"customerName": row.customer_name or "",` 后:
|
|
|
+
|
|
|
+```python
|
|
|
+"namePinyinAbbr": getattr(row, "name_pinyin_abbr", None) or "",
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: create_customer / update_customer**
|
|
|
+
|
|
|
+与产品相同,用 `resolve_name_pinyin_abbr(name=customer_name, ...)`;`update` 的 `_updatable` 不要简单盲加后双重处理——单独解析后赋值。
|
|
|
+
|
|
|
+- [ ] **Step 4: list_customers / list_customer_options**
|
|
|
+
|
|
|
+`keyword` 的 `or_` 增加:
|
|
|
+
|
|
|
+```python
|
|
|
+func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like(f"%{kw.lower()}%"),
|
|
|
+```
|
|
|
+
|
|
|
+`customer_name` 独立筛选改为:
|
|
|
+
|
|
|
+```python
|
|
|
+if customer_name:
|
|
|
+ cn = customer_name.strip()
|
|
|
+ if cn:
|
|
|
+ q = q.filter(
|
|
|
+ or_(
|
|
|
+ CrmCustomer.customer_name.like(f"%{cn}%"),
|
|
|
+ func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like(
|
|
|
+ f"%{cn.lower()}%"
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+`list_customer_options` 的 `keyword` 与 `customer_name` 同样 OR 拼音。
|
|
|
+
|
|
|
+- [ ] **Step 5: 测试**
|
|
|
+
|
|
|
+在 `test_crm_name_pinyin_search.py` 增加 `list_customers` / `list_customer_options` 的 MagicMock 断言。
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && pytest tests/test_crm_name_pinyin_search.py -v
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/schemas/crm/customer.py backend/app/crud/crm/customer.py backend/tests/test_crm_name_pinyin_search.py
|
|
|
+git commit -m "feat: store and search customer name pinyin initials."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 存量回填脚本
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/scripts/backfill_name_pinyin_abbr.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 实现脚本**
|
|
|
+
|
|
|
+```python
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""回填 crm_product / crm_customer 的 name_pinyin_abbr(仅空字段)。
|
|
|
+
|
|
|
+用法:
|
|
|
+ cd backend
|
|
|
+ venv/bin/python scripts/backfill_name_pinyin_abbr.py --dry-run
|
|
|
+ venv/bin/python scripts/backfill_name_pinyin_abbr.py
|
|
|
+ venv/bin/python scripts/backfill_name_pinyin_abbr.py --env-file .env.production
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import os
|
|
|
+import sys
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
|
+sys.path.insert(0, str(BACKEND_ROOT))
|
|
|
+
|
|
|
+
|
|
|
+def load_settings_env(env_file: str | None) -> None:
|
|
|
+ if env_file:
|
|
|
+ os.environ["ENV_FILE"] = env_file
|
|
|
+ from dotenv import load_dotenv
|
|
|
+
|
|
|
+ for path in [
|
|
|
+ Path(env_file) if env_file else None,
|
|
|
+ BACKEND_ROOT / ".env.local",
|
|
|
+ BACKEND_ROOT / ".env",
|
|
|
+ ]:
|
|
|
+ if path and Path(path).is_file():
|
|
|
+ load_dotenv(path, override=True)
|
|
|
+ break
|
|
|
+
|
|
|
+
|
|
|
+def main() -> int:
|
|
|
+ parser = argparse.ArgumentParser()
|
|
|
+ parser.add_argument("--dry-run", action="store_true")
|
|
|
+ parser.add_argument("--env-file", default=None)
|
|
|
+ parser.add_argument("--batch-size", type=int, default=200)
|
|
|
+ args = parser.parse_args()
|
|
|
+ load_settings_env(args.env_file)
|
|
|
+
|
|
|
+ from sqlalchemy.orm import Session
|
|
|
+ from app.core.database import SessionLocal # 若项目导出名不同,改为实际 Session 工厂
|
|
|
+ from app.models.crm_models import CrmProduct, CrmCustomer
|
|
|
+ from app.utils.pinyin_abbr import build_name_pinyin_abbr
|
|
|
+
|
|
|
+ db: Session = SessionLocal()
|
|
|
+ try:
|
|
|
+ for model, name_attr in (
|
|
|
+ (CrmProduct, "product_name"),
|
|
|
+ (CrmCustomer, "customer_name"),
|
|
|
+ ):
|
|
|
+ q = (
|
|
|
+ db.query(model)
|
|
|
+ .filter(model.del_flag == 0)
|
|
|
+ .filter(
|
|
|
+ (model.name_pinyin_abbr.is_(None)) | (model.name_pinyin_abbr == "")
|
|
|
+ )
|
|
|
+ )
|
|
|
+ total = q.count()
|
|
|
+ print(f"{model.__tablename__}: empty={total}")
|
|
|
+ offset = 0
|
|
|
+ updated = 0
|
|
|
+ while True:
|
|
|
+ rows = q.limit(args.batch_size).all()
|
|
|
+ if not rows:
|
|
|
+ break
|
|
|
+ for row in rows:
|
|
|
+ name = getattr(row, name_attr) or ""
|
|
|
+ abbr = build_name_pinyin_abbr(name)
|
|
|
+ if not abbr:
|
|
|
+ continue
|
|
|
+ if args.dry_run:
|
|
|
+ print(f" would set {row.id}: {abbr}")
|
|
|
+ else:
|
|
|
+ row.name_pinyin_abbr = abbr
|
|
|
+ updated += 1
|
|
|
+ if args.dry_run:
|
|
|
+ break
|
|
|
+ db.commit()
|
|
|
+ # 已填的行不再命中 filter;无需 offset 递增
|
|
|
+ print(f"{model.__tablename__}: updated={updated}")
|
|
|
+ finally:
|
|
|
+ db.close()
|
|
|
+ return 0
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ raise SystemExit(main())
|
|
|
+```
|
|
|
+
|
|
|
+实现时核对项目真实的 `SessionLocal` 导入路径(常见 `app.core.database`),与现有 `scripts/backfill_*.py` 对齐。
|
|
|
+
|
|
|
+- [ ] **Step 2: dry-run 本地验证**(需已执行 DDL)
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && venv/bin/python scripts/backfill_name_pinyin_abbr.py --dry-run
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/scripts/backfill_name_pinyin_abbr.py
|
|
|
+git commit -m "feat: add backfill script for CRM name pinyin initials."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 前端产品表单 / 列表 / 选择器
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/crm/product.ts`
|
|
|
+- Modify: `web-admin/src/components/business/product-form-dialog/index.vue`
|
|
|
+- Modify: `web-admin/src/views/crm/product/index.vue`
|
|
|
+- Modify: `web-admin/src/components/business/product-selector/index.vue`
|
|
|
+- Modify: `web-admin/src/components/business/db/DbCrmProductSelect.vue`(placeholder)
|
|
|
+
|
|
|
+- [ ] **Step 1: 类型**
|
|
|
+
|
|
|
+`ProductFormData` / `ProductDetail` 增加可选:
|
|
|
+
|
|
|
+```ts
|
|
|
+namePinyinAbbr?: string
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: ProductFormDialog**
|
|
|
+
|
|
|
+在「产品名称」表单项后增加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-form-item label="拼音首字母">
|
|
|
+ <div class="pinyin-abbr-row">
|
|
|
+ <el-input
|
|
|
+ v-model="formData.namePinyinAbbr"
|
|
|
+ placeholder="默认按名称自动生成,可手改"
|
|
|
+ maxlength="100"
|
|
|
+ clearable
|
|
|
+ />
|
|
|
+ <el-button class="btn-ghost" @click="regeneratePinyinAbbr">
|
|
|
+ <el-icon><Refresh /></el-icon>
|
|
|
+ 重新生成
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+</el-form-item>
|
|
|
+```
|
|
|
+
|
|
|
+- `formData` / `resetFormData` / 详情回填增加 `namePinyinAbbr`
|
|
|
+- `regeneratePinyinAbbr`:前端简易实现——调用与后端一致较难(无 pypinyin),**两种做法任选其一(推荐 A)**:
|
|
|
+ - **A(推荐):** 按钮仅清空字段并提示「保存时将按名称重新生成」;或清空后由用户保存触发后端空值生成。更稳妥:**清空 `namePinyinAbbr`,保存时后端 `submitted_provided=True` 且空 → 生成**。若希望按钮立刻看到结果,可加轻量前端库(YAGNI,不推荐本期引入)。
|
|
|
+ - **B:** 前端用极简规则(仅保留字母数字、汉字用 `pinyin-pro`)——需新依赖,本期不做。
|
|
|
+
|
|
|
+**按 Spec「重新生成 = 按当前名称覆盖表单值」**:若前端无法本地生成,实现为:
|
|
|
+
|
|
|
+1. 清空拼音字段;
|
|
|
+2. `ElMessage.info('已清空,保存时将按产品名称重新生成拼音首字母')`。
|
|
|
+
|
|
|
+保存 payload 必须带上 `namePinyinAbbr: ''`(或显式空串),确保后端 `submitted_provided=True`。
|
|
|
+
|
|
|
+样式:一行 flex,`gap: 8px`;输入框 `flex: 1`。图标 `Refresh` 从 `@element-plus/icons-vue` 导入。
|
|
|
+
|
|
|
+- [ ] **Step 3: 产品列表 / 选择器文案**
|
|
|
+
|
|
|
+- `product/index.vue`:筛选 placeholder、`filter-hint`、快速搜索 placeholder → 含「拼音首字母」
|
|
|
+- 查看弹窗 `fields` 在产品名称旁增加 `{ label: '拼音首字母', value: detailData.namePinyinAbbr }`
|
|
|
+- `product-selector`:快速搜索 placeholder → `产品名称/拼音首字母`
|
|
|
+- `DbCrmProductSelect`:默认 placeholder 若为产品名搜索,追加拼音提示
|
|
|
+
|
|
|
+- [ ] **Step 4: 手动回归** `/#/crm/product` 新增、编辑、手改拼音、清空保存、列表搜拼音。
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/crm/product.ts \
|
|
|
+ web-admin/src/components/business/product-form-dialog/index.vue \
|
|
|
+ web-admin/src/views/crm/product/index.vue \
|
|
|
+ web-admin/src/components/business/product-selector/index.vue \
|
|
|
+ web-admin/src/components/business/db/DbCrmProductSelect.vue
|
|
|
+git commit -m "feat: product form and search UI for pinyin initials."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 前端客户表单 / 列表 / 选择器
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/crm/customer/CustomerFormDialog.vue`
|
|
|
+- Modify: `web-admin/src/views/crm/customer/CustomerDetailDialog.vue`
|
|
|
+- Modify: `web-admin/src/views/crm/customer/index.vue`
|
|
|
+- Modify: `web-admin/src/components/business/db/DbCrmCustomerSelect.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: CustomerFormDialog**
|
|
|
+
|
|
|
+在「客户名称」后增加与产品相同的「拼音首字母」+「重新生成」(清空 + 保存时后端重生)。`form` / reset / 详情赋值带上 `namePinyinAbbr`;提交 body 带该字段。
|
|
|
+
|
|
|
+- [ ] **Step 2: CustomerDetailDialog**
|
|
|
+
|
|
|
+`basicFields` 在客户简称前或后增加:
|
|
|
+
|
|
|
+```ts
|
|
|
+{ label: '拼音首字母', value: c.namePinyinAbbr }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 客户列表**
|
|
|
+
|
|
|
+客户名称输入 placeholder → `客户名称/拼音首字母`;若有快速搜索,同步文案。
|
|
|
+
|
|
|
+- [ ] **Step 4: DbCrmCustomerSelect**
|
|
|
+
|
|
|
+placeholder 默认值追加拼音提示(若 props 有默认「请选择客户」之类,改为「客户名称/拼音」或保留并在文档注明远程 keyword 已支持)。
|
|
|
+
|
|
|
+- [ ] **Step 5: 手动回归** 客户新增/编辑/拼音检索;合同或地磅页客户下拉里输拼音。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit + 图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/crm/customer/CustomerFormDialog.vue \
|
|
|
+ web-admin/src/views/crm/customer/CustomerDetailDialog.vue \
|
|
|
+ web-admin/src/views/crm/customer/index.vue \
|
|
|
+ web-admin/src/components/business/db/DbCrmCustomerSelect.vue
|
|
|
+git commit -m "feat: customer form and search UI for pinyin initials."
|
|
|
+./scripts/update-graph.sh all
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 上线核对清单(执行环境)
|
|
|
+
|
|
|
+- [x] **Step 1:** 对目标库执行 `docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql`(本地已执行;生产部署时再跑一遍,脚本幂等)
|
|
|
+- [ ] **Step 2:** 部署后端(含 `pypinyin`)与前端到目标环境
|
|
|
+- [x] **Step 3:** `venv/bin/python scripts/backfill_name_pinyin_abbr.py`(本地已回填 product 374 / customer 262;生产部署后需再跑)
|
|
|
+- [ ] **Step 4:** 抽检:产品/客户列表与选择器用中文、小写、大小写混合拼音检索
|
|
|
+- [ ] **Step 5:** (可选)`python scripts/check_field_consistency.py` 若覆盖 product/customer
|
|
|
+
|
|
|
+#### 生产上线命令摘要
|
|
|
+
|
|
|
+```bash
|
|
|
+# 1) DDL(幂等)
|
|
|
+mysql ... < docs/sql/migrations/2026-08-07-crm-name-pinyin-abbr.sql
|
|
|
+
|
|
|
+# 2) 部署含 pypinyin 的后端 + 前端
|
|
|
+
|
|
|
+# 3) 回填空字段
|
|
|
+cd backend
|
|
|
+venv/bin/python scripts/backfill_name_pinyin_abbr.py --dry-run --env-file .env.production
|
|
|
+venv/bin/python scripts/backfill_name_pinyin_abbr.py --env-file .env.production
|
|
|
+
|
|
|
+# 4) 抽检示例(以 pypinyin 实际结果为准)
|
|
|
+# 产品「55% 工业硫酸」→ 搜 gyls / GYLS
|
|
|
+# 产品「苯骈三氮唑BTA」→ 搜 bpsdzbta
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec 覆盖自检
|
|
|
+
|
|
|
+| Spec 需求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| `name_pinyin_abbr` 两表字段、小写 | Task 2 |
|
|
|
+| 生成规则(汉字/字母数字/丢符号) | Task 1 |
|
|
|
+| 空才自动填、手改不覆盖、重新生成 | Task 3–4、6–7 |
|
|
|
+| list + options / 选择器检索 OR 拼音、大小写不敏感 | Task 3–4 |
|
|
|
+| 客户 `customerName` 筛选 OR 拼音 | Task 4 |
|
|
|
+| 表单可编辑 + 重新生成、查看只读、placeholder | Task 6–7 |
|
|
|
+| 一次回填空字段 | Task 5、8 |
|
|
|
+| 不含 short_name 拼音 / 公海 / 小程序 | 未排期(符合非目标) |
|
|
|
+
|
|
|
+## Placeholder 扫描
|
|
|
+
|
|
|
+无 TBD;「重新生成」前端采用清空 + 后端生成(因无前端拼音库),与 Spec「覆盖表单值」在保存后效果一致——已在 Task 6 写明。若产品坚持按钮瞬间填回缩写,需另开任务引入前端拼音库(本期不做)。
|