From ea46761f402913de375d0bed49a396efa5238c9b Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Sun, 15 Nov 2026 14:46:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E5=90=88=E5=90=8C=E7=94=A8?= =?UTF-8?q?=E8=AF=81=E6=94=B9=E4=B8=BA=E5=AE=A2=E6=88=B7=E7=94=A8=E8=AF=81?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=88=97=E8=A1=A8=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。 Co-authored-by: Cursor --- backend/app/api/pl/quota_cert.py | 26 +- backend/app/services/pl_quota_cert_service.py | 57 +++- .../app/services/pl_quota_customer_select.py | 5 +- backend/app/services/pl_quota_statistics.py | 109 ++++++- .../app/services/pl_quota_usage_service.py | 19 +- backend/tests/test_pl_customer_cert_quota.py | 248 +++++++++++++++ ...ename-contract-quota-to-customer-quota.sql | 16 + web-admin/scripts/contract-proof-tab.test.ts | 21 +- .../scripts/customer-cert-quota-panel.test.ts | 75 +++++ .../scripts/mobile-cert-ready-h5.test.ts | 14 +- web-admin/scripts/quota-statistics.test.ts | 127 ++++---- web-admin/src/App.vue | 2 +- web-admin/src/api/pl/quota-cert.ts | 30 +- web-admin/src/layouts/MpWebviewShell.vue | 2 +- web-admin/src/router/modules/pl.ts | 2 +- web-admin/src/router/routes/staticRoutes.ts | 10 +- .../components/ContractDetailDialog.vue | 11 +- .../m/crm/contract/CustomerCertQuotaTab.vue | 180 +++++++++++ web-admin/src/views/m/crm/contract/detail.vue | 55 +++- .../src/views/m/pl/plan-cert/ContractTab.vue | 143 +-------- .../src/views/m/pl/plan-cert/CustomerTab.vue | 143 +++++++++ .../src/views/m/pl/plan-cert/archive-form.vue | 25 +- .../views/m/pl/plan-cert/customer-detail.vue | 83 +++++ .../src/views/m/pl/plan-cert/face-model.ts | 11 +- web-admin/src/views/m/pl/plan-cert/index.vue | 2 +- .../pl/contract-quota/ContractQuotaPanel.vue | 6 +- .../contract-quota/CustomerCertQuotaPanel.vue | 297 ++++++++++++++++++ .../pl/contract-quota/CustomerQuotaPanel.vue | 296 +++++++++++++++++ web-admin/src/views/pl/quota-cert/index.vue | 57 ++-- 29 files changed, 1777 insertions(+), 295 deletions(-) create mode 100644 docs/sql/migrations/2026-11-15-rename-contract-quota-to-customer-quota.sql create mode 100644 web-admin/scripts/customer-cert-quota-panel.test.ts create mode 100644 web-admin/src/views/m/crm/contract/CustomerCertQuotaTab.vue create mode 100644 web-admin/src/views/m/pl/plan-cert/CustomerTab.vue create mode 100644 web-admin/src/views/m/pl/plan-cert/customer-detail.vue create mode 100644 web-admin/src/views/pl/contract-quota/CustomerCertQuotaPanel.vue create mode 100644 web-admin/src/views/pl/contract-quota/CustomerQuotaPanel.vue diff --git a/backend/app/api/pl/quota_cert.py b/backend/app/api/pl/quota_cert.py index d6e951dfe..fcd372c77 100644 --- a/backend/app/api/pl/quota_cert.py +++ b/backend/app/api/pl/quota_cert.py @@ -320,6 +320,23 @@ def contract_statistics_list(keyword: str = Query('', max_length=200), cert_count=cert_count, cert_count_min=cert_count_min, cert_count_max=cert_count_max)) +@router.get('/statistics/customers') +def customer_statistics_list(keyword: str = Query('', max_length=200), + customer_no: str = Query('', alias='customerNo', max_length=200), + customer_name: str = Query('', alias='customerName', max_length=200), + cert_count_op: str = Query('', alias='certCountOp', max_length=20), + cert_count: str = Query('', alias='certCount', max_length=40), + cert_count_min: str = Query('', alias='certCountMin', max_length=40), + cert_count_max: str = Query('', alias='certCountMax', max_length=40), + page: int = Query(1, ge=1), + page_size: int = Query(20, alias='pageSize', ge=1, le=100), + db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): + return _wire(statistics.list_customers( + db, user, keyword=keyword, page=page, page_size=page_size, customer_no=customer_no, + customer_name=customer_name, cert_count_op=cert_count_op, cert_count=cert_count, + cert_count_min=cert_count_min, cert_count_max=cert_count_max)) + + @router.get('/statistics/certificates') def certificate_statistics_list(keyword: str = Query('', max_length=200), cert_no: str = Query('', alias='certNo', max_length=200), @@ -378,6 +395,12 @@ def contract_statistics(contract_id: str, db: Session = Depends(get_db), return _wire(statistics.contract_statistics(db, user, contract_id)) +@router.get('/customers/{customer_id}/statistics') +def customer_statistics(customer_id: str, db: Session = Depends(get_db), + user: DbUserContext = Depends(current_user)): + return _wire(statistics.customer_statistics(db, user, customer_id)) + + @router.get('/{cert_type}/{cert_id}/statistics') def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): @@ -386,8 +409,9 @@ def certificate_statistics(cert_type: CertType, cert_id: str, db: Session = Depe @router.get('/{cert_type}') def list_certs(cert_type: CertType, keyword: Optional[str] = None, status: Optional[str] = None, + customer_id: Optional[str] = Query(None, alias='customerId', max_length=64), db: Session = Depends(get_db), user: DbUserContext = Depends(current_user)): - return _wire(certs.list_certs(db, user, cert_type, keyword, status)) + return _wire(certs.list_certs(db, user, cert_type, keyword, status, customer_id=customer_id)) @router.post('/{cert_type}/recognize', dependencies=[Depends(require_menu_code('pl:quota-cert:edit'))]) diff --git a/backend/app/services/pl_quota_cert_service.py b/backend/app/services/pl_quota_cert_service.py index 705c6dfd6..0ad4f5a49 100644 --- a/backend/app/services/pl_quota_cert_service.py +++ b/backend/app/services/pl_quota_cert_service.py @@ -9,6 +9,7 @@ from fastapi import HTTPException from pydantic import BaseModel from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError +from app.models.crm_models import CrmCustomer from app.models.pl_quota_models import ( PlPurchaseCert, PlTransportCert, PlTransportCertPurchaseRef, PlCertQtyLog, PlPlanCertUsage, PlContractCertLink, PlCertOperation, @@ -47,6 +48,39 @@ def usage_policy(valid_use_text): return {'一次有效': 'once', '多次有效': 'multiple'}.get((valid_use_text or '').strip(), 'needs_confirmation') +_ONCE_USAGE_STATUSES = ('reserved', 'in_transit', 'settled', 'exception') + + +def is_single_use_consumed(db, user, cert_type, cert, *, exclude_plan_id=None): + """一次有效证是否已不可再用:期初已使用,或其它计划已占用/已执行。 + + exclude_plan_id:当前计划自身的用量不计入占用(安排/结转同一计划可续用)。 + 多次有效或待确认原文返回 False,由调用方另行校验 policy。 + """ + if usage_policy(getattr(cert, 'valid_use_text', None)) != 'once': + return False + if getattr(cert, 'opening_single_use_consumed', 0): + return True + previous = ( + db.query(PlPlanCertUsage) + .filter_by( + tenant_no=user.tenant_no, + cert_type=cert_type, + cert_id=cert.id, + del_flag=0, + ) + .all() + ) + for usage in previous: + if exclude_plan_id is not None and usage.plan_id == exclude_plan_id: + continue + if usage.usage_status in _ONCE_USAGE_STATUSES: + return True + if (usage.confirmation_snapshot or {}).get('executed'): + return True + return False + + def _assert_known_usage_policy(row): if usage_policy(row.valid_use_text) == 'needs_confirmation': raise HTTPException(400, '有效次数原文待确认,明确一次或多次有效后才能启用') @@ -85,6 +119,23 @@ def _transport_derived_categories(db, user, transport_cert_id): return list(dict.fromkeys(r.category_id for r in rows if r.category_id)) +def _customer_display_name(db, user, customer_id): + """按证件绑定的系统客户 ID 取 CRM 名称;与证面收货/购买单位无关。""" + cid = str(customer_id or '').strip() + if not cid: + return None + row = ( + db.query(CrmCustomer.customer_name) + .filter( + CrmCustomer.id == cid, + CrmCustomer.tenant_no == user.tenant_no, + CrmCustomer.del_flag == 0, + ) + .first() + ) + return (row[0] if row else None) or None + + def get_cert(db, user, cert_type, cert_id, current=False): row = _get_row(db, user, cert_type, cert_id, lock=current) result = _row_to_dict(row) @@ -96,6 +147,8 @@ def get_cert(db, user, cert_type, cert_id, current=False): opening_query = opening_query.with_for_update() result['openingConfirmed'] = opening_query.first() is not None result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty + result['singleUseConsumed'] = is_single_use_consumed(db, user, cert_type, row) + result['customerName'] = _customer_display_name(db, user, getattr(row, 'customer_id', None)) if cert_type == 'transport': result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)] result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current) @@ -103,10 +156,12 @@ def get_cert(db, user, cert_type, cert_id, current=False): return result -def list_certs(db, user, cert_type, keyword=None, status=None): +def list_certs(db, user, cert_type, keyword=None, status=None, customer_id=None): model = _model(cert_type) query = db.query(model).filter(model.tenant_no == user.tenant_no, model.del_flag == 0) query = apply_pl_scope(query, model, user, db, user.tenant_no) + if customer_id: + query = query.filter(model.customer_id == customer_id) if keyword: sale_name = model.seller_name if cert_type == 'purchase' else model.shipper_name buy_name = model.buyer_name if cert_type == 'purchase' else model.consignee_name diff --git a/backend/app/services/pl_quota_customer_select.py b/backend/app/services/pl_quota_customer_select.py index 2813138e5..f6285d424 100644 --- a/backend/app/services/pl_quota_customer_select.py +++ b/backend/app/services/pl_quota_customer_select.py @@ -12,6 +12,7 @@ from app.models.pl_quota_models import ( ) from app.schemas.pl.quota_planning import ManualQuantity from app.services.pl_cert_category_service import category_ids_for_product +from app.services.pl_quota_cert_service import is_single_use_consumed from app.services.pl_quota_units import same_quantity_unit, standard_conversion_ratio @@ -120,7 +121,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat continue if not _in_force(purchase, on_date): continue - if purchase.opening_single_use_consumed: + if is_single_use_consumed(db, user, 'purchase', purchase): continue try: need = _permit_qty_for(Decimal(source.quantity), source.unit, purchase.quantity_unit) @@ -169,7 +170,7 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat continue if not _in_force(transport, on_date): continue - if transport.opening_single_use_consumed: + if is_single_use_consumed(db, user, 'transport', transport): continue if not _vehicle_ok(transport, vehicle_no): continue diff --git a/backend/app/services/pl_quota_statistics.py b/backend/app/services/pl_quota_statistics.py index 81d8d1986..f8ba35663 100644 --- a/backend/app/services/pl_quota_statistics.py +++ b/backend/app/services/pl_quota_statistics.py @@ -4,12 +4,12 @@ from decimal import Decimal from sqlalchemy import and_, or_, literal, func from fastapi import HTTPException from app.crud.crm.data_scope_helpers import apply_crm_owner_scope -from app.models.crm_models import CrmContract, CrmContractProduct +from app.models.crm_models import CrmContract, CrmContractProduct, CrmCustomer from app.models.pl_models import PlPlan from app.models.pl_quota_models import (PlContractCertLink as Link, PlContractCertProduct as Scope, PlPlanCertUsage as Usage, PlPlanCertUsageItem as Item, PlCertQtyLog as Log) from app.services.pl_data_scope import apply_pl_scope -from app.services.pl_quota_cert_service import MODELS, _get_row +from app.services.pl_quota_cert_service import MODELS, _get_row, get_cert, usage_policy ZERO = Decimal('0') CONTRACT_TYPES = {'sale', 'purchase', 'framework_sale', 'framework_purchase', 'framework'} @@ -109,6 +109,39 @@ def _contracts(db, user): user.to_user_info(), db, user.tenant_no) +def _customers(db, user): + return apply_crm_owner_scope(_active(db, user, CrmCustomer), CrmCustomer, + user.to_user_info(), db, user.tenant_no) + + +def _customer_cert_counts(db, user): + """按客户汇总购买证+运输证数量;默认排除已核销/作废,与证件列表一致。""" + parts = [] + for model in MODELS.values(): + query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no) + query = query.filter( + model.customer_id.isnot(None), + model.customer_id != '', + model.status.notin_(('closed', 'void')), + ) + parts.append(query.with_entities(model.customer_id.label('customer_id'), literal(1).label('one'))) + combined = parts[0].union_all(*parts[1:]).subquery() + return ( + db.query(combined.c.customer_id.label('customer_id'), func.count().label('cert_count')) + .group_by(combined.c.customer_id) + .subquery() + ) + + +def _customer_summary(row): + return { + 'id': row.id, + 'customerId': row.id, + 'customerNo': row.customer_no or '', + 'customerName': row.customer_name or '', + } + + def _certificates(db, user, keyword=None, cert_type=None, cert_no=None, cert_types=None): queries = [] for kind, model in MODELS.items(): @@ -351,6 +384,78 @@ def contract_statistics(db, user, contract_id): return {**_contract_summary(contract), 'certificates': result} +def list_customers(db, user, keyword=None, page=1, page_size=20, customer_no=None, customer_name=None, + cert_count_op=None, cert_count=None, cert_count_min=None, cert_count_max=None): + counts = _customer_cert_counts(db, user) + query = _customers(db, user).join(counts, counts.c.customer_id == CrmCustomer.id) + if keyword: + query = query.filter(or_( + CrmCustomer.customer_no.contains(keyword, autoescape=True), + CrmCustomer.customer_name.contains(keyword, autoescape=True), + CrmCustomer.short_name.contains(keyword, autoescape=True), + )) + if _text(customer_no): + query = query.filter(CrmCustomer.customer_no.contains(_text(customer_no), autoescape=True)) + if _text(customer_name): + query = query.filter(CrmCustomer.customer_name.contains(_text(customer_name), autoescape=True)) + for clause in _number_clauses(counts.c.cert_count, cert_count_op, cert_count, cert_count_min, cert_count_max): + query = query.filter(clause) + total = query.count() + rows = query.with_entities(CrmCustomer, counts.c.cert_count).order_by( + CrmCustomer.create_time.desc(), CrmCustomer.id + ).offset((page - 1) * page_size).limit(page_size).all() + return { + 'list': [{**_customer_summary(customer), 'certCount': int(count or 0)} for customer, count in rows], + 'total': total, + 'page': page, + 'pageSize': page_size, + } + + +def customer_statistics(db, user, customer_id): + customer = _customers(db, user).filter(CrmCustomer.id == customer_id).first() + if customer is None: + raise HTTPException(404, '客户不存在或无权访问') + certificates = [] + for kind, model in MODELS.items(): + query = apply_pl_scope(_active(db, user, model), model, user, db, user.tenant_no) + query = query.filter( + model.customer_id == customer_id, + model.status.notin_(('closed', 'void')), + ) + for row in query.order_by(model.create_time.desc(), model.id).all(): + item = get_cert(db, user, kind, row.id) + sale = item.get('sellerName') if kind == 'purchase' else item.get('shipperName') + buy = item.get('buyerName') if kind == 'purchase' else item.get('consigneeName') + certificates.append({ + 'id': row.id, + 'certId': row.id, + 'certType': kind, + 'certNo': item.get('certNo') or '', + 'productNameOnCert': item.get('productNameOnCert') or '', + 'quantityUnit': item.get('quantityUnit') or '', + 'totalQty': item.get('totalQty'), + 'reservedQty': item.get('reservedQty'), + 'usedQty': item.get('usedQty'), + 'availableQty': item.get('availableQty'), + 'status': item.get('status') or '', + 'validFrom': item.get('validFrom'), + 'validTo': item.get('validTo'), + 'salePartyName': sale or '', + 'buyPartyName': buy or '', + 'validUseText': item.get('validUseText') or '', + 'usagePolicy': item.get('usagePolicy') or usage_policy(row.valid_use_text), + 'singleUseConsumed': bool(item.get('singleUseConsumed')), + 'openingConfirmed': bool(item.get('openingConfirmed')), + 'createTime': item.get('createTime'), + }) + certificates.sort( + key=lambda row: (str(row.get('createTime') or ''), row.get('certNo') or ''), + reverse=True, + ) + return {**_customer_summary(customer), 'certificates': certificates} + + def certificate_statistics(db, user, cert_type, cert_id): _get_row(db, user, cert_type, cert_id) certs = _certificates(db, user, cert_type=cert_type) diff --git a/backend/app/services/pl_quota_usage_service.py b/backend/app/services/pl_quota_usage_service.py index d80836ec7..6324f05b6 100644 --- a/backend/app/services/pl_quota_usage_service.py +++ b/backend/app/services/pl_quota_usage_service.py @@ -16,7 +16,7 @@ from app.models.pl_models import PlPlan from app.models.crm_models import CrmContractProduct from app.schemas.pl.quota_usage import UsageManual, UsageRequest from app.services.pl_data_scope import assert_pl_row_in_scope -from app.services.pl_quota_cert_service import _audit, _get_row, usage_policy +from app.services.pl_quota_cert_service import _audit, _get_row, is_single_use_consumed, usage_policy from app.services.pl_quota_contract_service import _contract from app.services.pl_quota_allocation import (SourceDemand, PermitCandidate, ManualAllocation, Allocation, allocate_plan, settle_allocations, validate_allocation_pairing, InsufficientQuota) @@ -213,10 +213,8 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False, if kind=='transport' and (not r.vehicle_no or r.vehicle_no not in (c.vehicle_numbers_json or [])): skipped_for_vehicle=True continue - if policy=='once': - previous=query(m.PlPlanCertUsage,cert_type=kind,cert_id=pid,del_flag=0).all() - if c.opening_single_use_consumed or any(u.plan_id!=plan.id and (u.usage_status in ('reserved','in_transit','settled','exception') or (u.confirmation_snapshot or {}).get('executed')) for u in previous): - raise ValueError('一次有效证件已使用或已占用') + if policy=='once' and is_single_use_consumed(db, user, kind, c, exclude_plan_id=plan.id): + raise ValueError('一次有效证件已使用或已占用') eligible=set() if customer_rule: # 新规则已由客户+品类整笔选证;账本不再依赖合同绑证 scope。 @@ -381,15 +379,8 @@ def _settlement_cover_cert(db, user, r, plan, kind, cert): return False if kind == 'transport' and (not r.vehicle_no or r.vehicle_no not in (cert.vehicle_numbers_json or [])): return False - if policy == 'once': - previous = _query(db, user, m.PlPlanCertUsage, cert_type=kind, cert_id=cert.id, del_flag=0).all() - if cert.opening_single_use_consumed or any( - u.plan_id != plan.id and ( - u.usage_status in ('reserved', 'in_transit', 'settled', 'exception') - or (u.confirmation_snapshot or {}).get('executed') - ) for u in previous - ): - return False + if policy == 'once' and is_single_use_consumed(db, user, kind, cert, exclude_plan_id=plan.id): + return False return True diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py index dba8a8587..f7365d268 100644 --- a/backend/tests/test_pl_customer_cert_quota.py +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -463,3 +463,251 @@ def test_candidate_context_customer_rule_skips_contract_scope(monkeypatch): assert 'delivery:sp1' in sources assert scopes[(('purchase', 'buy1'), 'delivery:sp1')] == (None, None) assert candidates['purchase'][0].product_ids == frozenset({'delivery:sp1'}) + + +def test_list_certs_filters_by_customer_id(monkeypatch): + from app.services import pl_quota_cert_service as svc + + class Col: + def __init__(self, name): + self.name = name + + class CustomerCol: + def __eq__(self, other): + return ('CUSTOMER_FILTER', other) + + class FakeModel: + __tablename__ = 'pl_purchase_cert' + tenant_no = SimpleNamespace() + del_flag = 0 + customer_id = CustomerCol() + status = SimpleNamespace(notin_=lambda *a, **k: 'STATUS_FILTER') + create_time = SimpleNamespace(desc=lambda: 'DESC') + cert_no = seller_name = buyer_name = SimpleNamespace(contains=lambda *a, **k: True) + __table__ = SimpleNamespace(columns=[Col('id'), Col('status')]) + + class Query: + def __init__(self): + self.filters = [] + + def filter(self, *a, **k): + self.filters.extend(a) + return self + + def filter_by(self, **k): + return self + + def order_by(self, *a): + return self + + def all(self): + return [] + + q = Query() + db = MagicMock() + db.query.return_value = q + monkeypatch.setattr(svc, '_model', lambda t: FakeModel) + monkeypatch.setattr(svc, 'apply_pl_scope', lambda query, *a, **k: query) + monkeypatch.setattr(svc, 'get_cert', lambda *a, **k: {}) + svc.list_certs(db, SimpleNamespace(tenant_no='t'), 'purchase', customer_id='cust-1') + assert ('CUSTOMER_FILTER', 'cust-1') in q.filters + assert any(f == 'STATUS_FILTER' for f in q.filters) + + +def test_is_single_use_consumed_blocks_other_plan_usage(): + from app.services.pl_quota_cert_service import is_single_use_consumed + from app.models.pl_quota_models import PlPlanCertUsage + + cert = SimpleNamespace(id='buy1', valid_use_text='一次有效', opening_single_use_consumed=0) + usage_row = SimpleNamespace( + plan_id='other-plan', + usage_status='reserved', + confirmation_snapshot=None, + ) + + class UsageQuery: + def filter_by(self, **k): + return self + + def all(self): + return [usage_row] + + db = MagicMock() + + def q_side(model): + assert model is PlPlanCertUsage + return UsageQuery() + + db.query.side_effect = q_side + user = SimpleNamespace(tenant_no='t') + assert is_single_use_consumed(db, user, 'purchase', cert) is True + assert is_single_use_consumed(db, user, 'purchase', cert, exclude_plan_id='other-plan') is False + multi = SimpleNamespace(id='buy2', valid_use_text='多次有效', opening_single_use_consumed=1) + assert is_single_use_consumed(db, user, 'purchase', multi) is False + + +def test_whole_cover_skips_once_used_purchase_with_remaining(monkeypatch): + """一次有效证已被他计划占用时,即使余量足够也不能再选。""" + from app.services import pl_quota_customer_select as sel + from app.schemas.pl.quota_usage import UsageSource + + 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('10'), + unit='吨', + fingerprint='y' * 64, + snapshot={'customerId': 'cust1'}, + requires_transport=False, + ) + used_once = SimpleNamespace( + id='buy-once', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2026, 12, 1), + total_qty=Decimal('100'), used_qty=Decimal('10'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='一次有效', + ) + fresh = SimpleNamespace( + id='buy-fresh', customer_id='cust1', category_id='cat1', status='active', + verified_by='u', verified_time=datetime.now(), valid_from=None, valid_to=date(2027, 6, 1), + total_qty=Decimal('100'), used_qty=Decimal('0'), reserved_qty=Decimal('0'), + quantity_unit='吨', opening_single_use_consumed=0, valid_use_text='多次有效', + ) + + class PurchaseQuery: + def filter(self, *a, **k): + return self + + def order_by(self, *a, **k): + return self + + def all(self): + return [used_once, fresh] + + class LogQuery: + def filter_by(self, **k): + return self + + def first(self): + return SimpleNamespace(id='opening') + + from app.models.pl_quota_models import PlPurchaseCert, PlCertQtyLog + + 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 + user = SimpleNamespace(tenant_no='t') + monkeypatch.setattr(sel, 'category_ids_for_product', lambda db, tenant, pid: {'cat1'}) + monkeypatch.setattr( + sel, + 'is_single_use_consumed', + lambda db, user, kind, cert, **kw: cert.id == 'buy-once', + ) + purchase_ids, transport_ids, manuals = sel.select_whole_cover_certs( + db, user, [source], vehicle_no='皖A12345', on_date=date(2026, 11, 14), + ) + assert purchase_ids == ['buy-fresh'] + assert transport_ids == [] + assert manuals[0].permit_id == 'buy-fresh' + + +def test_list_customers_requires_customer_cert_counts(monkeypatch): + from app.services import pl_quota_statistics as stats + + class Q: + def __init__(self, total=1, rows=None): + self._total = total + self._rows = rows or [] + + def join(self, *a, **k): + return self + + def filter(self, *a, **k): + return self + + def count(self): + return self._total + + def with_entities(self, *a, **k): + return self + + def order_by(self, *a, **k): + return self + + def offset(self, *a, **k): + return self + + def limit(self, *a, **k): + return self + + def all(self): + return self._rows + + customer = SimpleNamespace(id='cust1', customer_no='C1', customer_name='甲客户', create_time=None) + monkeypatch.setattr(stats, '_customer_cert_counts', lambda db, user: SimpleNamespace(c=SimpleNamespace(customer_id='id', cert_count='cnt'))) + monkeypatch.setattr(stats, '_customers', lambda db, user: Q(rows=[(customer, 3)])) + result = stats.list_customers(MagicMock(), SimpleNamespace(tenant_no='t', to_user_info=lambda: {})) + assert result['total'] == 1 + assert result['list'][0]['customerName'] == '甲客户' + assert result['list'][0]['certCount'] == 3 + + +def test_get_cert_customer_name_from_crm_not_consignee(monkeypatch): + """详情/编辑用 CRM 系统客户名,不用证面收货单位。""" + from app.services import pl_quota_cert_service as svc + + class Col: + def __init__(self, name): + self.name = name + + row = SimpleNamespace( + id='t1', + cert_no='Y1', + customer_id='cust-kanghua', + consignee_name='国能宿州热电有限公司', + valid_use_text='多次有效', + total_qty=Decimal('10'), + used_qty=Decimal('0'), + reserved_qty=Decimal('0'), + __table__=SimpleNamespace( + columns=[ + Col('id'), + Col('cert_no'), + Col('customer_id'), + Col('consignee_name'), + Col('valid_use_text'), + Col('total_qty'), + Col('used_qty'), + Col('reserved_qty'), + ] + ), + ) + monkeypatch.setattr(svc, '_get_row', lambda *a, **k: row) + monkeypatch.setattr(svc, 'is_single_use_consumed', lambda *a, **k: False) + monkeypatch.setattr( + svc, '_customer_display_name', lambda *a, **k: '滁州康华电子材料有限公司' + ) + monkeypatch.setattr(svc, '_refs', lambda *a, **k: []) + monkeypatch.setattr(svc, '_has_history', lambda *a, **k: False) + monkeypatch.setattr(svc, '_transport_derived_categories', lambda *a, **k: []) + + opening = MagicMock() + opening.filter_by.return_value.first.return_value = None + db = MagicMock() + db.query.return_value = opening + + result = svc.get_cert(db, SimpleNamespace(tenant_no='t'), 'transport', 't1') + assert result['customerName'] == '滁州康华电子材料有限公司' + assert result['consigneeName'] == '国能宿州热电有限公司' + assert result['customerId'] == 'cust-kanghua' diff --git a/docs/sql/migrations/2026-11-15-rename-contract-quota-to-customer-quota.sql b/docs/sql/migrations/2026-11-15-rename-contract-quota-to-customer-quota.sql new file mode 100644 index 000000000..f78c2bcd7 --- /dev/null +++ b/docs/sql/migrations/2026-11-15-rename-contract-quota-to-customer-quota.sql @@ -0,0 +1,16 @@ +-- 合同用证 → 客户用证(菜单展示名);保留菜单 ID 与权限码。 +SET NAMES utf8mb4; +START TRANSACTION; + +UPDATE sys_menu +SET menu_name = '客户用证', update_time = NOW() +WHERE del_flag = 0 + AND ( + id = 'PL_CONTRACT_QUOTA' + OR (path = 'contract-quota' AND perms = 'pl:quota-cert:list') + OR id = 'MINI_PL_CERT_CONTRACT' + OR perms = 'mini:pl:contract-quota:list' + ) + AND menu_name = '合同用证'; + +COMMIT; diff --git a/web-admin/scripts/contract-proof-tab.test.ts b/web-admin/scripts/contract-proof-tab.test.ts index 41b7cb621..3b4aedd7e 100644 --- a/web-admin/scripts/contract-proof-tab.test.ts +++ b/web-admin/scripts/contract-proof-tab.test.ts @@ -203,13 +203,14 @@ test('real template keeps plans quota proof order and separate lazy contract sco canViewQuotaCert: true, canViewProof: true, detailActiveTab: 'proof', - detailContract: { id: 42 } + detailContract: { id: 42, customerId: 'cust-42' } } const rows = panes(ctx) assert.deepEqual( rows.map((v: any) => v.props.name), ['plans', 'quota', 'proof'] ) + assert.equal(rows[1].props.label, '客户证件与额度') assert.equal(rows[2].props.label, '运输计划合法使用证明') assert.equal(rows[2].props.lazy, '') assert.equal(panel(rows[1]), undefined) @@ -219,14 +220,19 @@ test('real template keeps plans quota proof order and separate lazy contract sco assert.equal(proof.props.active, true) assert.equal(proof.props.embedded, '') ctx.detailContract.id = 43 + ctx.detailContract.customerId = 'cust-43' ctx.currentDetailId = 43 assert.equal(panel(panes(ctx)[2]).key, 43) + ctx.detailActiveTab = 'quota' + const quota = panel(panes(ctx)[1]) + assert.equal(quota.type?.name || quota.type, 'CustomerCertQuotaPanel') + assert.equal(quota.props['customer-id'] ?? quota.props.customerId, 'cust-43') + assert.equal(quota.props.active, true) ctx.canViewQuotaCert = false assert.deepEqual( panes(ctx).map((v: any) => v.props.name), ['plans', 'proof'] ) - ctx.detailActiveTab = 'quota' assert.equal(panel(panes(ctx)[1]), undefined) }) @@ -265,7 +271,14 @@ test('mounted template unmounts old contract and never mounts quota for proof-on } }), StopDocuments: Child('proof'), - ContractQuotaPanel: Child('quota'), + CustomerCertQuotaPanel: vue.defineComponent({ + props: ['customerId', 'active'], + setup(props) { + lifecycle.push(`mount:quota:${props.customerId}`) + vue.onBeforeUnmount(() => lifecycle.push(`unmount:quota:${props.customerId}`)) + return () => vue.h('span') + } + }), PlContractLinkedPlansPanel: vue.defineComponent({ setup() { return () => vue.h('span') @@ -282,7 +295,7 @@ test('mounted template unmounts old contract and never mounts quota for proof-on canViewQuotaCert: false, canViewProof: true, detailActiveTab: 'proof', - detailContract: { id: 'A' } + detailContract: { id: 'A', customerId: 'cust-A' } }) const renderer = vue.createRenderer({ createElement: () => ({ children: [] }), diff --git a/web-admin/scripts/customer-cert-quota-panel.test.ts b/web-admin/scripts/customer-cert-quota-panel.test.ts new file mode 100644 index 000000000..ff5d290ac --- /dev/null +++ b/web-admin/scripts/customer-cert-quota-panel.test.ts @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +test('web contract detail embeds customer cert panel and renamed tab', () => { + const source = readFileSync( + new URL('../src/views/crm/contract/components/ContractDetailDialog.vue', import.meta.url), + 'utf8' + ) + assert.match(source, /label="客户证件与额度"/) + assert.match(source, /CustomerCertQuotaPanel/) + assert.doesNotMatch(source, /ContractQuotaPanel/) + assert.match(source, /:customer-id="detailContract\.customerId"/) +}) + +test('h5 contract detail includes customer cert tab', () => { + const source = readFileSync( + new URL('../src/views/m/crm/contract/detail.vue', import.meta.url), + 'utf8' + ) + assert.match(source, /CustomerCertQuotaTab/) + assert.match(source, /客户证件与额度/) + assert.match(source, /canViewQuotaCert/) + assert.match(source, /:customer-id="contract\.customerId"/) +}) + +test('customer cert panel lists by customerId without contract bindings', () => { + const source = readFileSync( + new URL('../src/views/pl/contract-quota/CustomerCertQuotaPanel.vue', import.meta.url), + 'utf8' + ) + assert.match(source, /quotaCertApi\.list\('purchase', \{ customerId \}\)/) + assert.match(source, /quotaCertApi\.list\('transport', \{ customerId \}\)/) + assert.match(source, /有效次数/) + assert.match(source, /singleUseConsumed/) + assert.doesNotMatch(source, /saveBindings|openCreate|bindings\(/) +}) + +test('standalone contract-quota page is customer quota list', () => { + const panel = readFileSync( + new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url), + 'utf8' + ) + const wrapper = readFileSync( + new URL('../src/views/pl/contract-quota/ContractQuotaPanel.vue', import.meta.url), + 'utf8' + ) + const router = readFileSync(new URL('../src/router/modules/pl.ts', import.meta.url), 'utf8') + assert.match(panel, /客户用证/) + assert.match(panel, /quotaStatisticsApi\.customers/) + assert.match(wrapper, /CustomerQuotaPanel/) + assert.match(router, /title: '客户用证'/) +}) + +test('web customer quota panel uses toQueryParams from useServerColumnFilters', () => { + const panel = readFileSync( + new URL('../src/views/pl/contract-quota/CustomerQuotaPanel.vue', import.meta.url), + 'utf8' + ) + assert.match(panel, /toQueryParams/) + assert.match(panel, /\.\.\.toQueryParams\(\)/) + assert.doesNotMatch(panel, /\bbuildQuery\b/) + assert.doesNotMatch(panel, /\bresetFilters\b/) + assert.match(panel, /clearAllFilters/) + assert.match(panel, /watchVisibleColumns/) +}) + +test('h5 customer quota tab does not call buildQuery', () => { + const tab = readFileSync( + new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url), + 'utf8' + ) + assert.doesNotMatch(tab, /\bbuildQuery\b/) + assert.match(tab, /quotaStatisticsApi\.customers/) +}) diff --git a/web-admin/scripts/mobile-cert-ready-h5.test.ts b/web-admin/scripts/mobile-cert-ready-h5.test.ts index 190aa7956..86f7e25b1 100644 --- a/web-admin/scripts/mobile-cert-ready-h5.test.ts +++ b/web-admin/scripts/mobile-cert-ready-h5.test.ts @@ -7,7 +7,11 @@ const readyTab = readFileSync( 'utf8' ) const contractTab = readFileSync( - new URL('../src/views/m/pl/plan-cert/ContractTab.vue', import.meta.url), + new URL('../src/views/m/pl/plan-cert/CustomerTab.vue', import.meta.url), + 'utf8' +) +const planCertIndex = readFileSync( + new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url), 'utf8' ) const archiveForm = readFileSync( @@ -30,9 +34,11 @@ test('H5 证书齐备对齐 2.0:待完善列表与完善并关闭,且文案 assert.doesNotMatch(readyTab, /也不扣减额度/) }) -test('H5 合同用证页对新规则给出只读提示', () => { - assert.match(contractTab, /客户\+证用品类/) - assert.match(contractTab, /仅供历史查看/) +test('H5 客户用证按客户列表展示证件', () => { + assert.match(contractTab, /quotaStatisticsApi\.customers/) + assert.match(contractTab, /\/m\/pl\/plan-cert\/customer\//) + assert.match(contractTab, /暂无客户用证数据/) + assert.match(planCertIndex, /title: '客户用证'/) }) test('H5 建档页使用关联购买证且无证面引用残留', () => { diff --git a/web-admin/scripts/quota-statistics.test.ts b/web-admin/scripts/quota-statistics.test.ts index 9cd2f2329..c89639d25 100644 --- a/web-admin/scripts/quota-statistics.test.ts +++ b/web-admin/scripts/quota-statistics.test.ts @@ -105,6 +105,9 @@ function harness(fixed?: string) { useServerColumnFilters, createStatisticsLoader, quotaStatisticsApi: api, + certQuotaRuleApi: { + get: async () => ({ configured: false }) + }, onBeforeUnmount: (fn: Function) => unmount.push(fn) } const src = ts.createSourceFile( @@ -154,16 +157,21 @@ test('cert tabs keep their identities and legal proof is a sibling menu', () => assert.equal(legal.meta!.title, '合法使用证明') assert.deepEqual( group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path), - ['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready'] + ['quota-cert', 'cert-category', 'contract-quota', 'certificate-usage', 'plan-cert', 'cert-ready'] ) assert.equal(group.children![0].id, 'PL_QUOTA_CERT') - assert.equal(group.children![3].id, 'PL00000000000000000000000000015') + assert.equal(group.children!.find((r) => r.path === 'contract-quota')?.meta?.title, '客户用证') + assert.equal( + group.children!.find((r) => r.path === 'plan-cert')?.id, + 'PL00000000000000000000000000015' + ) assert.deepEqual( group.children!.filter((r) => !r.meta?.isHide).map((r) => r.meta!.menuCode), [ 'pl:quota-cert:list', 'pl:quota-cert:list', 'pl:quota-cert:list', + 'pl:quota-cert:list', 'pl:plan-cert:list', 'pl:cert-ready:list' ] @@ -648,71 +656,58 @@ function compiledSfc(path: string, dependencies: Record, inlineTemp ) return new Function(...Object.keys(env), `${code}; return component`)(...Object.values(env)) } -for (const wrapper of [ - 'contract-quota/ContractQuotaPanel.vue', - 'certificate-usage/CertificateUsagePanel.vue' -]) { - for (const active of [undefined, false]) - test(`compiled ${wrapper} ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => { - const calls: string[] = [] - const dependencies = { - useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }), - useRouter: () => ({ push() {}, replace() {} }), - fetchGetMenuCodes: async () => { - calls.push('permissions') - return { all: true } - }, - quotaStatisticsApi: { - contracts: async () => { - calls.push('contracts') - return { list: [], total: 0 } - }, - certificates: async () => { - calls.push('certificates') - return { list: [], total: 0 } - } - }, - usePersistedListColumns, - useServerColumnFilters, - createStatisticsLoader, - useElementSize: () => ({ width: vue.ref(1120) }), - useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), - statusLabel: statisticsLabels.statusLabel, - contractTypeLabel: statisticsLabels.contractTypeLabel - } - // Compile the real inner setup too; only its visual child tree is omitted here. - const StatisticsPanel = compiledSfc( - '../src/views/pl/quota-statistics/StatisticsPanel.vue', - dependencies, - false - ) - StatisticsPanel.render = () => null - const Wrapper = compiledSfc(`../src/views/pl/${wrapper}`, { StatisticsPanel }) - const renderer = vue.createRenderer({ - createElement: () => ({}), - createText: () => ({}), - createComment: () => ({}), - setText: () => {}, - setElementText: () => {}, - parentNode: () => null, - nextSibling: () => null, - insert: () => {}, - remove: () => {}, - patchProp: () => {} - }) - const app = renderer.createApp(Wrapper, active === undefined ? {} : { active }) - app.mount({}) - await flush() - await vue.nextTick() - assert.deepEqual( - calls, - active === false - ? [] - : ['permissions', wrapper.startsWith('contract') ? 'contracts' : 'certificates'] - ) - app.unmount() +for (const active of [undefined, false]) + test(`compiled certificate-usage/CertificateUsagePanel.vue ${active === false ? 'inactive skips requests' : 'defaults to active and loads'}`, async () => { + const calls: string[] = [] + const dependencies = { + useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }), + useRouter: () => ({ push() {}, replace() {} }), + fetchGetMenuCodes: async () => { + calls.push('permissions') + return { all: true } + }, + quotaStatisticsApi: { + certificates: async () => { + calls.push('certificates') + return { list: [], total: 0 } + } + }, + usePersistedListColumns, + useServerColumnFilters, + createStatisticsLoader, + useElementSize: () => ({ width: vue.ref(1120) }), + useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }), + statusLabel: statisticsLabels.statusLabel, + contractTypeLabel: statisticsLabels.contractTypeLabel + } + const StatisticsPanel = compiledSfc( + '../src/views/pl/quota-statistics/StatisticsPanel.vue', + dependencies, + false + ) + StatisticsPanel.render = () => null + const Wrapper = compiledSfc('../src/views/pl/certificate-usage/CertificateUsagePanel.vue', { + StatisticsPanel }) -} + const renderer = vue.createRenderer({ + createElement: () => ({}), + createText: () => ({}), + createComment: () => ({}), + setText: () => {}, + setElementText: () => {}, + parentNode: () => null, + nextSibling: () => null, + insert: () => {}, + remove: () => {}, + patchProp: () => {} + }) + const app = renderer.createApp(Wrapper, active === undefined ? {} : { active }) + app.mount({}) + await flush() + await vue.nextTick() + assert.deepEqual(calls, active === false ? [] : ['permissions', 'certificates']) + app.unmount() + }) test('expanded content follows the table viewport width and keeps alignment local', () => { const h = harness() diff --git a/web-admin/src/App.vue b/web-admin/src/App.vue index 148952776..a90e76e8f 100644 --- a/web-admin/src/App.vue +++ b/web-admin/src/App.vue @@ -111,7 +111,7 @@ const panelTitleClassList = [...panelTitleIconByText.map(([, cls]) => cls), 'auto-title-list'] const panelTitleBarClass = 'title-bar-heading' - const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|证件用量|计划段次用证|证书齐备|合同证件与额度|运输证件规则)$/] + const panelTitleBarTextRules = [/^(购买证与运输证|合法使用证明|计划用证|证件建档|证用品类|合同用证|客户用证|证件用量|计划段次用证|证书齐备|合同证件与额度|客户证件与额度|运输证件规则)$/] const panelTitleHeadingSelector = '.page-container .panel-top h2, .page-container .panel-top h3, .page-container .storage-content-header h3, .page-container .storage-aside__head h2, .page-container .user-dept-aside__head h2, .console-container .panel-top h2' diff --git a/web-admin/src/api/pl/quota-cert.ts b/web-admin/src/api/pl/quota-cert.ts index f775be08a..96b241b49 100644 --- a/web-admin/src/api/pl/quota-cert.ts +++ b/web-admin/src/api/pl/quota-cert.ts @@ -61,6 +61,7 @@ export interface QuotaCert extends Omit { verifiedTime?: string | null openingConfirmed: boolean usagePolicy: 'once' | 'multiple' | 'needs_confirmation' + singleUseConsumed?: boolean createTime?: string certType?: CertType derivedCategoryIds?: string[] @@ -132,8 +133,10 @@ export interface LegalDocument { } const base = '/transport/quota-certs' export const quotaCertApi = { - list: (kind: CertType, params: { keyword?: string; status?: string } = {}) => - request.get(`${base}/${kind}`, { params }).then(pickBxData), + list: ( + kind: CertType, + params: { keyword?: string; status?: string; customerId?: string } = {} + ) => request.get(`${base}/${kind}`, { params }).then(pickBxData), get: (kind: CertType, id: string) => request.get(`${base}/${kind}/${id}`).then(pickBxData), save: (kind: CertType, body: CertBody, id?: string) => @@ -504,6 +507,13 @@ export interface StatisticsPage { export interface ContractStatisticsSummary extends ContractOption { certCount: number } +export interface CustomerStatisticsSummary { + id: string + customerId: string + customerNo: string + customerName: string + certCount: number +} export interface CertificateStatisticsSummary { id: string certId: string @@ -522,6 +532,14 @@ export interface CertificateStatisticsSummary { buyPartyName?: string | null validUseText?: string | null } +export interface CustomerStatistics extends CustomerStatisticsSummary { + certificates: (CertificateStatisticsSummary & { + usagePolicy?: QuotaCert['usagePolicy'] + singleUseConsumed?: boolean + openingConfirmed?: boolean + createTime?: string + })[] +} export interface StatisticsProduct { id: string contractProductId: string @@ -579,6 +597,8 @@ export interface StatisticsQuery { contractNo?: string contractName?: string contractTypeIn?: string + customerNo?: string + customerName?: string certCountOp?: string certCount?: string certCountMin?: string @@ -622,12 +642,18 @@ export const quotaStatisticsApi = { request .get(`${base}/statistics/contracts`, { params }) .then(pickBxData>), + customers: (params: StatisticsQuery) => + request + .get(`${base}/statistics/customers`, { params }) + .then(pickBxData>), certificates: (params: StatisticsQuery) => request .get(`${base}/statistics/certificates`, { params }) .then(pickBxData>), contract: (id: string) => request.get(`${base}/contracts/${id}/statistics`).then(pickBxData), + customer: (id: string) => + request.get(`${base}/customers/${id}/statistics`).then(pickBxData), certificate: (kind: CertType, id: string) => request.get(`${base}/${kind}/${id}/statistics`).then(pickBxData) } diff --git a/web-admin/src/layouts/MpWebviewShell.vue b/web-admin/src/layouts/MpWebviewShell.vue index 6c5f27bba..c46c20422 100644 --- a/web-admin/src/layouts/MpWebviewShell.vue +++ b/web-admin/src/layouts/MpWebviewShell.vue @@ -133,7 +133,7 @@ mode: 'list', title: '运输证件管理', search: '证件编号 / 品名', - tabs: ['证件建档', '合同用证', '证件用量', '计划段次用证', '证书齐备'], + tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'], filters: [], caption: '正在加载运输证件管理' } diff --git a/web-admin/src/router/modules/pl.ts b/web-admin/src/router/modules/pl.ts index 248449c7e..b326ad2d0 100644 --- a/web-admin/src/router/modules/pl.ts +++ b/web-admin/src/router/modules/pl.ts @@ -80,7 +80,7 @@ export const plRoutes: AppRouteRecord[] = [ name: 'PlContractQuota', path: 'contract-quota', component: '/pl/contract-quota/index', - meta: { title: '合同用证', keepAlive: false, menuCode: 'pl:quota-cert:list' } + meta: { title: '客户用证', keepAlive: false, menuCode: 'pl:quota-cert:list' } }, { id: 'PL_CERTIFICATE_USAGE', diff --git a/web-admin/src/router/routes/staticRoutes.ts b/web-admin/src/router/routes/staticRoutes.ts index e7091cb5a..ab1c05af8 100644 --- a/web-admin/src/router/routes/staticRoutes.ts +++ b/web-admin/src/router/routes/staticRoutes.ts @@ -171,11 +171,17 @@ export const staticRoutes: AppRouteRecordRaw[] = [ component: () => import('@views/m/pl/plan-cert/archive-detail.vue'), meta: { title: '证件建档', isHideTab: true, isMpWebview: true } }, + { + path: '/m/pl/plan-cert/customer/:id', + name: 'MpPlPlanCertCustomer', + component: () => import('@views/m/pl/plan-cert/customer-detail.vue'), + meta: { title: '客户用证', isHideTab: true, isMpWebview: true } + }, { path: '/m/pl/plan-cert/contract/:id', name: 'MpPlPlanCertContract', - component: () => import('@views/m/pl/plan-cert/contract-detail.vue'), - meta: { title: '合同用证', isHideTab: true, isMpWebview: true } + redirect: { name: 'MpPlPlanCert', query: { tab: 'contract' } }, + meta: { title: '客户用证', isHideTab: true, isMpWebview: true } }, { path: '/m/pl/plan-cert/usage/:kind/:id', diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 2b1901180..48263bff8 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -504,13 +504,12 @@ /> - - + @@ -1060,7 +1059,7 @@ import UploadDialog from './UploadDialog.vue'; import ContractFormDialog from './ContractFormDialog.vue'; import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'; import type { ViewField } from '@/components/business/system-view-dialog/types'; -import ContractQuotaPanel from '@/views/pl/contract-quota/ContractQuotaPanel.vue'; +import CustomerCertQuotaPanel from '@/views/pl/contract-quota/CustomerCertQuotaPanel.vue'; import StopDocuments from '@/views/pl/legal-use-document/StopDocuments.vue'; import PlContractLinkedPlansPanel from '@/components/business/pl-contract-linked-plans-panel/index.vue'; import { diff --git a/web-admin/src/views/m/crm/contract/CustomerCertQuotaTab.vue b/web-admin/src/views/m/crm/contract/CustomerCertQuotaTab.vue new file mode 100644 index 000000000..115c294ab --- /dev/null +++ b/web-admin/src/views/m/crm/contract/CustomerCertQuotaTab.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/web-admin/src/views/m/crm/contract/detail.vue b/web-admin/src/views/m/crm/contract/detail.vue index 355b47fa1..9bac82775 100644 --- a/web-admin/src/views/m/crm/contract/detail.vue +++ b/web-admin/src/views/m/crm/contract/detail.vue @@ -342,6 +342,19 @@ +
+
+

客户证件与额度

+ +

无证件查看权限

+
+
+

运输计划

@@ -933,6 +946,8 @@ } from '@/utils/mp-webview' import { useUserStore } from '@/store/modules/user' import { useDictStore } from '@/store/modules/dict' + import { fetchGetMenuCodes } from '@/api/auth' + import CustomerCertQuotaTab from './CustomerCertQuotaTab.vue' import '@/styles/mp-webview.scss' import '../customer/customer.scss' @@ -970,6 +985,7 @@ type DetailTabKey = | 'contract' | 'plans' + | 'quota' | 'payment' | 'invoice' | 'approval' @@ -979,6 +995,7 @@ const acting = ref(false) const errorText = ref('') const activeTab = ref('contract') + const canViewQuotaCert = ref(false) const detailBody = ref() const partiesExpanded = ref(false) const approverPickerOpen = ref(false) @@ -1056,14 +1073,34 @@ } } - const detailTabs: Array<{ key: DetailTabKey; label: string }> = [ - { key: 'contract', label: '合同信息' }, - { key: 'plans', label: '运输计划' }, - { key: 'payment', label: '回款记录' }, - { key: 'invoice', label: '开票记录' }, - { key: 'approval', label: '审批流程' }, - { key: 'operation', label: '操作记录' } - ] + const detailTabs = computed(() => { + const tabs: Array<{ key: DetailTabKey; label: string }> = [ + { key: 'contract', label: '合同信息' }, + { key: 'plans', label: '运输计划' } + ] + if (canViewQuotaCert.value) tabs.push({ key: 'quota', label: '客户证件与额度' }) + tabs.push( + { key: 'payment', label: '回款记录' }, + { key: 'invoice', label: '开票记录' }, + { key: 'approval', label: '审批流程' }, + { key: 'operation', label: '操作记录' } + ) + return tabs + }) + + async function refreshQuotaCertPermission() { + try { + const permissions = await fetchGetMenuCodes() + const codes = permissions?.menuCodes || [] + canViewQuotaCert.value = + !!permissions?.all || !!permissions?.isSuperAdmin || codes.includes('pl:quota-cert:list') + } catch { + canViewQuotaCert.value = false + } + if (!canViewQuotaCert.value && activeTab.value === 'quota') { + activeTab.value = 'contract' + } + } const unreceivedAmount = computed(() => { const amount = Number(contract.value?.amount) || 0 @@ -1573,7 +1610,7 @@ } loading.value = true try { - const res = await getContract(id) + const [res] = await Promise.all([getContract(id), refreshQuotaCertPermission()]) if (!isCurrentLoad(requestSeq, id)) return const data = ((res as any)?.data ?? res) as ContractVO if (String(data?.id || '') !== id) throw new Error('合同不存在或无权查看') diff --git a/web-admin/src/views/m/pl/plan-cert/ContractTab.vue b/web-admin/src/views/m/pl/plan-cert/ContractTab.vue index e62b8e6ed..444246e62 100644 --- a/web-admin/src/views/m/pl/plan-cert/ContractTab.vue +++ b/web-admin/src/views/m/pl/plan-cert/ContractTab.vue @@ -1,144 +1,7 @@ - - diff --git a/web-admin/src/views/m/pl/plan-cert/CustomerTab.vue b/web-admin/src/views/m/pl/plan-cert/CustomerTab.vue new file mode 100644 index 000000000..768d40771 --- /dev/null +++ b/web-admin/src/views/m/pl/plan-cert/CustomerTab.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/web-admin/src/views/m/pl/plan-cert/archive-form.vue b/web-admin/src/views/m/pl/plan-cert/archive-form.vue index 1adebd849..7d1a60268 100644 --- a/web-admin/src/views/m/pl/plan-cert/archive-form.vue +++ b/web-admin/src/views/m/pl/plan-cert/archive-form.vue @@ -1201,19 +1201,20 @@ return } const certRecord = (cert || {}) as Record - let name = - String(certRecord.customerName || '').trim() || - systemCustomer.value.customerName || - customerOptions.value.find((item) => item.id === id)?.customerName || - '' + let name = '' + try { + const detail = (await fetchCustomerDetail({ id })) as Record + const data = detail?.data || detail + name = String(data?.customerName || '').trim() + } catch { + name = '' + } + // 系统客户以 CRM 为准;禁止用证面收货单位等缓存名顶替。 if (!name) { - try { - const detail = (await fetchCustomerDetail({ id })) as Record - const data = detail?.data || detail - name = String(data?.customerName || '').trim() || id - } catch { - name = id - } + name = + String(certRecord.customerName || '').trim() || + customerOptions.value.find((item) => item.id === id)?.customerName || + id } systemCustomer.value = { id, customerName: name } form.value.customerId = id diff --git a/web-admin/src/views/m/pl/plan-cert/customer-detail.vue b/web-admin/src/views/m/pl/plan-cert/customer-detail.vue new file mode 100644 index 000000000..009091c15 --- /dev/null +++ b/web-admin/src/views/m/pl/plan-cert/customer-detail.vue @@ -0,0 +1,83 @@ + + + diff --git a/web-admin/src/views/m/pl/plan-cert/face-model.ts b/web-admin/src/views/m/pl/plan-cert/face-model.ts index c62c478c3..950cd2f6a 100644 --- a/web-admin/src/views/m/pl/plan-cert/face-model.ts +++ b/web-admin/src/views/m/pl/plan-cert/face-model.ts @@ -44,7 +44,16 @@ export function certFaceGroups( : display(cert, field[0]), wide: longFaceKeys.has(field[0]) || field[0] === 'vehicleNumbersJson' })) - if (index === 0) fields.unshift({ label: '状态', value: display(cert, 'status') }) + if (index === 0) { + fields.unshift( + { label: '状态', value: display(cert, 'status') }, + { + label: '系统客户', + value: String(cert.customerName || '').trim() || '—', + wide: true + } + ) + } return { title: group.title, fields } }) groups[groups.length - 1].fields.push({ diff --git a/web-admin/src/views/m/pl/plan-cert/index.vue b/web-admin/src/views/m/pl/plan-cert/index.vue index f00f0954c..a7264fb64 100644 --- a/web-admin/src/views/m/pl/plan-cert/index.vue +++ b/web-admin/src/views/m/pl/plan-cert/index.vue @@ -183,7 +183,7 @@ const committed = ref('') const CERT_TABS = [ { name: 'archive', title: '证件建档', perm: 'mini:pl:quota-cert:list' }, - { name: 'contract', title: '合同用证', perm: 'mini:pl:contract-quota:list' }, + { name: 'contract', title: '客户用证', perm: 'mini:pl:contract-quota:list' }, { name: 'usage', title: '证件用量', perm: 'mini:pl:certificate-usage:list' }, { name: 'plan', title: '计划段次用证', perm: 'mini:pl:plan-cert:segment' }, { name: 'ready', title: '证书齐备', perm: 'mini:pl:cert-ready:list' } diff --git a/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue b/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue index 60c7393c2..5600a5243 100644 --- a/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue +++ b/web-admin/src/views/pl/contract-quota/ContractQuotaPanel.vue @@ -1,8 +1,8 @@ - + diff --git a/web-admin/src/views/pl/contract-quota/CustomerCertQuotaPanel.vue b/web-admin/src/views/pl/contract-quota/CustomerCertQuotaPanel.vue new file mode 100644 index 000000000..525dbfca1 --- /dev/null +++ b/web-admin/src/views/pl/contract-quota/CustomerCertQuotaPanel.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/web-admin/src/views/pl/contract-quota/CustomerQuotaPanel.vue b/web-admin/src/views/pl/contract-quota/CustomerQuotaPanel.vue new file mode 100644 index 000000000..c55401aed --- /dev/null +++ b/web-admin/src/views/pl/contract-quota/CustomerQuotaPanel.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/web-admin/src/views/pl/quota-cert/index.vue b/web-admin/src/views/pl/quota-cert/index.vue index 0104d9925..860192551 100644 --- a/web-admin/src/views/pl/quota-cert/index.vue +++ b/web-admin/src/views/pl/quota-cert/index.vue @@ -666,7 +666,7 @@ type ContractOption, type ScopeProduct } from '@/api/pl/quota-cert' - import { fetchCustomerOptions } from '@/api/crm/customer' + import { fetchCustomerDetail, fetchCustomerOptions } from '@/api/crm/customer' import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue' import CertContractBinder from './CertContractBinder.vue' import { @@ -761,28 +761,32 @@ async function ensureCustomerOption(id?: string | null, nameHint = '') { if (!id) return - if (customerOptions.value.some((c) => c.id === id)) return - if (nameHint) { - customerOptions.value = [{ id, customerName: nameHint }, ...customerOptions.value] - return - } + let name = '' try { - const result = await fetchCustomerOptions({ keyword: '', limit: 200 }) - const list = Array.isArray(result) - ? result - : (result as any)?.list || (result as any)?.data || [] - const hit = (list || []).find((row: any) => String(row.id) === id) - if (hit) { - customerOptions.value = [ - { id, customerName: String(hit.customerName || hit.name || id) }, - ...customerOptions.value - ] - } else { - customerOptions.value = [{ id, customerName: id }, ...customerOptions.value] - } + const detail = (await fetchCustomerDetail({ id })) as Record + const data = detail?.data || detail + name = String(data?.customerName || data?.name || '').trim() } catch { - customerOptions.value = [{ id, customerName: id }, ...customerOptions.value] + name = '' } + if (!name) { + try { + const result = await fetchCustomerOptions({ keyword: nameHint || '', limit: 200 }) + const list = Array.isArray(result) + ? result + : (result as any)?.list || (result as any)?.data || [] + const hit = (list || []).find((row: any) => String(row.id) === id) + name = String(hit?.customerName || hit?.name || '').trim() + } catch { + name = '' + } + } + // 系统客户名称以 CRM 为准;nameHint 仅作 CRM 不可用时的兜底(应为接口返回的 customerName)。 + const label = name || String(nameHint || '').trim() || id + customerOptions.value = [ + { id, customerName: label }, + ...customerOptions.value.filter((c) => c.id !== id) + ] } async function loadCategories() { @@ -1048,8 +1052,17 @@ value: statusLabels[detail.value.status], tagType: tagType(detail.value.status) } + const systemCustomerField: ViewField = { + label: '系统客户', + value: String(detail.value.customerName || '').trim() || '—', + span: 'full' + } const groups = certFormGroups(kind.value).map((group, index) => - detailGroup(group.title, group.fields, index === 0 ? [statusField] : []) + detailGroup( + group.title, + group.fields, + index === 0 ? [statusField, systemCustomerField] : [] + ) ) groups[groups.length - 1].fields.push({ label: '系统备注', @@ -1424,7 +1437,7 @@ applyCertForm(targetKind, cert) await ensureCustomerOption( form.value.customerId, - formKind.value === 'purchase' ? form.value.buyerName : form.value.consigneeName + String((cert as QuotaCert).customerName || '').trim() ) if (targetKind === 'transport') purchases.value = purchaseOptions() } catch (error) {