diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index 99d5ecb3b..898305028 100644 --- a/backend/app/api/pl/plan.py +++ b/backend/app/api/pl/plan.py @@ -3807,7 +3807,7 @@ def archive_plan( db.flush() result = get_plan(id, db, user) if not quota_settled: - result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id) + result.quota_settlement_error = read_quota_settlement_error(db, tenant_no, plan.id, plan=plan) return result diff --git a/backend/app/services/pl_quota_business.py b/backend/app/services/pl_quota_business.py index c1b7fb8e2..d6818961f 100644 --- a/backend/app/services/pl_quota_business.py +++ b/backend/app/services/pl_quota_business.py @@ -171,26 +171,40 @@ def settle_plan_quota(db, user, plan, payload, *, adjustment=False): except ArchiveQuotaBlocked as exc: raise HTTPException(409, str(exc)) from exc except (HTTPException, ValueError, AllocationIncomplete) as exc: + message = str(getattr(exc, 'detail', exc)) + # 无用量行(如证书齐备仅放行安排)时仍保留原因,供同请求读回。 + setattr(plan, '_quota_settlement_error', message) rows = db.query(PlPlanCertUsage).filter_by(tenant_no=user.tenant_no, plan_id=plan.id, del_flag=0).all() for row in rows: if not adjustment: row.usage_status = 'exception' row.confirmation_snapshot = {**(row.confirmation_snapshot or {}), 'executed': True, - 'settlementError': str(getattr(exc, 'detail', exc))} + 'settlementError': message} row.update_by = user.id if not adjustment: plan.archive_status = 'exception' return False -def read_quota_settlement_error(db, tenant_no, plan_id): +def read_quota_settlement_error(db, tenant_no, plan_id, plan=None): """结转失败时返回已经写在用量快照上的原因。""" + from app.models.pl_models import PlPlan from app.models.pl_quota_models import PlPlanCertUsage + + if plan is not None: + cached = getattr(plan, '_quota_settlement_error', None) + if isinstance(cached, str) and cached.strip(): + return cached.strip() rows = db.query(PlPlanCertUsage).filter_by(tenant_no=tenant_no, plan_id=plan_id, del_flag=0).all() for row in rows: message = (row.confirmation_snapshot or {}).get('settlementError') if isinstance(message, str) and message.strip(): return message.strip() + target = plan + if target is None: + target = db.query(PlPlan).filter_by(id=plan_id, tenant_no=tenant_no, del_flag=0).first() + if target is not None and int(getattr(target, 'quota_cert_pending_fulfill', 0) or 0) == 1: + return '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档' return '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。' diff --git a/backend/tests/test_pl_customer_cert_quota.py b/backend/tests/test_pl_customer_cert_quota.py index f7365d268..74a763df3 100644 --- a/backend/tests/test_pl_customer_cert_quota.py +++ b/backend/tests/test_pl_customer_cert_quota.py @@ -61,6 +61,47 @@ def test_apply_plan_quota_new_rule_waive_skips_reserve_not_settle(monkeypatch): assert '待完善' in str(exc.value.detail) +def test_settle_pending_fulfill_without_usage_rows_keeps_pending_message(monkeypatch): + """证书齐备待完善且无用量行时,归档结转错误不能回落成通用扣减文案。""" + from contextlib import nullcontext + + from app.services import pl_quota_business as biz + from fastapi import HTTPException + + plan = SimpleNamespace( + id='p1', + quota_cert_waived=1, + quota_cert_pending_fulfill=1, + cert_quota_rule_version='customer_category', + cert_configuration={}, + archive_status='none', + ) + user = SimpleNamespace(id='u', tenant_no='t') + pending = '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档' + + class _Query: + def filter_by(self, **kwargs): + return self + + def all(self): + return [] + + db = MagicMock() + db.query.return_value = _Query() + db.flush = MagicMock() + db.begin_nested = MagicMock(return_value=nullcontext()) + + def boom(*_a, **_k): + raise HTTPException(409, pending) + + monkeypatch.setattr(biz, 'apply_plan_quota', boom) + + assert biz.settle_plan_quota(db, user, plan, None) is False + message = biz.read_quota_settlement_error(db, 't', 'p1', plan=plan) + assert '待完善' in message + assert '证件没有扣减' not in message + + def test_list_certs_default_excludes_closed_void(monkeypatch): from app.services import pl_quota_cert_service as svc diff --git a/web-admin/scripts/archive-quota-block-dialog.test.ts b/web-admin/scripts/archive-quota-block-dialog.test.ts new file mode 100644 index 000000000..c2d50f1ad --- /dev/null +++ b/web-admin/scripts/archive-quota-block-dialog.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolveArchiveQuotaBlockMode } from '../src/utils/pl-archive-quota-block' + +test('pending fulfill plan only alerts, no certificate-ready confirm', () => { + assert.equal( + resolveArchiveQuotaBlockMode( + '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。', + '用证没有完成', + { quotaCertPendingFulfill: 1, quotaCertWaived: 1 } + ), + 'alert-pending' + ) +}) + +test('already waived plan does not offer archive-time certificate ready', () => { + assert.equal( + resolveArchiveQuotaBlockMode('余额不足', '用证没有完成', { quotaCertWaived: 1 }), + 'alert-pending' + ) +}) + +test('pending keywords still force alert mode without plan flags', () => { + assert.equal( + resolveArchiveQuotaBlockMode( + '该计划仍处于证书齐备待完善,请先在证书齐备中完善并关闭后再归档', + '用证没有完成', + {} + ), + 'alert-pending' + ) +}) + +test('unmarked legacy quota block still offers certificate ready', () => { + assert.equal( + resolveArchiveQuotaBlockMode( + '实际数量已保存,证件没有扣减。请把实际数量改小,或先补充购买证和运输证,再重新归档。', + '用证没有完成', + { quotaCertPendingFulfill: 0, quotaCertWaived: 0 } + ), + 'confirm-ready' + ) +}) + +test('non quota-title blocks stay plain alert', () => { + assert.equal(resolveArchiveQuotaBlockMode('不能归档:缺证', '不能归档', {}), 'alert-plain') +}) diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index edce7d8ed..bd5ac1387 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -624,6 +624,13 @@ export interface Plan { hazardCertReady?: number hazardCertReadyBy?: string hazardCertReadyTime?: string + /** 证书齐备:新规则仅放行安排 */ + quotaCertWaived?: number + quotaCertWaivedBy?: string + quotaCertWaivedByName?: string + quotaCertWaivedTime?: string + /** 新规则证书齐备待完善 */ + quotaCertPendingFulfill?: number erpEntered?: number remark?: string createBy?: string diff --git a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue index d5f326bed..948d410e5 100644 --- a/web-admin/src/components/business/pl-plan-archive-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-archive-dialog/index.vue @@ -299,6 +299,7 @@ extractArchiveLineOverageDetail, showArchiveLineOverageConfirm } from '@/utils/pl-contract-qty-reminder' + import { resolveArchiveQuotaBlockMode } from '@/utils/pl-archive-quota-block' const props = defineProps<{ visible: boolean @@ -1602,12 +1603,12 @@ async function showArchiveQuotaBlockDialog(message: string, title = '不能归档') { const html = `

${escapeArchiveBlockText(message)}

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

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

` - : html + const mode = resolveArchiveQuotaBlockMode(message, title, plan.value) + if (mode !== 'confirm-ready') { + const guide = + mode === 'alert-pending' + ? `${html}

该运输计划已走证书齐备,归档前须完善证件。请到「证书齐备 → 已标记待完善证书」上传购买证/运输证并执行完善并关闭后再归档。

` + : html await ElMessageBox.alert(guide, title, { type: 'warning', confirmButtonText: '知道了', diff --git a/web-admin/src/utils/pl-archive-quota-block.ts b/web-admin/src/utils/pl-archive-quota-block.ts new file mode 100644 index 000000000..1ddbc2ff3 --- /dev/null +++ b/web-admin/src/utils/pl-archive-quota-block.ts @@ -0,0 +1,27 @@ +/** 归档用证拦截弹窗:已齐备/待完善只提醒,未齐备旧规则才可再点证书齐备。 */ + +export type ArchiveQuotaBlockMode = 'alert-pending' | 'alert-plain' | 'confirm-ready' + +export type ArchiveQuotaBlockPlanFlags = { + quotaCertPendingFulfill?: number | boolean | null + quotaCertWaived?: number | boolean | null +} + +function flagOn(value: number | boolean | null | undefined) { + return value === true || Number(value || 0) === 1 +} + +export function resolveArchiveQuotaBlockMode( + message: string, + title: string, + plan?: ArchiveQuotaBlockPlanFlags | null +): ArchiveQuotaBlockMode { + const pendingByMessage = + message.includes('待完善') || + message.includes('完善并关闭') || + message.includes('证书齐备待完善') + const pendingByPlan = flagOn(plan?.quotaCertPendingFulfill) || flagOn(plan?.quotaCertWaived) + if (title !== '用证没有完成') return 'alert-plain' + if (pendingByPlan || pendingByMessage) return 'alert-pending' + return 'confirm-ready' +}