diff --git a/backend/app/services/pl_quota_planning.py b/backend/app/services/pl_quota_planning.py index b7c88af7a..11facf37e 100644 --- a/backend/app/services/pl_quota_planning.py +++ b/backend/app/services/pl_quota_planning.py @@ -335,12 +335,20 @@ def get_plan_quota(db, user, plan_id): options.append(item) usages = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan_id, del_flag=0).order_by(PlPlanCertUsage.create_time.desc()).all() version = resolve_measurement_version(db, user, plan) + from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category + customer_rule = plan_uses_customer_category(plan) result = {'planId': plan.id, 'planNo': plan.plan_no, 'licensePlate': plan.license_plate, 'quotaCertWaived': int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1, + 'certQuotaRuleVersion': getattr(plan, 'cert_quota_rule_version', None) or 'legacy', 'measurementVersion': version, 'configuration': {**(plan.cert_configuration or {}), 'measurementVersion': version}, 'sourceOptions': options, 'stopProducts': stop_products, - 'canSaveConfiguration': plan.transport_status != 'arranged' and plan.archive_status != 'archived', + # 新规则禁止手工保存用证配置;安排仅自动选证 + 'canSaveConfiguration': ( + not customer_rule + and plan.transport_status != 'arranged' + and plan.archive_status != 'archived' + ), 'usages': []} for usage in usages: _get_row(db, user, usage.cert_type, usage.cert_id) diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py index c5b2b5129..4b74676f8 100644 --- a/backend/tests/test_pl_customer_cert_quota.py +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -194,3 +194,112 @@ def test_whole_cover_skips_insufficient_earlier_purchase(): assert len(manuals) == 1 assert manuals[0].permit_id == 'buy-late' assert manuals[0].permit_quantity == Decimal('50') + + +def test_save_configuration_blocked_for_customer_category(monkeypatch): + from app.services import pl_quota_planning as planning + from app.schemas.pl.quota_planning import PlanQuotaConfiguration + + plan = SimpleNamespace( + id='p1', + cert_quota_rule_version='customer_category', + transport_status='pending_arrange', + archive_status='none', + cert_configuration={}, + ) + monkeypatch.setattr(planning, '_plan', lambda *a, **k: plan) + with pytest.raises(HTTPException) as exc: + planning.save_configuration( + MagicMock(), + SimpleNamespace(id='u', tenant_no='t', dept_id=''), + 'p1', + PlanQuotaConfiguration(), + ) + assert exc.value.status_code == 409 + assert '自动选证' in str(exc.value.detail) + + +def test_legacy_waive_still_skips_settle(monkeypatch): + from app.services import pl_quota_business as biz + + plan = SimpleNamespace( + id='p1', + quota_cert_waived=1, + quota_cert_pending_fulfill=0, + cert_quota_rule_version='legacy', + cert_configuration={}, + ) + monkeypatch.setattr( + 'app.services.pl_cert_quota_rule_config_service.plan_uses_customer_category', + lambda p: False, + ) + monkeypatch.setattr('app.services.pl_quota_waiver.assert_waived_sources', lambda *a, **k: None) + result = biz.apply_plan_quota(MagicMock(), SimpleNamespace(id='u'), plan, 'settle', None) + assert result.get('waived') is True + + +def test_same_city_source_skips_transport_requirement(): + """同市来源 requires_transport=False,整笔选证可不带运输证。""" + from app.services import pl_quota_customer_select as sel + from app.schemas.pl.quota_usage import UsageSource + from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog + + source = UsageSource( + source_stop_type='delivery', + source_stop_id='s1', + source_stop_product_id='sp1', + plan_product_id='pp1', + contract_id='c1', + contract_product_id='cp1', + product_id='prod1', + quantity=Decimal('20'), + unit='吨', + fingerprint='y' * 64, + snapshot={'customerId': 'cust1', 'sameCity': 1}, + requires_transport=False, + ) + purchase = SimpleNamespace( + id='buy1', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 1, 1), + total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, + ) + + class PurchaseQuery: + def filter(self, *a, **k): + return self + + def order_by(self, *a, **k): + return self + + def all(self): + return [purchase] + + class LogQuery: + def filter_by(self, **k): + return self + + def first(self): + return SimpleNamespace(id='opening') + + db = MagicMock() + + def q_side(model): + if model is PlPurchaseCert: + return PurchaseQuery() + if model is PlCertQtyLog: + return LogQuery() + return MagicMock() + + db.query.side_effect = q_side + orig = sel.category_ids_for_product + sel.category_ids_for_product = lambda db, tenant, pid: {'cat1'} + try: + purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs( + db, SimpleNamespace(tenant_no='t'), [source], vehicle_no='皖A1', on_date=date(2026, 11, 14), + ) + finally: + sel.category_ids_for_product = orig + assert purchase_ids == ['buy1'] + assert transport_ids == [] + assert manuals[0].permit_id == 'buy1' diff --git a/docs/sql/2026-11-14-pl-cert-product-name-candidates.sql b/docs/sql/2026-11-14-pl-cert-product-name-candidates.sql new file mode 100644 index 000000000..59f0f1b1c --- /dev/null +++ b/docs/sql/2026-11-14-pl-cert-product-name-candidates.sql @@ -0,0 +1,24 @@ +-- 只读:按证面品名归纳候选证用品类名(上线前人工建品类参考) +-- 不写库。执行后人工创建 pl_cert_category、关联产品,并回填证件 customer_id/category_id,再配置 effective_at。 +SET NAMES utf8mb4; + +SELECT + TRIM(product_name_on_cert) AS candidate_category_name, + COUNT(*) AS cert_count, + SUM(CASE WHEN cert_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count, + SUM(CASE WHEN cert_type = 'transport' THEN 1 ELSE 0 END) AS transport_count +FROM ( + SELECT 'purchase' AS cert_type, product_name_on_cert + FROM pl_purchase_cert + WHERE del_flag = 0 + AND product_name_on_cert IS NOT NULL + AND TRIM(product_name_on_cert) <> '' + UNION ALL + SELECT 'transport' AS cert_type, product_name_on_cert + FROM pl_transport_cert + WHERE del_flag = 0 + AND product_name_on_cert IS NOT NULL + AND TRIM(product_name_on_cert) <> '' +) t +GROUP BY TRIM(product_name_on_cert) +ORDER BY cert_count DESC, candidate_category_name; diff --git a/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md b/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md index c694d82c5..1c2bce544 100644 --- a/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md +++ b/docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md @@ -1,6 +1,6 @@ # 客户+品类证件额度 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use `.agents/skills/subagent-driven-development/SKILL.md`(推荐)或 `.agents/skills/executing-plans/SKILL.md` 按任务推进。步骤用 checkbox(`- [ ]`)跟踪。 +> **For agentic workers:** REQUIRED SUB-SKILL: Use `.agents/skills/subagent-driven-development/SKILL.md`(推荐)或 `.agents/skills/executing-plans/SKILL.md` 按任务推进。步骤用 checkbox(`- [x]`)跟踪。 > **设计权威:** `docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md` > **硬性闸门:** 每期全部 checkbox 完成并验证后,必须停下来等用户回复「开始第N期」再继续;未经确认不得进入下一期或 commit(除非用户当轮明确要求提交)。 @@ -33,7 +33,7 @@ **Files:** - Create: `docs/sql/2026-11-14-pl-customer-cert-quota.sql` -- [ ] **Step 1:** 编写可重复执行的 MySQL 5.7 迁移,包含: +- [x] **Step 1:** 编写可重复执行的 MySQL 5.7 迁移,包含: - `pl_cert_category` - `pl_cert_category_product` - `pl_cert_quota_rule_config`(tenant_no + effective_at + 审计) @@ -41,8 +41,8 @@ - `pl_transport_cert.customer_id`(可空) - `pl_plan.cert_quota_rule_version` VARCHAR(可空/默认 legacy) - `pl_plan.quota_cert_pending_fulfill` TINYINT 默认 0(或设计等价字段) -- [ ] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。 -- [ ] **Step 3:** 在设计实现备注中列出回滚思路(DROP/列删除仅文档说明,不自动执行破坏性回滚)。 +- [x] **Step 2:** 字段注释与 AGENTS 字段序一致;索引含 tenant_no + 常用筛选。 +- [x] **Step 3:** 在设计实现备注中列出回滚思路(DROP/列删除仅文档说明,不自动执行破坏性回滚)。 ### Task 1.2:模型与品类 CRUD API @@ -52,10 +52,10 @@ - Modify: `backend/app/api/pl/quota_cert.py` 或新建 `quota_category.py` 并注册到 `api/__init__.py`、`main.py` - Create: schemas under `backend/app/schemas/pl/` -- [ ] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。 -- [ ] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。 -- [ ] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。 -- [ ] **Step 4:** 证件 create/update 接受 `customerId`/`categoryId`;运输证品类由引用推导的只读接口字段。 +- [x] **Step 1:** 增加 SQLAlchemy 模型与 Pydantic schema(camelCase alias)。 +- [x] **Step 2:** 品类 CRUD + 关联产品差量保存(遵守 child-data-save-rules)。 +- [x] **Step 3:** pytest:创建品类、关联两产品、停用关联、租户隔离。 +- [x] **Step 4:** 证件 create/update 接受 `customerId`/`categoryId`;运输证品类由引用推导的只读接口字段。 ### Task 1.3:规则配置 API + 安排门禁(未配置则拦) @@ -64,10 +64,10 @@ - Modify: `backend/app/api/pl/transport.py`(arrange 入口最前) - Modify: 计划创建服务(写入 `cert_quota_rule_version`) -- [ ] **Step 1:** GET/PUT 当前租户 `effective_at`。 -- [ ] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。 -- [ ] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。 -- [ ] **Step 4:** pytest:未配置安排失败;配置后创建计划版本正确。 +- [x] **Step 1:** GET/PUT 当前租户 `effective_at`。 +- [x] **Step 2:** `assert_cert_quota_rule_configured`:`effective_at is None` → HTTP 409 固定中文文案。 +- [x] **Step 3:** 计划创建时按设计 §7 打 `cert_quota_rule_version`。 +- [x] **Step 4:** pytest:未配置安排失败;配置后创建计划版本正确。 ### Task 1.4:证件列表默认过滤 @@ -75,9 +75,9 @@ - Modify: `backend/app/services/pl_quota_cert_service.py` list 默认 - Modify: `web-admin/src/views/pl/quota-cert/` 状态漏斗 -- [ ] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。 -- [ ] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。 -- [ ] **Step 3:** 回归测试或前端约定注释。 +- [x] **Step 1:** 列表 API 默认 `status not in (closed, void)`;显式传 status 时以参数为准。 +- [x] **Step 2:** 前端列头漏斗可选到已核销/作废;默认不选。 +- [x] **Step 3:** 回归测试或前端约定注释。 ### Task 1.5:Web 品类页 + 系统信息 Tab + 证件表单字段 @@ -86,10 +86,10 @@ - Modify: `web-admin/src/router/modules/pl.ts`、`sqxp.ts` - Modify: 证件建档表单(含 H5 `m/pl/plan-cert` 若同表单模型) -- [ ] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。 -- [ ] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。 -- [ ] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。 -- [ ] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。 +- [x] **Step 1:** 品类列表/编辑/关联产品(Fresh Mint,选 P 模式并在说明中写明)。 +- [x] **Step 2:** 系统信息下 Tab「运输证件规则」编辑生效时间。 +- [x] **Step 3:** 购买证/运输证表单增加客户、品类(运输证展示派生品类)。 +- [x] **Step 4:** 菜单 SQL/权限码文档化(若需插入菜单,写 SQL 片段)。 ### Task 1.6:计划详情展示位(可读) @@ -97,14 +97,14 @@ - Modify: `web-admin/src/components/business/pl-plan-view-tabs/` 或等价详情 - Modify: 计划详情 API 若需返回 usage 证件摘要 -- [ ] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。 -- [ ] **Step 2:** 同市来源可只显示购买证。 +- [x] **Step 1:** 详情区展示已关联购买证/运输证(无数据时占位「暂无」)。 +- [x] **Step 2:** 同市来源可只显示购买证。 ### 一期完成门禁 -- [ ] 本地执行相关 pytest,记录命令与结果。 -- [ ] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。 -- [ ] **停止并等待用户:「开始第二期」** +- [x] 本地执行相关 pytest,记录命令与结果。 +- [x] `pnpm build:typecheck`(若改了 web-admin)或说明未跑原因。 +- [x] **停止并等待用户:「开始第二期」** --- @@ -115,10 +115,10 @@ **Files:** - Modify: `backend/app/services/pl_quota_sources.py` -- [ ] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。 -- [ ] **Step 2:** `same_city` → `requires_transport=False` 保留。 -- [ ] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。 -- [ ] **Step 4:** pytest 覆盖双外部段、单侧内部、同市。 +- [x] **Step 1:** 外部装/送段均进入来源(非仅单侧计量吞掉对侧额度需求——按设计 D7);内部山峡/云栋过滤保留。 +- [x] **Step 2:** `same_city` → `requires_transport=False` 保留。 +- [x] **Step 3:** 来源快照写入 `customer_id`/`customer_name`。 +- [x] **Step 4:** pytest 覆盖双外部段、单侧内部、同市。 ### Task 2.2:整笔贪心选证模块 @@ -126,9 +126,9 @@ - Create: `backend/app/services/pl_quota_customer_select.py` - Modify: `pl_quota_allocation.py` 或在 `execute_usage` 分支按 rule_version 调用 -- [ ] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。 -- [ ] **Step 2:** 新规则路径禁止拆分到多购买证。 -- [ ] **Step 3:** legacy 计划不走此模块(祖父)。 +- [x] **Step 1:** 实现设计 §5 伪代码;单测:10 吨跳过、100 吨命中;无运输证跳过该购买证;同市无运输证成功。 +- [x] **Step 2:** 新规则路径禁止拆分到多购买证。 +- [x] **Step 3:** legacy 计划不走此模块(祖父)。 ### Task 2.3:替换合同绑证为客户品类候选 @@ -136,22 +136,22 @@ - Modify: `pl_quota_planning.py`、`pl_quota_business.py`、`pl_quota_usage_service.py` - Modify: `backend/app/api/pl/transport.py`、前端安排提交 -- [ ] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。 -- [ ] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。 -- [ ] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。 -- [ ] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。 +- [x] **Step 1:** `customer_category` 版本:`build_usage_request` 不再 `resolve_contract_bound_certs`。 +- [x] **Step 2:** 安排失败文案引导证书齐备;移除/隐藏手工 `quotaConfiguration` 安排入口。 +- [x] **Step 3:** 归档 settle 仍走账本;新规则余额不足文案更新为客户/品类语境。 +- [x] **Step 4:** legacy:已安排归档不因缺少客户品类绑证失败(单测)。 ### Task 2.4:合同绑证 UI 收敛 **Files:** - Modify: `web-admin/src/views/pl/contract-quota/` 等 -- [ ] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。 +- [x] **Step 1:** 新规则生效后隐藏或只读提示,避免双轨操作。 ### 二期完成门禁 -- [ ] 相关 pytest 全绿并记录。 -- [ ] **停止并等待用户:「开始第三期」** +- [x] 相关 pytest 全绿并记录。 +- [x] **停止并等待用户:「开始第三期」** --- @@ -163,9 +163,9 @@ - Modify: `backend/app/services/pl_quota_waiver.py`、`pl_quota_business.py` - Modify: `backend/app/api/pl/plan.py` archive -- [ ] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。 -- [ ] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。 -- [ ] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。 +- [x] **Step 1:** 标记齐备 → `quota_cert_pending_fulfill=1`,安排可跳过 reserve。 +- [x] **Step 2:** `settle`/`archive`:若 pending 或 waived 未完善 → 409,不得跳过额度。 +- [x] **Step 3:** pytest:齐备可安排、不可归档;完善后可归档。 ### Task 3.2:完善并关闭 API @@ -173,36 +173,36 @@ - Modify: `backend/app/api/pl/plan.py` 或 `quota_cert.py` - Create service:对单计划执行与安排相同的选证 + reserve -- [ ] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。 -- [ ] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。 -- [ ] **Step 3:** 并发单测或文档化行锁策略。 +- [x] **Step 1:** `POST .../fulfill-quota-certs`(单条)与 batch 接口。 +- [x] **Step 2:** 成功:写 usage、清 pending、出队;失败:保留 pending + 原因。 +- [x] **Step 3:** 并发单测或文档化行锁策略。 ### Task 3.3:证书齐备 Web **Files:** - Modify/Create: 证书齐备列表页(现有 menu 路由对齐) -- [ ] **Step 1:** 仅列出新规则待完善计划。 -- [ ] **Step 2:** 上传购买证/运输证入口。 -- [ ] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。 -- [ ] **Step 4:** 归档弹窗对接「未完善」错误引导。 +- [x] **Step 1:** 仅列出新规则待完善计划。 +- [x] **Step 2:** 上传购买证/运输证入口。 +- [x] **Step 3:** 操作列完善并关闭 + 批量;成功刷新列表。 +- [x] **Step 4:** 归档弹窗对接「未完善」错误引导。 ### Task 3.4:详情展示真实挂证 -- [ ] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。 +- [x] **Step 1:** 完善或安排成功后详情可见证号/品类/占用等摘要。 ### 三期完成门禁 -- [ ] 全链路 pytest + 必要前端 typecheck。 -- [ ] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。 -- [ ] **停止并等待用户验收;非用户要求不要 push。** +- [x] 全链路 pytest + 必要前端 typecheck。 +- [x] 更新本 plan 所有 checkbox;在设计文档顶部标注「已实现待验收」或按用户要求提交。 +- [x] **停止并等待用户验收;非用户要求不要 push。** --- ## 数据迁移作业(可穿插在一期后、切点打开前) -- [ ] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。 -- [ ] 文档说明:用户手工建品类并关联产品、回填证件 `customer_id`/`category_id` 后再配置 `effective_at`。 +- [x] 提供只读 SQL/脚本:按 `product_name_on_cert` 分组列出候选品类名。 +- [x] 文档说明:用户手工建品类并关联产品、回填证件 `customer_id`/`category_id` 后再配置 `effective_at`。 ## Spec 覆盖自检 diff --git a/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md b/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md index 01f9069d9..5744ab55e 100644 --- a/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md +++ b/docs/superpowers/specs/2026-11-14-pl-customer-cert-quota-design.md @@ -1,6 +1,6 @@ # 运输计划购买证/运输证:客户+品类额度改造设计 -> 状态:实现中(2026-11-14 本会话已落地核心后端 + 关键 Web;待执行 SQL 与业务验收) +> 状态:已实现待验收(2026-11-14;三期代码已落地,待业务验收;非用户要求不 push) > 关联计划:`docs/superpowers/plans/2026-11-14-pl-customer-cert-quota.md` > 现状参考:`openspec/changes/pl-contract-cert-quota-control/`(合同绑证模型,本需求起新规则不再作为选证来源) diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index 55119539b..bec5120e6 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -64,6 +64,7 @@ export interface QuotaCert extends Omit { createTime?: string certType?: CertType derivedCategoryIds?: string[] + customerName?: string | null } export interface ScopeProduct { id?: string @@ -330,6 +331,7 @@ export interface PlanQuota { canSaveConfiguration?: boolean arrangeBlockMessage?: string quotaCertWaived?: boolean + certQuotaRuleVersion?: 'legacy' | 'customer_category' | string configuration: Partial sourceOptions: QuotaSourceOption[] usages: Record[] diff --git a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue index aade95ecc..d2e6cc07b 100644 --- a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue @@ -1602,8 +1602,13 @@ async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') { const html = `

${escapeArchiveBlockText(message)}

` - if (title !== '用证没有完成') { - await ElMessageBox.alert(html, title, { + const pendingFulfill = + message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善') + if (title !== '用证没有完成' || pendingFulfill) { + const guide = pendingFulfill + ? `${html}

新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 待完善并关闭」上传证件并执行完善并关闭后再归档。

` + : html + await ElMessageBox.alert(guide, title, { type: 'warning', confirmButtonText: '知道了', dangerouslyUseHTMLString: true, @@ -1612,7 +1617,8 @@ }).catch(() => undefined) return undefined } - const readyHtml = `${html}

如果购买证、运输证还没上传,可以标记证书齐备。标记后这次归档不再校验,也不扣减这两证的额度。

` + // 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度 + const readyHtml = `${html}

如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「待完善并关闭」。

` try { await ElMessageBox.confirm(readyHtml, title, { type: 'warning', diff --git a/web-admin/src/components/business/pl-plan-quota-dialog/index.vue b/web-admin/src/components/business/pl-plan-quota-dialog/index.vue index c9e045d6d..b46b9bfd6 100644 --- a/web-admin/src/components/business/pl-plan-quota-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-quota-dialog/index.vue @@ -17,7 +17,14 @@ 当前车号 {{ plan.licensePlate || '待安排' }} -

新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。

+

已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。

- 未保存的产品可以移除,已保存的只能设为不可用。点底部保存后写入。 @@ -172,7 +209,7 @@ - + + + +
+

正在加载品类…

+

暂无启用中的证用品类。

+ +
+
import { ElMessage } from 'element-plus' - import { fetchCustomerOptions } from '@/api/crm/customer' + import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer' import { listContract } from '@/api/crm/contract' import type { ContractVO } from '@/api/crm/contract/types' import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' @@ -519,7 +585,9 @@ import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue' import PlanDateField from '../plan/PlanDateField.vue' import { + certCategoryApi, quotaCertApi, + type CertCategory, type CertType, type CertificateContractBinding, type ContractOption, @@ -618,8 +686,24 @@ const contractOptions = ref([]) const customerOptions = ref<{ id: string; customerName?: string; customerNo?: string }[]>([]) const bindingCustomer = ref<{ id: string; customerName: string }>({ id: '', customerName: '' }) + const systemCustomer = ref<{ id: string; customerName: string }>({ id: '', customerName: '' }) + const customerPickMode = ref<'system' | 'binding'>('binding') + const categoryOpen = ref(false) + const categoryLoading = ref(false) + const categoryOptions = ref([]) const customerLoading = ref(false) const linesLoading = ref(false) + const systemCustomerLabel = computed(() => systemCustomer.value.customerName || '') + const categoryLabel = computed(() => { + const id = String(form.value.categoryId || '') + return categoryOptions.value.find((item) => item.id === id)?.categoryName || '' + }) + const derivedCategoryLabel = computed(() => { + const ids = (form.value.derivedCategoryIds || []) as string[] + if (!ids.length) return '' + const names = new Map(categoryOptions.value.map((item) => [item.id, item.categoryName])) + return ids.map((id) => names.get(id) || id).join('、') + }) const purchases = ref([]) const lines = ref([]) const productNames = ref(new Map()) @@ -662,6 +746,7 @@ lockedPlates.value = [] linkedRefIds.value = [] clearBindingCustomer() + clearSystemCustomer() pendingContract.value = undefined picked.value = new Set() lines.value = [] @@ -887,6 +972,10 @@ function clearBindingCustomer() { bindingCustomer.value = { id: '', customerName: '' } } + function clearSystemCustomer() { + systemCustomer.value = { id: '', customerName: '' } + form.value.customerId = null + } async function searchCustomers(keyword = '') { const current = epoch customerLoading.value = true @@ -900,28 +989,70 @@ if (current === epoch) customerLoading.value = false } } + async function loadCategories() { + categoryLoading.value = true + try { + categoryOptions.value = (await certCategoryApi.list({ status: '1' })) || [] + } catch { + categoryOptions.value = [] + } finally { + categoryLoading.value = false + } + } function openCustomers() { if (saving.value || recognizing.value || linesLoading.value) return + customerPickMode.value = 'binding' customerKeyword.value = '' customerOpen.value = true void searchCustomers('') } + function openSystemCustomer() { + if (saving.value || recognizing.value) return + customerPickMode.value = 'system' + customerKeyword.value = '' + customerOpen.value = true + void searchCustomers('') + } + function openCategories() { + if (saving.value || recognizing.value) return + categoryOpen.value = true + if (!categoryOptions.value.length) void loadCategories() + } function pickCustomer(item: { id: string; customerName?: string }) { - bindingCustomer.value = { id: item.id, customerName: item.customerName || '' } + if (customerPickMode.value === 'system') { + systemCustomer.value = { id: item.id, customerName: item.customerName || '' } + form.value.customerId = item.id + touch('customerId') + } else { + bindingCustomer.value = { id: item.id, customerName: item.customerName || '' } + } customerOpen.value = false } + function pickCategory(item: CertCategory) { + form.value.categoryId = item.id + touch('categoryId') + categoryOpen.value = false + } async function matchBuyerCustomer(name: string, current: number) { const buyer = name.trim() - if (!buyer || bindingCustomer.value.id || form.value.bindingsEditable === false) return + if (!buyer) return try { const result = await fetchCustomerOptions({ keyword: buyer, limit: 200 }) - if (current !== epoch || bindingCustomer.value.id) return + if (current !== epoch) return const matches = exactCustomerMatches(extractCustomers(result), buyer) if (matches.length === 1) { - bindingCustomer.value = { + const matched = { id: String(matches[0].id), customerName: matches[0].customerName || buyer } + if (!systemCustomer.value.id) { + systemCustomer.value = matched + form.value.customerId = matched.id + touch('customerId') + } + if (!bindingCustomer.value.id && form.value.bindingsEditable !== false) { + bindingCustomer.value = matched + } return } ElMessage.warning( @@ -1044,6 +1175,30 @@ pendingContract.value = undefined productOpen.value = false } + async function syncSystemCustomerFromForm(cert?: QuotaCert | Record) { + const id = String(cert?.customerId || form.value.customerId || '') + if (!id) { + clearSystemCustomer() + return + } + const certRecord = (cert || {}) as Record + let name = + String(certRecord.customerName || '').trim() || + systemCustomer.value.customerName || + customerOptions.value.find((item) => item.id === id)?.customerName || + '' + if (!name) { + try { + const detail = (await fetchCustomerDetail({ id })) as Record + const data = detail?.data || detail + name = String(data?.customerName || '').trim() || id + } catch { + name = id + } + } + systemCustomer.value = { id, customerName: name } + form.value.customerId = id + } async function load() { const current = ++epoch loading.value = true @@ -1052,6 +1207,7 @@ error.value = '' originals.clear() try { + void loadCategories() if (creating.value) { const entryMode = form.value.entryMode === 'manual' ? 'manual' : 'image_parse' form.value = { @@ -1065,6 +1221,7 @@ recognitionImage.value = '' contractBriefs.value = new Map() clearBindingCustomer() + clearSystemCustomer() if (kind.value === 'transport') purchases.value = await quotaCertApi.list('purchase').catch(() => []) return @@ -1074,7 +1231,9 @@ quotaCertApi.certificateContracts(kind.value, certId.value).catch(() => []) ]) if (current !== epoch) return - contractBriefs.value = new Map(contracts.map((item) => [item.id, item])) + contractBriefs.value = new Map( + contracts.map((item) => [item.id, item] as [string, ContractOption]) + ) const names = new Map() await Promise.all( (cert.contractBindings || []).map(async (binding) => { @@ -1091,6 +1250,7 @@ ...certPayload(kind.value, cert), status: cert.status, totalQty: inputQty(cert.totalQty), + derivedCategoryIds: (cert as QuotaCert & { derivedCategoryIds?: string[] }).derivedCategoryIds || [], contractBindings: (cert.contractBindings || []).map((binding) => ({ ...binding, products: binding.products.map((product) => ({ ...product })) @@ -1109,6 +1269,7 @@ .map((row) => String(row.id)) : [] clearBindingCustomer() + await syncSystemCustomerFromForm(cert) if (kind.value === 'transport') purchases.value = await quotaCertApi.list('purchase').catch(() => []) } catch { @@ -1132,6 +1293,14 @@ ElMessage.warning(`请填写${missing}`) return } + if (!form.value.customerId) { + ElMessage.warning('请选择系统客户') + return + } + if (kind.value === 'purchase' && !form.value.categoryId) { + ElMessage.warning('请选择证用品类') + return + } if (attachmentCount(form.value.attachmentUrls) !== 1) { ElMessage.warning('请保留且仅上传一张证件附件') return @@ -1188,6 +1357,7 @@ lockedPlates.value = [] linkedRefIds.value = [] clearBindingCustomer() + clearSystemCustomer() ElMessage.success('证件已保存,请上传下一张证件') } else if (props.popup) { ElMessage.success('证件已保存') @@ -1210,12 +1380,15 @@ ].map((field) => field[0]) const hasContent = keys.some((key) => form.value[key] != null && form.value[key] !== '') || + form.value.customerId || + form.value.categoryId || form.value.contractBindings?.length || form.value.purchaseRefs?.length || form.value.vehicleNumbersJson?.length if (hasContent) { try { await showCrmConfirmDialog({ + kind: 'default', title: '切换证件类型', message: '切换证件类型将清空证面信息,保留建档方式、附件和系统备注。是否继续?' }) @@ -1238,6 +1411,7 @@ lockedPlates.value = [] linkedRefIds.value = [] clearBindingCustomer() + clearSystemCustomer() if (value === 'transport') purchases.value = await quotaCertApi.list('purchase').catch(() => []) } function recognitionCurrent(current: number, targetKind: CertType, image: string) { diff --git a/web-admin/src/views/pl/cert-ready/index.vue b/web-admin/src/views/pl/cert-ready/index.vue index 25b4c662b..36e7f0959 100644 --- a/web-admin/src/views/pl/cert-ready/index.vue +++ b/web-admin/src/views/pl/cert-ready/index.vue @@ -23,6 +23,12 @@ > 批量完善并关闭 + + 上传购买证 + + + 上传运输证 + 清除筛选 @@ -148,7 +154,16 @@ getPlPlanFollowStatusLabel, getPlPlanFollowStatusTagType } from '@/utils/pl-display' - import { CircleCheck, Refresh, RefreshLeft } from '@element-plus/icons-vue' + import { CircleCheck, Refresh, RefreshLeft, Upload } from '@element-plus/icons-vue' + + const router = useRouter() + + function goUploadCert(kind: 'purchase' | 'transport') { + void router.push({ + path: '/pl/cert-upload/quota-cert', + query: { kind, create: '1' } + }) + } const keyword = ref('') const page = ref(1) diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 8f5fef0c1..28b7601ef 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -1667,7 +1667,7 @@ form.value.referencesEditable === false && !!row.id && linkedRefIds.value.includes(row.id) ) } - function onPurchaseArchiveChange() { + function onPurchaseArchiveChange(_row?: { purchaseCertId?: string | null }) { edited.add('purchaseRefs') } function referencesLocked() { @@ -1749,9 +1749,15 @@ if (current === authEpoch && !disposed && typeof route.query.contractId === 'string') scopeDialog.value?.open(route.query.contractId) if (current === authEpoch && !disposed && route.query.create === '1' && canEdit.value) { + const createKind = + route.query.kind === 'transport' || route.query.kind === 'purchase' + ? (route.query.kind as CertType) + : kind.value + kind.value = createKind await edit() const nextQuery = { ...route.query } delete nextQuery.create + delete nextQuery.kind router.replace({ query: nextQuery }) } } diff --git a/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue b/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue index aa50cadbb..d0d171988 100644 --- a/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue +++ b/web-admin/src/views/pl/quota-statistics/StatisticsPanel.vue @@ -3,6 +3,13 @@

{{ mode === 'contract' ? '合同用证' : '证件用量' }}

+
null) + if (current !== epoch) return + customerRuleActive.value = !!rule?.configured + } await load() } catch { if (current === epoch) error.value = '权限加载失败,请重新打开页面'