fix(pl): 补齐客户品类证件额度三期收口项。
完善 H5 建档、归档待完善引导、齐备上传入口与新规则只读提示,并同步计划验收状态与单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
208c93d192
commit
b0c1bca683
12 files changed
+439
-73
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
@@ -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 覆盖自检
|
||||
|
||||
|
||||
@@ -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/`(合同绑证模型,本需求起新规则不再作为选证来源)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface QuotaCert extends Omit<CertBody, 'status'> {
|
||||
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<QuotaConfiguration>
|
||||
sourceOptions: QuotaSourceOption[]
|
||||
usages: Record<string, any>[]
|
||||
|
||||
@@ -1602,8 +1602,13 @@
|
||||
|
||||
async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') {
|
||||
const html = `<p class="pl-arrange-block-text">${escapeArchiveBlockText(message)}</p>`
|
||||
if (title !== '用证没有完成') {
|
||||
await ElMessageBox.alert(html, title, {
|
||||
const pendingFulfill =
|
||||
message.includes('待完善') || message.includes('完善并关闭') || message.includes('证书齐备待完善')
|
||||
if (title !== '用证没有完成' || pendingFulfill) {
|
||||
const guide = pendingFulfill
|
||||
? `${html}<p class="pl-arrange-block-text">新规则下证书齐备只放行安排,不能绕过归档挂证。请到「证书齐备 → 待完善并关闭」上传证件并执行完善并关闭后再归档。</p>`
|
||||
: html
|
||||
await ElMessageBox.alert(guide, title, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '知道了',
|
||||
dangerouslyUseHTMLString: true,
|
||||
@@ -1612,7 +1617,8 @@
|
||||
}).catch(() => undefined)
|
||||
return undefined
|
||||
}
|
||||
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。标记后这次归档不再校验,也不扣减这两证的额度。</p>`
|
||||
// 仅旧规则(legacy)允许在归档时标记证书齐备以跳过额度
|
||||
const readyHtml = `${html}<p class="pl-arrange-block-text">如果购买证、运输证还没上传,可以标记证书齐备。仅旧规则计划标记后这次归档不再校验、不扣这两证额度;新规则计划请走「待完善并关闭」。</p>`
|
||||
try {
|
||||
await ElMessageBox.confirm(readyHtml, title, {
|
||||
type: 'warning',
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
<span>当前车号</span>
|
||||
<strong>{{ plan.licensePlate || '待安排' }}</strong>
|
||||
</div>
|
||||
<p v-if="viewOnly && plan.canSaveConfiguration === false" class="quota-note"
|
||||
<p
|
||||
v-if="viewOnly && plan.certQuotaRuleVersion === 'customer_category'"
|
||||
class="quota-note"
|
||||
>新规则计划由系统按客户+品类自动选证,本页仅查看占用;安排不提供手工选证。</p
|
||||
>
|
||||
<p
|
||||
v-else-if="viewOnly && plan.canSaveConfiguration === false"
|
||||
class="quota-note"
|
||||
>已安排计划请从调整运输提交用证变更;已归档计划仅查看占用。</p
|
||||
>
|
||||
<template v-if="viewOnly">
|
||||
|
||||
@@ -154,17 +154,54 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="mp-wv-section">
|
||||
<h3>客户与品类</h3>
|
||||
<p class="plan-empty-hint">
|
||||
新规则按系统客户选证;购买证还需绑定证用品类。运输证品类由引用的购买证自动汇总。安排时不再依赖合同产品绑定。
|
||||
</p>
|
||||
<van-field
|
||||
:model-value="systemCustomerLabel"
|
||||
label="系统客户"
|
||||
label-align="top"
|
||||
readonly
|
||||
:is-link="form.status !== 'void' && form.status !== 'closed'"
|
||||
placeholder="搜索并选择客户"
|
||||
required
|
||||
@click="form.status !== 'void' && form.status !== 'closed' && openSystemCustomer()"
|
||||
/>
|
||||
<van-field
|
||||
v-if="kind === 'purchase'"
|
||||
:model-value="categoryLabel"
|
||||
label="证用品类"
|
||||
label-align="top"
|
||||
readonly
|
||||
:is-link="form.status !== 'void' && form.status !== 'closed'"
|
||||
placeholder="选择证用品类"
|
||||
required
|
||||
@click="form.status !== 'void' && form.status !== 'closed' && openCategories()"
|
||||
/>
|
||||
<van-field
|
||||
v-else
|
||||
:model-value="derivedCategoryLabel"
|
||||
label="适用品类"
|
||||
label-align="top"
|
||||
readonly
|
||||
placeholder="引用购买证并保存后自动汇总"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="mp-wv-section mp-plan-cert-list">
|
||||
<h3>绑定合同产品</h3>
|
||||
<h3>历史:绑定合同产品(旧规则,可选)</h3>
|
||||
<p v-if="form.bindingsEditable === false" class="plan-empty-hint">
|
||||
当前仅展示可见合同,绑定范围不可编辑。证面信息仍可保存。
|
||||
</p>
|
||||
<p v-else class="plan-empty-hint">
|
||||
仅兼容旧合同绑证数据。新规则选证以客户+品类为准,可不填。
|
||||
<template v-if="kind === 'purchase'"
|
||||
>识别后按购买单位或个人的名称或姓名自动填入名称完全一致的客户。选择合同后带出全部产品。</template
|
||||
>识别后仍可按购买单位自动匹配客户并带出合同产品。</template
|
||||
>
|
||||
<template v-else>运输证识别后,会把匹配购买证上的合同和产品带到这里。</template>
|
||||
未保存的产品可以移除,已保存的只能设为不可用。点底部保存后写入。<template
|
||||
未保存的产品可以移除,已保存的只能设为不可用。<template
|
||||
v-if="hasLockedBinding"
|
||||
>已有用证,不能增删产品。产品可以设为不可用。</template
|
||||
>
|
||||
@@ -172,7 +209,7 @@
|
||||
<van-field
|
||||
v-if="form.bindingsEditable !== false && form.status !== 'void' && form.status !== 'closed'"
|
||||
:model-value="bindingCustomer.customerName"
|
||||
label="客户名称"
|
||||
label="合同客户"
|
||||
label-align="top"
|
||||
readonly
|
||||
is-link
|
||||
@@ -380,7 +417,11 @@
|
||||
teleport="body"
|
||||
class="mp-cert-form-sheet mp-cert-contract-sheet"
|
||||
>
|
||||
<van-nav-bar title="选择客户" left-text="关闭" @click-left="customerOpen = false" />
|
||||
<van-nav-bar
|
||||
:title="customerPickMode === 'system' ? '选择系统客户' : '选择合同客户'"
|
||||
left-text="关闭"
|
||||
@click-left="customerOpen = false"
|
||||
/>
|
||||
<van-search
|
||||
v-model="customerKeyword"
|
||||
placeholder="客户名称"
|
||||
@@ -403,6 +444,31 @@
|
||||
</button>
|
||||
</div>
|
||||
</van-popup>
|
||||
<van-popup
|
||||
v-model:show="categoryOpen"
|
||||
position="bottom"
|
||||
round
|
||||
teleport="body"
|
||||
class="mp-cert-form-sheet mp-cert-contract-sheet"
|
||||
>
|
||||
<van-nav-bar title="选择证用品类" left-text="关闭" @click-left="categoryOpen = false" />
|
||||
<div class="mp-cert-contract-sheet__list">
|
||||
<p v-if="categoryLoading" class="plan-empty-hint">正在加载品类…</p>
|
||||
<p v-else-if="!categoryOptions.length" class="plan-empty-hint">暂无启用中的证用品类。</p>
|
||||
<button
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.id"
|
||||
class="mp-cert-contract-card"
|
||||
type="button"
|
||||
@click="pickCategory(item)"
|
||||
>
|
||||
<span class="mp-cert-contract-card__no">{{ item.categoryName || '—' }}</span>
|
||||
<span v-if="item.categoryCode" class="mp-cert-contract-card__meta">{{
|
||||
item.categoryCode
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</van-popup>
|
||||
<van-popup
|
||||
v-model:show="contractOpen"
|
||||
position="bottom"
|
||||
@@ -511,7 +577,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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<ContractOption[]>([])
|
||||
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<CertCategory[]>([])
|
||||
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<QuotaCert[]>([])
|
||||
const lines = ref<ContractProduct[]>([])
|
||||
const productNames = ref(new Map<string, string>())
|
||||
@@ -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<string, any>) {
|
||||
const id = String(cert?.customerId || form.value.customerId || '')
|
||||
if (!id) {
|
||||
clearSystemCustomer()
|
||||
return
|
||||
}
|
||||
const certRecord = (cert || {}) as Record<string, any>
|
||||
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<string, any>
|
||||
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<string, string>()
|
||||
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) {
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
>
|
||||
<el-icon><CircleCheck /></el-icon>批量完善并关闭
|
||||
</el-button>
|
||||
<el-button v-if="listMode === 'fulfill'" @click="goUploadCert('purchase')">
|
||||
<el-icon><Upload /></el-icon>上传购买证
|
||||
</el-button>
|
||||
<el-button v-if="listMode === 'fulfill'" @click="goUploadCert('transport')">
|
||||
<el-icon><Upload /></el-icon>上传运输证
|
||||
</el-button>
|
||||
<el-button v-if="keyword || hasActiveFilters" @click="reset">
|
||||
<el-icon><RefreshLeft /></el-icon>清除筛选
|
||||
</el-button>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<div v-if="!embedded" class="panel-top"
|
||||
><h2 class="title-bar-heading">{{ mode === 'contract' ? '合同用证' : '证件用量' }}</h2></div
|
||||
>
|
||||
<el-alert
|
||||
v-if="!embedded && mode === 'contract' && customerRuleActive"
|
||||
class="mb-3"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="新规则已生效:安排选证改为客户+证用品类,本页合同绑证仅供历史查看与旧规则计划,请勿再作为新计划的用证操作入口。"
|
||||
/>
|
||||
<div v-if="!contractId" class="list-toolbar"
|
||||
><div class="list-toolbar-row cert-list-toolbar-row">
|
||||
<ListQuickSearchInput
|
||||
@@ -188,6 +195,7 @@
|
||||
import { fetchGetMenuCodes } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import {
|
||||
certQuotaRuleApi,
|
||||
quotaCertApi,
|
||||
quotaStatisticsApi,
|
||||
type ContractStatistics,
|
||||
@@ -219,6 +227,7 @@
|
||||
)
|
||||
const user = useUserStore()
|
||||
const router = useRouter()
|
||||
const customerRuleActive = ref(false)
|
||||
const allowed = ref(false),
|
||||
canEdit = ref(false),
|
||||
loading = ref(false),
|
||||
@@ -530,6 +539,7 @@
|
||||
clear()
|
||||
allowed.value = false
|
||||
canEdit.value = false
|
||||
customerRuleActive.value = false
|
||||
sessionKey.value++
|
||||
if (!user.accessToken || !props.active) return
|
||||
const current = epoch
|
||||
@@ -541,6 +551,11 @@
|
||||
canEdit.value =
|
||||
allowed.value &&
|
||||
(!!p.all || !!p.isSuperAdmin || (p.menuCodes || []).includes('pl:quota-cert:edit'))
|
||||
if (props.mode === 'contract' && !props.embedded) {
|
||||
const rule = await certQuotaRuleApi.get().catch(() => null)
|
||||
if (current !== epoch) return
|
||||
customerRuleActive.value = !!rule?.configured
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
if (current === epoch) error.value = '权限加载失败,请重新打开页面'
|
||||
|
||||
Reference in new issue
Block a user