Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf47a26ba0 |
No files matched your search
@@ -87,9 +87,6 @@ def _transport_derived_categories(db, user, transport_cert_id):
|
||||
|
||||
def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=current)
|
||||
# 存量未期初确认的证件在读取时自动补期初0并启用(void/closed 除外)。
|
||||
if not current:
|
||||
row = ensure_zero_opening(db, user, cert_type, cert_id, row=row)
|
||||
result = _row_to_dict(row)
|
||||
result['usagePolicy'] = usage_policy(row.valid_use_text)
|
||||
opening_query = db.query(PlCertQtyLog.id).filter_by(
|
||||
@@ -101,9 +98,7 @@ def get_cert(db, user, cert_type, cert_id, current=False):
|
||||
result['availableQty'] = row.total_qty - row.used_qty - row.reserved_qty
|
||||
if cert_type == 'transport':
|
||||
result['purchaseRefs'] = [_row_to_dict(r) for r in _refs(db, user, cert_id, lock=current)]
|
||||
result['referencesEditable'] = not _has_usage_beyond_zero_opening(
|
||||
db, user, cert_type, cert_id, lock=current
|
||||
)
|
||||
result['referencesEditable'] = not _has_history(db, user, cert_type, cert_id, lock=current)
|
||||
result['derivedCategoryIds'] = _transport_derived_categories(db, user, cert_id)
|
||||
return result
|
||||
|
||||
@@ -174,11 +169,8 @@ def _refs_keep_verified(pending, removed):
|
||||
|
||||
|
||||
def _has_history(db, user, cert_type, cert_id, lock=True):
|
||||
"""任意额度流水或计划占用均视为已有历史(含自动期初),用于锁证号/核定量等身份。"""
|
||||
for model in (PlCertQtyLog, PlPlanCertUsage):
|
||||
query = db.query(model.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
query = db.query(model.id).filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
if query.first() is not None:
|
||||
@@ -186,35 +178,6 @@ def _has_history(db, user, cert_type, cert_id, lock=True):
|
||||
return False
|
||||
|
||||
|
||||
def _has_usage_beyond_zero_opening(db, user, cert_type, cert_id, lock=True):
|
||||
"""真实用量/占用才锁运输证引用;期初已用=0 的 opening 仍可补全关联购买证。"""
|
||||
usage_q = db.query(PlPlanCertUsage.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
if lock:
|
||||
usage_q = usage_q.with_for_update()
|
||||
if usage_q.first() is not None:
|
||||
return True
|
||||
log_q = db.query(PlCertQtyLog).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
)
|
||||
if lock:
|
||||
log_q = log_q.with_for_update()
|
||||
for log in log_q.all():
|
||||
snap = log.event_snapshot or {}
|
||||
# 仅建档自动期初(已用0)不锁引用;手工期初或其它流水仍锁。
|
||||
if (
|
||||
log.event_type == 'opening'
|
||||
and snap.get('auto')
|
||||
and (log.used_delta or 0) == 0
|
||||
and (log.reserved_delta or 0) == 0
|
||||
and (log.total_delta or 0) == 0
|
||||
):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_refs(db, user, cert_id, submitted, historical, purchases, current=False):
|
||||
if submitted is None:
|
||||
raise HTTPException(400, '引用集合不可为null;清空请提交[]')
|
||||
@@ -319,8 +282,7 @@ def prepare_cert(db, user, cert_type, data, cert_id=None, *, locked=None, effect
|
||||
if duplicate and duplicate.id != cert_id:
|
||||
raise HTTPException(400, '证号已存在,删除后也不得重建额度池')
|
||||
effective_id = cert_id or effective_id or str(uuid4())
|
||||
refs_locked = bool(row and _has_usage_beyond_zero_opening(db, user, cert_type, cert_id))
|
||||
pending, removed = _validate_refs(db,user,effective_id,refs,refs_locked,purchases,current=bool(cert_id)) if refs_present else ([],[])
|
||||
pending, removed = _validate_refs(db,user,effective_id,refs,historical,purchases,current=bool(cert_id)) if refs_present else ([],[])
|
||||
changed = {key for key, value in values.items() if row is None or getattr(row, key) != value}
|
||||
refs_changed = bool(removed) or any(previous is None or previous.del_flag == 1 or any(getattr(previous, key) != value for key, value in item.items() if key != 'id') for previous, item in pending)
|
||||
return dict(model=model, row=row, values=values, cert_id=cert_id, effective_id=effective_id,
|
||||
@@ -367,98 +329,19 @@ def apply_cert(db, user, prepared):
|
||||
return row
|
||||
|
||||
|
||||
def ensure_zero_opening(db, user, cert_type, cert_id, *, row=None):
|
||||
"""建档后无需手工期初确认:缺 opening 流水时写入期初已用=0;draft 直接启用。"""
|
||||
if row is None:
|
||||
row = _get_row(db, user, cert_type, cert_id)
|
||||
if row.status in ('void', 'closed'):
|
||||
return row
|
||||
existing = db.query(PlCertQtyLog.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
|
||||
).first()
|
||||
if existing:
|
||||
return row
|
||||
row = _get_row(db, user, cert_type, cert_id, lock=True)
|
||||
if row.status in ('void', 'closed'):
|
||||
return row
|
||||
if db.query(PlCertQtyLog.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, event_type='opening', del_flag=0,
|
||||
).with_for_update().first():
|
||||
return row
|
||||
# 有效次数原文未识别时保留 draft,不自动启用;明确一次/多次后保存或读取再启用。
|
||||
if usage_policy(row.valid_use_text) == 'needs_confirmation':
|
||||
return row
|
||||
used = Decimal(row.used_qty or 0)
|
||||
op_id = f'auto-opening:{cert_type}:{cert_id}'
|
||||
digest = hashlib.sha256(op_id.encode()).hexdigest()
|
||||
operation, replay = _claim_operation(db, user, 'opening', op_id, digest)
|
||||
if replay:
|
||||
return row
|
||||
db.add(PlCertQtyLog(
|
||||
**_audit(user),
|
||||
operation_id=op_id,
|
||||
request_hash=digest,
|
||||
cert_type=cert_type,
|
||||
cert_id=cert_id,
|
||||
event_type='opening',
|
||||
quantity_unit=row.quantity_unit,
|
||||
total_delta=Decimal(0),
|
||||
reserved_delta=Decimal(0),
|
||||
used_delta=used,
|
||||
before_total_qty=row.total_qty,
|
||||
after_total_qty=row.total_qty,
|
||||
before_reserved_qty=row.reserved_qty,
|
||||
after_reserved_qty=row.reserved_qty,
|
||||
before_used_qty=used,
|
||||
after_used_qty=used,
|
||||
reason='建档自动启用',
|
||||
evidence_urls=None,
|
||||
event_snapshot={'singleUseConsumed': False, 'sourceNote': 'auto', 'auto': True},
|
||||
))
|
||||
if row.status == 'draft':
|
||||
row.status = 'active'
|
||||
row.verified_by = user.id
|
||||
row.verified_time = datetime.now()
|
||||
row.update_by = user.id
|
||||
operation.result_json = {'certType': cert_type, 'certId': cert_id, 'auto': True}
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
def save_cert(db, user, cert_type, data, cert_id=None):
|
||||
row = apply_cert(db, user, prepare_cert(db, user, cert_type, data, cert_id))
|
||||
ensure_zero_opening(db, user, cert_type, row.id, row=row)
|
||||
return get_cert(db, user, cert_type, row.id)
|
||||
|
||||
|
||||
def delete_cert(db, user, cert_type, cert_id):
|
||||
row = _get_row(db,user,cert_type,cert_id,lock=True)
|
||||
if row.reserved_qty or row.used_qty:
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
if db.query(PlPlanCertUsage.id).filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0,
|
||||
).with_for_update().first():
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
logs = (
|
||||
db.query(PlCertQtyLog)
|
||||
.filter_by(tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id, del_flag=0)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
# 仅建档自动期初(或无流水)仍允许删除;手工期初或其它额度流水则禁止。
|
||||
def _auto_opening(log):
|
||||
snap = log.event_snapshot or {}
|
||||
return log.event_type == 'opening' and bool(snap.get('auto')) and (log.used_delta or 0) == 0
|
||||
|
||||
if any(not _auto_opening(log) for log in logs):
|
||||
if _has_history(db,user,cert_type,cert_id) or row.reserved_qty or row.used_qty:
|
||||
raise HTTPException(400, '已有用量或流水的证件只能停用、核销或作废')
|
||||
if db.query(PlContractCertLink.id).filter_by(tenant_no=user.tenant_no,cert_type=cert_type,cert_id=cert_id,del_flag=0).with_for_update().first():
|
||||
raise HTTPException(400, '证件已绑定合同')
|
||||
if cert_type == 'purchase' and db.query(PlTransportCertPurchaseRef.id).filter_by(tenant_no=user.tenant_no,purchase_cert_id=cert_id,del_flag=0).with_for_update().first():
|
||||
raise HTTPException(400, '证件被运输证引用')
|
||||
for log in logs:
|
||||
log.del_flag = 1
|
||||
log.update_by = user.id
|
||||
row.del_flag = 1
|
||||
row.update_by = user.id
|
||||
if cert_type == 'transport':
|
||||
@@ -505,24 +388,7 @@ def _confirm_cert(db, user, cert_type, cert_id, data):
|
||||
_assert_known_usage_policy(row)
|
||||
if row.status == 'void': raise HTTPException(400, '作废证件不能启用')
|
||||
if row.status == 'closed': raise HTTPException(400, '已核销证件不能启用')
|
||||
# 建档自动期初(used=0)可被手工/接口期初覆盖;已有真实用量或非自动期初则拒绝。
|
||||
openings = (
|
||||
db.query(PlCertQtyLog)
|
||||
.filter_by(
|
||||
tenant_no=user.tenant_no, cert_type=cert_type, cert_id=cert_id,
|
||||
event_type='opening', del_flag=0,
|
||||
)
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
for log in openings:
|
||||
snap = log.event_snapshot or {}
|
||||
if snap.get('auto') and (log.used_delta or 0) == 0:
|
||||
log.del_flag = 1
|
||||
log.update_by = user.id
|
||||
else:
|
||||
raise HTTPException(400, '期初已确认,不能重复初始化额度')
|
||||
if _has_history(db, user, cert_type, cert_id):
|
||||
if row.verified_time or _has_history(db,user,cert_type,cert_id):
|
||||
raise HTTPException(400, '期初已确认,不能重复初始化额度')
|
||||
if body.used_qty > row.total_qty:
|
||||
raise HTTPException(400, '期初已用量不能超过核定量')
|
||||
|
||||
@@ -42,16 +42,6 @@ def _has_opening(db, user, cert_type, cert_id) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_opening_quiet(db, user, cert_type, cert_id, row):
|
||||
"""选证前尽量自动补期初;单元测试 mock 库失败时静默跳过。"""
|
||||
try:
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
|
||||
ensure_zero_opening(db, user, cert_type, cert_id, row=row)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _vehicle_ok(cert, vehicle_no: str) -> bool:
|
||||
plates = cert.vehicle_numbers_json or []
|
||||
if not isinstance(plates, list):
|
||||
@@ -126,7 +116,6 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
for purchase in candidates:
|
||||
if not purchase.verified_by or not purchase.verified_time:
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'purchase', purchase.id, purchase)
|
||||
if not _has_opening(db, user, 'purchase', purchase.id):
|
||||
continue
|
||||
if not _in_force(purchase, on_date):
|
||||
@@ -176,7 +165,6 @@ def select_whole_cover_certs(db, user, sources, *, vehicle_no: str, on_date: dat
|
||||
for transport in transports:
|
||||
if not transport.verified_by or not transport.verified_time:
|
||||
continue
|
||||
_ensure_opening_quiet(db, user, 'transport', transport.id, transport)
|
||||
if not _has_opening(db, user, 'transport', transport.id):
|
||||
continue
|
||||
if not _in_force(transport, on_date):
|
||||
|
||||
@@ -351,7 +351,11 @@ def get_plan_quota(db, user, plan_id):
|
||||
),
|
||||
'usages': []}
|
||||
for usage in usages:
|
||||
_get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
cert = _get_row(db, user, usage.cert_type, usage.cert_id)
|
||||
items = db.query(PlPlanCertUsageItem).filter_by(tenant_no=user.tenant_no, usage_id=usage.id, del_flag=0).order_by(PlPlanCertUsageItem.allocation_seq).all()
|
||||
result['usages'].append({**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]})
|
||||
payload = {**_row_to_dict(usage), 'items': [_row_to_dict(row) for row in items]}
|
||||
# 详情展示证面附件:用量行本身无附件字段,从证件档案回填
|
||||
payload['attachmentUrls'] = getattr(cert, 'attachment_urls', None) or ''
|
||||
payload['certNo'] = payload.get('certNo') or getattr(cert, 'cert_no', None) or usage.cert_no_snapshot
|
||||
result['usages'].append(payload)
|
||||
return result
|
||||
@@ -200,10 +200,8 @@ def _candidate_context(db,user,r,plan,certs,usages,items,selected,preview=False,
|
||||
skipped_for_vehicle=False
|
||||
from app.services.pl_cert_quota_rule_config_service import plan_uses_customer_category
|
||||
customer_rule = plan_uses_customer_category(plan)
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
for k in sorted(selected):
|
||||
kind,pid=k; c=certs[k]
|
||||
c = certs[k] = ensure_zero_opening(db, user, kind, pid, row=c)
|
||||
if not query(m.PlCertQtyLog,cert_type=kind,cert_id=pid,event_type='opening',del_flag=0).first(): raise ValueError('证件尚未确认期初')
|
||||
if c.status=='closed': raise ValueError('已核销证件不能再安排用证')
|
||||
if c.status!='active' or not c.verified_time or not c.verified_by: raise ValueError('证件尚未启用复核')
|
||||
@@ -372,8 +370,6 @@ ARCHIVE_QUOTA_BLOCK = '合同绑定的购买证和运输证剩余额度不够覆
|
||||
|
||||
def _settlement_cover_cert(db, user, r, plan, kind, cert):
|
||||
"""合同上另有证件时,不合格的证跳过,不因此挡住其余还能扣的证。"""
|
||||
from app.services.pl_quota_cert_service import ensure_zero_opening
|
||||
cert = ensure_zero_opening(db, user, kind, cert.id, row=cert)
|
||||
if not _query(db, user, m.PlCertQtyLog, cert_type=kind, cert_id=cert.id, event_type='opening', del_flag=0).first():
|
||||
return False
|
||||
if cert.status != 'active' or not cert.verified_time or not cert.verified_by:
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_permission_guards_validation_and_atomic_creation(api):
|
||||
saved = client.post(path, json=payload())
|
||||
assert saved.status_code == 200
|
||||
row = saved.json()
|
||||
assert row['tenantNo'] == 't' and row['status'] == 'active' and row['openingConfirmed'] is True
|
||||
assert row['tenantNo'] == 't' and row['status'] == 'draft' and row['openingConfirmed'] is False
|
||||
assert client.put(path + '/' + row['id'], json={'totalQty': '-1'}).status_code == 422
|
||||
assert client.get(path + '/' + row['id']).json()['totalQty'] == '30.000000'
|
||||
app.dependency_overrides[auth] = lambda: {'role': 'admin', 'user_id': 'u', 'tenant_no': 'other'}
|
||||
@@ -130,8 +130,7 @@ def test_total_adjust_requires_provenance_and_replays_without_duplicate_log(api)
|
||||
assert response.status_code == 200
|
||||
assert client.post(path, json=body).status_code == 200
|
||||
logs = client.get(base + '/' + row['id'] + '/logs').json()
|
||||
adjust_logs = [item for item in logs if item.get('eventType') == 'total_adjust']
|
||||
assert len(adjust_logs) == 1 and adjust_logs[0]['totalDelta'] == '5.000000'
|
||||
assert len(logs) == 1 and logs[0]['totalDelta'] == '5.000000'
|
||||
assert client.post(path, json={**body,'totalQty':'40'}).status_code == 409
|
||||
assert client.post(path, json={**body,'operationId':'adjust-2','sourceNote':''}).status_code == 409
|
||||
assert client.get(base + '/' + row['id']).json()['totalQty'] == '35.000000'
|
||||
|
||||
@@ -41,31 +41,14 @@ def transport(**kw):
|
||||
return data
|
||||
|
||||
|
||||
def test_save_auto_enables_with_zero_opening(ctx):
|
||||
"""建档即启用:期初已用默认 0,无需手工期初确认。"""
|
||||
db, u, s, m = ctx
|
||||
row = s.save_cert(db, u, 'purchase', purchase())
|
||||
assert row['status'] == 'active'
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['usedQty'] == Decimal('0')
|
||||
assert row['verifiedBy'] == 'u'
|
||||
assert row['verifiedTime'] is not None
|
||||
log = db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').one()
|
||||
assert (log.event_snapshot or {}).get('auto') is True
|
||||
transport_row = s.save_cert(db, u, 'transport', transport())
|
||||
assert transport_row['status'] == 'active' and transport_row['openingConfirmed'] is True
|
||||
|
||||
|
||||
def test_manual_optional_image_complete_fields(ctx):
|
||||
db,u,s,m = ctx
|
||||
data = purchase(buyerAddress='地址', buyerPhone='电话', buyerLegalRepresentative='甲', sellerAddress='乙址', sellerPhone='乙电话', sellerLegalRepresentative='乙', purchasePurpose='用途', issueDate='2026-01-01', validFrom='2026-01-01', validTo='2027-01-01')
|
||||
row = s.save_cert(db,u,'purchase',data)
|
||||
assert row['buyerAddress'] == '地址' and row['attachmentUrls'] is None
|
||||
assert row['totalQty'] == Decimal('100.123456')
|
||||
assert row['status'] == 'active'
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['usedQty'] == Decimal('0')
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0, event_type='opening').count() == 1
|
||||
assert row['status'] == 'draft'
|
||||
assert db.query(m.PlCertQtyLog).count() == 0
|
||||
db.rollback()
|
||||
assert db.query(m.PlPurchaseCert).count() == 0
|
||||
|
||||
@@ -73,8 +56,9 @@ def test_manual_optional_image_complete_fields(ctx):
|
||||
def test_opening_confirmation_remains_visible_after_reverification_required(ctx):
|
||||
db, user, svc, models = ctx
|
||||
row = svc.save_cert(db, user, 'purchase', purchase())
|
||||
assert row['openingConfirmed'] is True
|
||||
assert row['status'] == 'active'
|
||||
assert row['openingConfirmed'] is False
|
||||
row = svc.confirm_cert(db, user, 'purchase', row['id'], {
|
||||
'operationId': 'op-ui', 'usedQty': '0', 'singleUseConsumed': False, 'reason': '全新证'})
|
||||
changed = svc.save_cert(db, user, 'purchase', {'buyerAddress': '更正地址'}, row['id'])
|
||||
assert changed['verifiedTime'] is None
|
||||
assert changed['openingConfirmed'] is True
|
||||
@@ -105,12 +89,10 @@ def test_reference_delta_omitted_empty_and_invalid_prewrite(ctx):
|
||||
def test_confirmation_and_history_protection(ctx):
|
||||
db,u,s,m=ctx
|
||||
row=s.save_cert(db,u,'purchase',purchase())
|
||||
assert row['status']=='active' and row['openingConfirmed'] is True
|
||||
with pytest.raises((HTTPException,ValidationError)): s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','operationId':'op'})
|
||||
row=s.confirm_cert(db,u,'purchase',row['id'],{'usedQty':'0','singleUseConsumed':True,'reason':'核对','evidenceUrls':'https://minio/evidence','operationId':'op'})
|
||||
assert row['openingSingleUseConsumed']==1 and row['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_type=='opening'
|
||||
# 建档自动期初后身份已锁定;手工覆盖期初后同样不可改核定量/证号。
|
||||
assert db.query(m.PlCertQtyLog).one().event_type=='opening'
|
||||
for data in ({'quantityUnit':'千克'},{'certNo':'new'},{'totalQty':'90'}):
|
||||
with pytest.raises(HTTPException): s.save_cert(db,u,'purchase',data,row['id'])
|
||||
with pytest.raises(HTTPException): s.delete_cert(db,u,'purchase',row['id'])
|
||||
@@ -169,9 +151,8 @@ def test_opening_retry_preserves_balance_and_operation_identity(ctx):
|
||||
db.query(m.PlPurchaseCert).one().used_qty=Decimal('5')
|
||||
retry=s.confirm_cert(db,u,'purchase',row['id'],opening())
|
||||
assert retry['usedQty']==Decimal('5')
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
# 自动期初操作号 + 手工期初操作号;重放不新增手工操作
|
||||
assert db.query(m.PlCertOperation).filter_by(operation_id='opening').count()==1
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
assert db.query(m.PlCertOperation).count()==1
|
||||
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(usedQty='7'))
|
||||
with pytest.raises(HTTPException): s.confirm_cert(db,u,'purchase',row['id'],opening(operationId='second'))
|
||||
|
||||
@@ -228,11 +209,10 @@ def test_reverify_after_correction_keeps_opening_and_balances(ctx):
|
||||
assert corrected['status']=='suspended' and corrected['verifiedBy'] is None
|
||||
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
|
||||
assert result['status']=='active' and result['usedQty']==Decimal('5')
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
# 自动期初 + 手工期初 + 复核
|
||||
assert db.query(m.PlCertOperation).count()==3
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
assert db.query(m.PlCertOperation).count()==2
|
||||
result=s.verify_cert(db,u,'purchase',row['id'],{'operationId':'verify-1','reason':'地址更正核对','evidenceUrls':'https://minio/evidence'})
|
||||
assert db.query(m.PlCertOperation).count()==3
|
||||
assert db.query(m.PlCertOperation).count()==2
|
||||
|
||||
|
||||
def test_actual_scope_denies_user_without_roles(ctx):
|
||||
@@ -281,7 +261,7 @@ def test_historical_opening_accepts_written_source_without_image(ctx):
|
||||
row=s.save_cert(db,u,'purchase',purchase())
|
||||
result=s.confirm_cert(db,u,'purchase',row['id'],{'operationId':'written-source','usedQty':'5','singleUseConsumed':True,'reason':'历史核对','sourceNote':'2026年10月纸质台账第3页,经张三核实'})
|
||||
assert result['usedQty']==Decimal('5') and result['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().event_snapshot['sourceNote'].startswith('2026')
|
||||
assert db.query(m.PlCertQtyLog).one().event_snapshot['sourceNote'].startswith('2026')
|
||||
|
||||
|
||||
def test_unknown_use_text_is_preserved_and_requires_confirmation(ctx):
|
||||
@@ -443,7 +423,7 @@ def test_opening_verify_stale_operation_snapshot_uses_unique_insert_arbitration(
|
||||
result=fn(db,u,'purchase',p['id'],body)
|
||||
assert missed and result['id']==p['id']
|
||||
assert db.query(m.PlCertOperation).count()==count
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==1
|
||||
assert db.query(m.PlCertQtyLog).count()==1
|
||||
|
||||
|
||||
def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
@@ -451,7 +431,7 @@ def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
p=s.save_cert(db,u,'purchase',purchase())
|
||||
row=s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'reason':' '})
|
||||
assert row['status']=='active'
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).one().reason==''
|
||||
assert db.query(m.PlCertQtyLog).one().reason==''
|
||||
s.save_cert(db,u,'purchase',{'buyerAddress':'更正地址'},p['id'])
|
||||
verified=s.verify_cert(db,u,'purchase',p['id'],{'operationId':'verify-blank','reason':''})
|
||||
assert verified['status']=='active' and verified['verifiedTime']
|
||||
@@ -460,12 +440,10 @@ def test_opening_and_verify_allow_blank_reason(ctx):
|
||||
def test_rejected_opening_rolls_back_claimed_operation(ctx):
|
||||
db,u,s,m=ctx
|
||||
p=s.save_cert(db,u,'purchase',purchase())
|
||||
auto_ops = db.query(m.PlCertOperation).count()
|
||||
auto_logs = db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()
|
||||
with pytest.raises(HTTPException):
|
||||
s.confirm_cert(db,u,'purchase',p['id'],{**opening(),'usedQty':'999'})
|
||||
assert db.query(m.PlCertOperation).count()==auto_ops
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count()==auto_logs
|
||||
assert db.query(m.PlCertOperation).count()==0
|
||||
assert db.query(m.PlCertQtyLog).count()==0
|
||||
|
||||
|
||||
def test_replay_reads_current_certificate_and_opening_not_old_snapshot(ctx,monkeypatch):
|
||||
@@ -494,7 +472,7 @@ def test_closed_certificate_keeps_history_and_cannot_be_reopened(ctx):
|
||||
used = confirmed['usedQty']
|
||||
closed = s.save_cert(db, u, 'purchase', {'status': 'closed'}, row['id'])
|
||||
assert closed['status'] == 'closed' and closed['usedQty'] == used
|
||||
assert db.query(m.PlCertQtyLog).filter_by(del_flag=0).count() == 1
|
||||
assert db.query(m.PlCertQtyLog).count() == 1
|
||||
for action, payload in (
|
||||
('confirm', {**opening(), 'operationId': 'reopen-opening'}),
|
||||
('verify', {'operationId': 'reopen-verify', 'reason': '重新启用'}),
|
||||
|
||||
@@ -317,3 +317,39 @@ def test_missing_contract_is_empty_binding_not_unreadable(case, contract_id):
|
||||
assert rows[0]['bindingsReadable'] is True
|
||||
assert rows[0]['certificates'] == []
|
||||
assert rows[1]['bindingsReadable'] is False
|
||||
|
||||
|
||||
def test_plan_quota_usages_include_cert_attachment_urls(case):
|
||||
from app.models.crm_models import CrmContract
|
||||
from app.models.pl_quota_models import (
|
||||
PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert,
|
||||
)
|
||||
from app.services.pl_quota_planning import get_plan_quota
|
||||
c = case
|
||||
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem, PlPurchaseCert, PlTransportCert):
|
||||
model.__table__.create(c.db.bind)
|
||||
c.db.add(PlPurchaseCert(
|
||||
id='p1', cert_no='G-BUY', product_name_on_cert='硫酸', valid_use_text='一次有效',
|
||||
buyer_name='买方', seller_name='卖方', total_qty=33, quantity_unit='吨',
|
||||
attachment_urls='https://example.com/buy.jpg', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlTransportCert(
|
||||
id='t1', cert_no='Y-TR', product_name_on_cert='硫酸', valid_use_text='多次有效',
|
||||
shipper_name='货主', total_qty=70, quantity_unit='吨', vehicle_numbers_json=['皖A1'],
|
||||
attachment_urls='', tenant_no='t', status='active',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u1', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='purchase', cert_id='p1', cert_no_snapshot='G-BUY', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.add(PlPlanCertUsage(
|
||||
id='u2', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN',
|
||||
cert_type='transport', cert_id='t1', cert_no_snapshot='Y-TR', cert_revision=1,
|
||||
quantity_unit='吨', reserved_qty=10, used_qty=0, usage_status='reserved',
|
||||
))
|
||||
c.db.flush()
|
||||
usages = {u['certId']: u for u in get_plan_quota(c.db, c.user, 'plan')['usages']}
|
||||
assert usages['p1']['attachmentUrls'] == 'https://example.com/buy.jpg'
|
||||
assert usages['p1']['certNo'] == 'G-BUY'
|
||||
assert usages['t1']['attachmentUrls'] in ('', None)
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const viewTabs = readFileSync(
|
||||
new URL('../src/components/business/pl-plan-view-tabs/index.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const h5Detail = readFileSync(
|
||||
new URL('../src/views/m/pl/plan/detail.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('Web 计划详情:关联证件在装送货后且无危化品证书信息区', () => {
|
||||
assert.doesNotMatch(viewTabs, /危化品证书信息/)
|
||||
assert.doesNotMatch(viewTabs, /PlPlanHazardCertViewTable/)
|
||||
const loadIdx = viewTabs.indexOf('装送货信息')
|
||||
const certIdx = viewTabs.indexOf('关联购买证 / 运输证')
|
||||
const freightIdx = viewTabs.indexOf('运费信息')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(viewTabs, /AttachmentUploadField/)
|
||||
assert.match(viewTabs, /attachmentUrls/)
|
||||
})
|
||||
|
||||
test('H5 计划详情:关联购买证/运输证替换危化品证书信息并展示附件', () => {
|
||||
assert.doesNotMatch(h5Detail, /危化品证书信息/)
|
||||
assert.match(h5Detail, /关联购买证 \/ 运输证/)
|
||||
assert.match(h5Detail, /关联证件/)
|
||||
const loadIdx = h5Detail.indexOf('plan-sec-load')
|
||||
const certIdx = h5Detail.indexOf('plan-sec-cert')
|
||||
const freightIdx = h5Detail.indexOf('plan-sec-freight')
|
||||
assert.ok(loadIdx >= 0 && certIdx > loadIdx && freightIdx > certIdx)
|
||||
assert.match(h5Detail, /AttachmentUploadField|CertAttachments/)
|
||||
assert.match(h5Detail, /attachmentUrls/)
|
||||
})
|
||||
@@ -71,23 +71,6 @@
|
||||
<p v-else-if="!loading" class="pl-plan-form-empty-hint">暂无车辆信息</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-row">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span>{{ item.certNo || item.certId }}</span>
|
||||
<span class="text-slate-500">预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">装送货信息</h3>
|
||||
<PlPlanStopsSplitShell
|
||||
@@ -251,8 +234,31 @@
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
|
||||
<PlPlanHazardCertViewTable :products="hazardProducts" />
|
||||
<h3 class="pl-plan-form-section-title">关联购买证 / 运输证</h3>
|
||||
<p v-if="plan?.quotaCertPendingFulfill" class="pl-plan-form-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="pl-plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="pl-plan-linked-cert-block">
|
||||
<div class="pl-plan-linked-cert-block__meta">
|
||||
<el-tag size="small" :type="item.kind === 'purchase' ? 'success' : 'warning'">
|
||||
{{ item.kind === 'purchase' ? '购买证' : '运输证' }}
|
||||
</el-tag>
|
||||
<span class="pl-plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="pl-plan-linked-cert-block__qty"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="pl-plan-linked-cert-block__slot">
|
||||
<span class="pl-plan-linked-cert-block__label">证件附件</span>
|
||||
<div v-if="item.attachmentUrls" class="pl-plan-linked-cert-block__attachment">
|
||||
<AttachmentUploadField :model-value="item.attachmentUrls" disabled />
|
||||
</div>
|
||||
<span v-else class="pl-plan-linked-cert-block__missing">未上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-form-empty-hint">暂无关联证件</p>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
@@ -430,7 +436,6 @@
|
||||
type BrandQtyRow
|
||||
} from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
import PlPlanDriverFeedbackDialog from '@/components/business/pl-plan-driver-feedback-dialog/index.vue'
|
||||
import PlPlanHazardCertViewTable from '@/components/business/pl-plan-hazard-cert-view-table/index.vue'
|
||||
import PlPlanArchiveBrandViewBlock from '@/components/business/pl-plan-archive-brand-view-block/index.vue'
|
||||
import PlPlanStopFeedbackPanel from '@/components/business/pl-plan-stop-feedback-panel/index.vue'
|
||||
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
|
||||
@@ -476,7 +481,6 @@
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import { buildArchiveBrandViewsBySegment } from '@/utils/pl-plan-archive-brand-view'
|
||||
import { resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
|
||||
import { isHazardProduct } from '@/composables/usePlToxicProductCertCheck'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { formatDate, formatDateTime } from '@/utils/format'
|
||||
import {
|
||||
@@ -1638,18 +1642,6 @@
|
||||
return fields
|
||||
})
|
||||
|
||||
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
|
||||
const hazardProducts = computed(() =>
|
||||
products.value
|
||||
.filter((item) => isHazardProduct(item))
|
||||
.map((item) => {
|
||||
const formRow = item.id ? planProductFormById.value.get(String(item.id)) : undefined
|
||||
const standardName = String(formRow?.standardProductName || '').trim()
|
||||
if (!standardName || standardName === item.productName) return item
|
||||
return { ...item, productName: standardName }
|
||||
})
|
||||
)
|
||||
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
@@ -1658,6 +1650,7 @@
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
@@ -1670,9 +1663,10 @@
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no,
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—'
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
@@ -1932,6 +1926,93 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border, #d9e8e2);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__qty {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__slot {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__label {
|
||||
flex-shrink: 0;
|
||||
color: var(--mint-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__attachment {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.attachment-upload) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:deep(.attachment-list) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-item) {
|
||||
width: 140px;
|
||||
margin-bottom: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.attachment-preview) {
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-linked-cert-block__missing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed var(--border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
background: var(--table-head, #f8fafc);
|
||||
color: var(--mint-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pl-plan-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
>编辑</button
|
||||
>
|
||||
<button
|
||||
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
|
||||
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
|
||||
class="mp-wv-btn"
|
||||
type="button"
|
||||
@click="confirmRow = row"
|
||||
>
|
||||
复核
|
||||
{{ row.openingConfirmed ? '复核' : '期初确认' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -8,21 +8,37 @@
|
||||
:close-on-click-overlay="!saving"
|
||||
@update:show="onShow"
|
||||
>
|
||||
<van-nav-bar title="复核证件" left-text="关闭" @click-left="!saving && emit('close')" />
|
||||
<van-nav-bar
|
||||
:title="row?.openingConfirmed ? '复核证件' : '期初核对'"
|
||||
left-text="关闭"
|
||||
@click-left="!saving && emit('close')"
|
||||
/>
|
||||
<div v-if="row" class="mp-cert-confirm-sheet__body mp-cert-form">
|
||||
<p class="plan-empty-hint">{{ row.certNo }} · 复核已启用的证件</p>
|
||||
<p class="plan-empty-hint">{{ row.certNo }} · {{ row.openingConfirmed ? '复核已启用的证件' : '核对历史用量后启用' }}</p>
|
||||
<template v-if="!row.openingConfirmed">
|
||||
<van-field v-model="opening.usedQty" label="历史已用量" label-align="top" type="number" :disabled="saving" />
|
||||
<van-cell title="一次有效证已有使用记录" center>
|
||||
<template #right-icon>
|
||||
<van-switch v-model="opening.singleUseConsumed" :disabled="saving" size="20px" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-field v-model="opening.sourceNote" label="历史来源说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
|
||||
<p class="plan-empty-hint">核对凭据</p>
|
||||
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" :disabled="saving" hint="" />
|
||||
</template>
|
||||
<van-field v-model="opening.reason" label="核对说明" label-align="top" type="textarea" rows="2" :disabled="saving" />
|
||||
</div>
|
||||
<footer>
|
||||
<button class="mp-wv-btn" type="button" :disabled="saving" @click="emit('close')">取消</button>
|
||||
<button class="mp-wv-btn mp-wv-btn--primary" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? '正在提交…' : '确认复核' }}
|
||||
{{ saving ? '正在提交…' : '确认启用' }}
|
||||
</button>
|
||||
</footer>
|
||||
</van-popup>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import { quotaCertApi, type CertType, type QuotaCert } from '@/api/pl/quota-cert'
|
||||
import { randomOperationId } from '@/utils/random-id'
|
||||
import './cert-mobile.scss'
|
||||
@@ -60,15 +76,19 @@
|
||||
opening.reason = opening.reason.trim()
|
||||
saving.value = true
|
||||
try {
|
||||
await quotaCertApi.verify(kind, props.row.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
ElMessage.success('证件已复核')
|
||||
if (props.row.openingConfirmed) {
|
||||
await quotaCertApi.verify(kind, props.row.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
} else {
|
||||
await quotaCertApi.confirm(kind, props.row.id, { ...opening })
|
||||
}
|
||||
ElMessage.success(props.row.openingConfirmed ? '证件已复核' : '期初已确认')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
} catch {
|
||||
ElMessage.error('复核没有提交,请重试')
|
||||
ElMessage.error('核对没有提交,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -248,7 +248,37 @@
|
||||
</section>
|
||||
|
||||
<section id="plan-sec-cert" class="mp-wv-section mp-h5-anchor">
|
||||
<h3>危化品证书信息</h3>
|
||||
<h3>关联购买证 / 运输证</h3>
|
||||
<p v-if="plan.quotaCertPendingFulfill" class="plan-empty-hint">
|
||||
证书齐备(待完善):请到「证书齐备 → 已标记待完善证书」挂证并完善并关闭
|
||||
</p>
|
||||
<div v-else-if="linkedCertRows.length" class="plan-linked-certs">
|
||||
<div v-for="item in linkedCertRows" :key="item.key" class="plan-linked-cert-block">
|
||||
<div class="plan-linked-cert-block__meta">
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="
|
||||
item.kind === 'purchase' ? 'status-tag--success' : 'status-tag--warning'
|
||||
"
|
||||
>{{ item.kind === 'purchase' ? '购买证' : '运输证' }}</span
|
||||
>
|
||||
<span class="plan-linked-cert-block__no">{{ item.certNo || item.certId }}</span>
|
||||
<span class="plan-vehicle-card__meta"
|
||||
>预占 {{ item.reservedQty }} / 已用 {{ item.usedQty }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="plan-linked-cert-block__slot">
|
||||
<div class="plan-subcard__label">证件附件</div>
|
||||
<AttachmentUploadField
|
||||
v-if="item.attachmentUrls"
|
||||
:model-value="item.attachmentUrls"
|
||||
disabled
|
||||
/>
|
||||
<p v-else class="plan-linked-cert-block__missing">未上传</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="plan-empty-hint">暂无关联证件</p>
|
||||
<div class="plan-cert-actions">
|
||||
<van-button block plain type="primary" @click="quotaOpen = true">查看用证</van-button>
|
||||
<van-button
|
||||
@@ -536,10 +566,40 @@
|
||||
const errorText = ref('')
|
||||
const plan = ref<Plan | null>(null)
|
||||
const quotaOpen = ref(false)
|
||||
const linkedCertRows = ref<
|
||||
Array<{
|
||||
key: string
|
||||
kind: string
|
||||
certId: string
|
||||
certNo?: string
|
||||
reservedQty: string | number
|
||||
usedQty: string | number
|
||||
attachmentUrls: string
|
||||
}>
|
||||
>([])
|
||||
const needsLegalUseDocument = computed(() =>
|
||||
(plan.value?.products || []).some((row) => resolveProductHazardFlags(row).isExplosive)
|
||||
)
|
||||
const quotaTitle = computed(() => `计划用证 · ${plan.value?.planNo || ''}`)
|
||||
|
||||
async function loadLinkedCerts(planId: string) {
|
||||
linkedCertRows.value = []
|
||||
try {
|
||||
const { planQuotaApi } = await import('@/api/pl/quota-cert')
|
||||
const data = await planQuotaApi.get(planId)
|
||||
linkedCertRows.value = (data?.usages || []).map((u: any, index: number) => ({
|
||||
key: `${u.certType || u.cert_type}-${u.certId || u.cert_id}-${index}`,
|
||||
kind: String(u.certType || u.cert_type || ''),
|
||||
certId: String(u.certId || u.cert_id || ''),
|
||||
certNo: u.certNo || u.cert_no || u.certNoSnapshot || u.cert_no_snapshot,
|
||||
reservedQty: u.reservedQty ?? u.reserved_qty ?? '—',
|
||||
usedQty: u.usedQty ?? u.used_qty ?? '—',
|
||||
attachmentUrls: String(u.attachmentUrls || u.attachment_urls || '').trim()
|
||||
}))
|
||||
} catch {
|
||||
linkedCertRows.value = []
|
||||
}
|
||||
}
|
||||
const deliveryIndex = ref(0)
|
||||
const moreOpen = ref(false)
|
||||
const voidOpen = ref(false)
|
||||
@@ -568,7 +628,7 @@
|
||||
{ key: 'base', label: '基础信息', anchor: 'plan-sec-base' },
|
||||
{ key: 'transport', label: '运输信息', anchor: 'plan-sec-transport' },
|
||||
{ key: 'load', label: '装送货', anchor: 'plan-sec-load' },
|
||||
{ key: 'cert', label: '危化品证', anchor: 'plan-sec-cert' },
|
||||
{ key: 'cert', label: '关联证件', anchor: 'plan-sec-cert' },
|
||||
{ key: 'freight', label: '运费', anchor: 'plan-sec-freight' },
|
||||
{ key: 'remark', label: '备注', anchor: 'plan-sec-remark' }
|
||||
]
|
||||
@@ -751,6 +811,7 @@
|
||||
loadVersion++
|
||||
actionVersion++
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
readDetail.value = null
|
||||
partialErrors.value = []
|
||||
errorText.value = ''
|
||||
@@ -807,9 +868,12 @@
|
||||
readDetail.value = detail
|
||||
partialErrors.value = [...detail.errors, ...(arrangeFailed ? ['安排信息'] : [])]
|
||||
plan.value = data
|
||||
await loadLinkedCerts(id)
|
||||
if (!current()) return
|
||||
} catch {
|
||||
if (!current()) return
|
||||
plan.value = null
|
||||
linkedCertRows.value = []
|
||||
resetArrangeActions()
|
||||
errorText.value = '计划详情加载失败或记录不存在,请重试'
|
||||
} finally {
|
||||
@@ -1516,6 +1580,57 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-certs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9e8e2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-linked-cert-block__missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
margin: 0;
|
||||
border: 1px dashed #d9e8e2;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mp-plan-quota-sheet {
|
||||
|
||||
@@ -93,10 +93,11 @@
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit && row.openingConfirmed && row.status !== 'void' && row.status !== 'closed'"
|
||||
v-if="canEdit && row.status !== 'void' && row.status !== 'closed'"
|
||||
size="small"
|
||||
@click="openConfirm(row)"
|
||||
><el-icon><Check /></el-icon>复核</el-button
|
||||
><el-icon><Check /></el-icon
|
||||
>{{ row.openingConfirmed ? '复核' : '期初确认' }}</el-button
|
||||
>
|
||||
<el-button size="small" @click="view(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
@@ -590,12 +591,35 @@
|
||||
</SystemViewDialog>
|
||||
<el-dialog
|
||||
v-model="confirmVisible"
|
||||
title="复核证件"
|
||||
:title="confirmRow?.openingConfirmed ? '复核证件' : '期初核对'"
|
||||
width="640px"
|
||||
align-center
|
||||
class="dialog-form-layout quota-cert-confirm-dialog"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<template v-if="!confirmRow?.openingConfirmed">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
历史已用量(证面单位)<span v-if="confirmUnit" class="form-label-tip">{{
|
||||
confirmUnit
|
||||
}}</span>
|
||||
</template>
|
||||
<el-input v-model="opening.usedQty">
|
||||
<template v-if="confirmUnit" #append>{{ confirmUnit }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="一次有效证是否已有使用记录">
|
||||
<el-switch v-model="opening.singleUseConsumed" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="历史来源说明">
|
||||
<el-input v-model="opening.sourceNote" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="核对凭据">
|
||||
<AttachmentUploadField v-model="opening.evidenceUrls" folder="pl/quota-cert" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="核对说明"
|
||||
><el-input v-model="opening.reason" type="textarea"
|
||||
/></el-form-item>
|
||||
@@ -604,7 +628,7 @@
|
||||
><el-button @click="confirmVisible = false"
|
||||
><el-icon><Close /></el-icon>取消</el-button
|
||||
><el-button type="primary" :loading="saving" @click="confirm"
|
||||
><el-icon><Check /></el-icon>确认复核</el-button
|
||||
><el-icon><Check /></el-icon>确认启用</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
@@ -1781,10 +1805,12 @@
|
||||
saving.value = true
|
||||
try {
|
||||
const targetKind = rowCertType(confirmRow.value)
|
||||
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
if (confirmRow.value.openingConfirmed)
|
||||
await quotaCertApi.verify(targetKind, confirmRow.value.id, {
|
||||
operationId: opening.operationId,
|
||||
reason: opening.reason
|
||||
})
|
||||
else await quotaCertApi.confirm(targetKind, confirmRow.value.id, opening)
|
||||
confirmVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user