feat: 保存运输证件、报销付款和开发流程的当前实现。

补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-04 21:42:33 +08:00
1 parent 7325a0aa52
commit 8e9536332a
202 files changed
+19200 -2054

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
attachmentCount,
dropsLockedPlate,
keptPlates,
missingCertLabel,
productIdentity,
referenceRowLocked,
scopeExpands,
statusChoices
} from '../src/views/m/pl/plan-cert/cert-form-rules'
test('an empty certificate names the first missing field and does not invent a unit', () => {
assert.equal(missingCertLabel('purchase', { quantityUnit: '' }), '编号')
assert.equal(
missingCertLabel('purchase', {
certNo: 'G1',
productNameOnCert: '硫酸',
totalQty: '1',
quantityUnit: '',
validUseText: '多次有效',
buyerName: '甲',
sellerName: '乙'
}),
'数量单位'
)
assert.equal(
missingCertLabel('transport', {
certNo: 'Y1',
productNameOnCert: '硫酸',
totalQty: '1',
quantityUnit: 'dun',
validUseText: '多次有效',
shipperName: '山峡'
}),
''
)
})
test('exactly one attachment is counted and status only moves to suspended, closed or void', () => {
assert.equal(attachmentCount(' a.png, ,b.pdf '), 2)
assert.equal(attachmentCount(''), 0)
assert.deepEqual(
statusChoices('active').map(([value]) => value),
['active', 'suspended', 'closed', 'void']
)
assert.deepEqual(
statusChoices('closed').map(([value]) => value),
['closed']
)
assert.deepEqual(
statusChoices('void').map(([value]) => value),
['void']
)
})
test('locked plates stay and a linked purchase reference cannot be edited', () => {
assert.equal(dropsLockedPlate(['皖A'], ['皖B']), true)
assert.deepEqual(keptPlates(['皖A'], ['皖B', '皖A']), ['皖A', '皖B'])
assert.equal(
referenceRowLocked(false, { id: 'ref-1', purchaseCertId: 'cert-1' }, ['ref-1']),
true
)
assert.equal(referenceRowLocked(false, { id: 'ref-2', purchaseCertId: null }, ['ref-1']), false)
})
test('a void or closed certificate cannot gain a contract, a product, or a restored product', () => {
assert.equal(scopeExpands('void', true, [], []), true)
assert.equal(scopeExpands('closed', true, [], []), true)
assert.equal(
scopeExpands(
'void',
false,
[{ contractProductId: 'line-1', scopeStatus: 'inactive' }],
[{ contractProductId: 'line-1', scopeStatus: 'active' }]
),
true
)
assert.equal(
scopeExpands(
'active',
false,
[{ contractProductId: 'line-1', scopeStatus: 'active' }],
[{ contractProductId: 'line-1', scopeStatus: 'inactive' }]
),
false
)
assert.equal(productIdentity([{ contractProductId: 'b' }, { contractProductId: 'a' }]), 'a\nb')
})
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addDraftProducts,
canRestoreProduct,
canSaveNewLink,
canSaveProductEdit,
certChoiceLabel,
certChoiceState,
removeDraftProduct
} from '../src/views/m/pl/plan-cert/cert-link-rules'
import { replaceCertProducts } from '../src/views/pl/quota-cert/model'
const saved = { id: 'scope-1', contractProductId: 'line-1', scopeStatus: 'active' as const }
const other = { id: 'scope-2', contractProductId: 'line-2', scopeStatus: 'inactive' as const }
test('void and already linked certificates cannot be newly selected', () => {
const taken = new Set(['purchase:linked'])
assert.equal(certChoiceState({ id: 'voided', status: 'void' }, 'purchase', taken), 'void')
assert.equal(certChoiceState({ id: 'closed', status: 'closed' }, 'purchase', taken), 'closed')
assert.equal(
certChoiceLabel({ id: 'closed', status: 'closed', certNo: 'G1' }, 'purchase', taken),
'G1(已核销)'
)
assert.equal(certChoiceState({ id: 'linked', status: 'active' }, 'purchase', taken), 'taken')
assert.equal(certChoiceState({ id: 'free', status: 'active' }, 'purchase', taken), 'open')
assert.match(certChoiceLabel({ id: 'voided', status: 'void', certNo: 'G1' }, 'purchase', taken), /已作废/)
assert.match(certChoiceLabel({ id: 'linked', status: 'active', certNo: 'G2' }, 'purchase', taken), /已关联/)
})
test('a new link needs one available product and an edit saves only after the scope changes', () => {
assert.equal(canSaveNewLink([]), false)
assert.equal(canSaveNewLink([{ contractProductId: 'line-1', scopeStatus: 'inactive' }]), false)
assert.equal(canSaveNewLink([{ contractProductId: 'line-1', scopeStatus: 'active' }]), true)
assert.equal(canSaveProductEdit([saved], [saved]), false)
assert.equal(
canSaveProductEdit([saved], [{ ...saved, scopeStatus: 'inactive' }]),
true
)
})
test('saved products stay in the set and only change availability', () => {
assert.equal(canRestoreProduct('void', 'inactive'), false)
assert.equal(canRestoreProduct('void', 'active'), true)
assert.equal(canRestoreProduct('active', 'inactive'), true)
const removed = removeDraftProduct([saved, { contractProductId: 'draft', scopeStatus: 'active' }], 'line-1')
assert.deepEqual(
removed.map((product) => product.contractProductId),
['line-1', 'draft']
)
assert.deepEqual(
removeDraftProduct(removed, 'draft').map((product) => product.contractProductId),
['line-1']
)
const added = addDraftProducts([saved], ['line-1', 'line-3'])
assert.deepEqual(
added.map((product) => product.contractProductId),
['line-1', 'line-3']
)
assert.equal(added[0].id, 'scope-1')
})
test('replacing one certificate keeps the other certificate and the product id', () => {
const bindings = [
{
id: 'link-1',
certType: 'purchase' as const,
certId: 'cert-1',
linkStatus: 'active' as const,
products: [saved, other]
},
{
id: 'link-2',
certType: 'transport' as const,
certId: 'cert-2',
linkStatus: 'active' as const,
products: [{ id: 'scope-3', contractProductId: 'line-9', scopeStatus: 'active' as const }]
}
]
const next = replaceCertProducts(bindings, 'purchase', 'cert-1', [
{ ...saved, scopeStatus: 'inactive' },
other
])
assert.equal(next.length, 2)
assert.equal(next[0].id, 'link-2')
assert.equal(next[1].id, 'link-1')
assert.equal(next[1].products[0].id, 'scope-1')
assert.equal(next[1].products[0].scopeStatus, 'inactive')
assert.equal(next[1].products[1].contractProductId, 'line-2')
})
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buyPartyName,
certFaceFields,
expiryWarning,
salePartyName
} from '../src/views/pl/quota-cert/cert-list-face'
const today = new Date(2026, 10, 4)
test('purchase and transport certificates use their own party names', () => {
assert.equal(salePartyName({ certType: 'purchase', sellerName: '销售方', shipperName: '货主' }), '销售方')
assert.equal(buyPartyName({ certType: 'purchase', buyerName: '购买方', consigneeName: '收货方' }), '购买方')
assert.equal(salePartyName({ certType: 'transport', sellerName: '销售方', shipperName: '货主' }), '货主')
assert.equal(buyPartyName({ certType: 'transport', buyerName: '购买方', consigneeName: '收货方' }), '收货方')
assert.equal(salePartyName({ certType: 'transport', salePartyName: '已汇总货主', shipperName: '货主' }), '已汇总货主')
})
test('contract certificate face fields stay beside the shared quantity labels', () => {
assert.deepEqual(
certFaceFields({
certType: 'transport',
shipperName: '货主',
consigneeName: '收货方',
validUseText: '一次有效',
validTo: '2026-11-04'
}).map((item) => item.label),
['销售单位', '购买单位', '有效次数', '是否预警']
)
const [seller, buyer, times, alert] = certFaceFields({
certType: 'transport',
shipperName: '货主',
consigneeName: '收货方',
validUseText: '一次有效',
validTo: '2026-11-04'
})
assert.equal(seller.value, '货主')
assert.equal(buyer.value, '收货方')
assert.equal(times.value, '一次有效')
assert.equal(alert.value, '今天到期')
assert.equal(alert.alert, true)
})
test('only certificates expiring within seven days show a red warning label', () => {
assert.equal(expiryWarning('2026-11-04', today)?.label, '今天到期')
assert.equal(expiryWarning('2026-11-11', today)?.label, '7天后到期')
assert.equal(expiryWarning('2026-11-03', today), null)
assert.equal(expiryWarning('2026-11-12', today), null)
assert.equal(expiryWarning('', today), null)
})
@@ -205,6 +205,8 @@ function harness(
submitPlanArrange: async (id: string) => {
writes.push(['arrange', id])
},
planQuotaApi: { get: async () => ({ stopProducts: [] }) },
toxicContractCertBindingMessage: () => '',
getUnitLabel: () => '',
...options.overrides
}
@@ -22,6 +22,7 @@ function harness() {
ElMessage: { warning() {}, success() {}, error() {} }, ElMessageBox: { alert: async () => {} },
tabsRef: { value: null }, emit() {}, pickBxData: (v: any) => v,
extractArchiveLineOverageDetail: () => null, applyArchiveOverageHighlight() {}, showArchiveLineOverageConfirm: async () => false,
showArchiveQuotaBlockDialog: async () => {},
planApi: { archive: async (id: string, data: any) => { calls.push({ id, data }); if(outcome instanceof Error) throw outcome; return outcome } }
}
const js = ts.transpileModule(`let archiveSession=0; let quotaArchiveAttempt={signature:'',id:''};${methods}; return {run:handleSubmit,submit:submitArchiveWithOverageConfirm,invalidate:()=>archiveSession++}`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
+287 -92
View File
@@ -7,6 +7,12 @@ import * as vue from 'vue'
import * as model from '../src/views/pl/quota-cert/model'
import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { getProductUnitLabel } from '../src/utils/crm-product-display'
import { buyPartyName, expiryWarning, salePartyName } from '../src/views/pl/quota-cert/cert-list-face'
import {
bindingsFromPurchaseCerts,
matchPurchaseCert
} from '../src/views/pl/quota-cert/contract-binder-rules'
function deferred() {
let resolve!: (value: any) => void
@@ -40,7 +46,18 @@ function harness() {
usePersistedListColumns,
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
useRoute: () => ({ query: {} }),
useRouter: () => ({ push() {}, replace() {} }),
useUserStore: () => user,
useDictStore: () => ({
getLabel: (_type: string, value: string) => (value === 'dun' ? '吨' : value || '—'),
getOptions: () => [{ value: 'dun', label: '吨', tagType: '' }]
}),
getProductUnitLabel,
buyPartyName,
expiryWarning,
salePartyName,
bindingsFromPurchaseCerts,
matchPurchaseCert,
onMounted: () => {},
onBeforeUnmount: () => {},
fetchGetMenuCodes: async () => ({ all: true }),
@@ -143,6 +160,70 @@ function harness() {
}
}
test('each contract link keeps its own tab, including repeated contracts', () => {
const h = harness()
try {
const tabs = h.state.contractTabItems(
[
{ id: 'link-1', contractId: 'c1', products: [] },
{ id: 'link-2', contractId: 'c1', products: [] },
{ id: 'link-3', contractId: 'c2', products: [] }
],
[
{ id: 'c1', contractNo: 'HT-1', contractName: '重复合同' },
{ id: 'c2', contractNo: 'HT-2', contractName: '另一合同' }
]
)
assert.deepEqual(
tabs.map((tab: { key: string; label: string }) => [tab.key, tab.label]),
[
['link-1', 'HT-1(1)'],
['link-2', 'HT-1(2)'],
['link-3', 'HT-2']
]
)
} finally {
h.stop()
}
})
test('replacing certificate products keeps the other certificates on the contract', () => {
const result = model.replaceCertProducts(
[
{ certType: 'transport', certId: 't', products: [{ contractProductId: 'keep' }] },
{
certType: 'purchase',
certId: 'p',
id: 'link',
products: [{ id: 'old', contractProductId: 'a' }]
}
],
'purchase',
'p',
[{ contractProductId: 'b' }]
)
assert.equal(result[0].certId, 't')
assert.deepEqual(result[0].products, [{ contractProductId: 'keep' }])
assert.equal(result[1].id, 'link')
assert.deepEqual(result[1].products, [{ contractProductId: 'b' }])
})
test('certificate quantities display three decimal places', () => {
assert.equal(model.formatCertQty('1700.000000'), '1700.000')
assert.equal(model.formatCertQty('0.000000'), '0.000')
assert.equal(model.formatCertQty('1.23456'), '1.235')
assert.equal(model.formatCertQty('9.9995'), '10.000')
assert.equal(model.formatCertQty('-1.2345'), '-1.235')
assert.equal(model.formatCertQty('-0.0004'), '0.000')
assert.equal(model.formatCertQty(''), '—')
assert.equal(model.formatCertQty(null), '—')
})
test('new certificate defaults to review after image parsing', () => {
assert.equal(model.newCert('purchase').entryMode, 'image_parse')
assert.equal(model.newCert('transport').entryMode, 'image_parse')
})
test('type changes immediately while options load and stale options cannot enter a later form', async () => {
const h = harness(),
s = h.state
@@ -257,6 +338,25 @@ test('single attachment is automatically selected, parsing uses form type and pr
}
})
test('unconfigured image parsing warns and keeps the manual entry', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.form.value.certNo = '手工证号'
s.form.value.attachmentUrls = 'one.png'
await vue.nextTick()
const parsing = s.recognize()
h.parses[0].reject(new Error('图片解析尚未配置,可继续手工录入'))
await parsing
assert.equal(s.form.value.certNo, '手工证号')
assert.equal(s.recognizing.value, false)
assert.ok(h.messages.includes('图片解析尚未配置,可继续手工录入'))
} finally {
h.stop()
}
})
test('missing, removed, multiple new attachments and uploads in progress never start parsing', async () => {
const h = harness(),
s = h.state
@@ -357,31 +457,48 @@ test('多合同一次保存且失败保留输入;受限可见关系只展示
}
})
test('产品勾选往返保留原范围ID;关闭重开后拒绝旧合同和产品请求', async () => {
test('确定产品时保留原范围身份,已用证不能增删产品', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
h.productRequests[0].resolve([{ id: 'p' }, { id: 'p2' }])
await vue.nextTick()
s.form.value.contractBindings[0].products = [
{ id: 'scope', contractProductId: 'p', scopeStatus: 'inactive' }
]
s.setBindingProducts(0, ['p', 'p2'])
s.acceptFormBinding({
contractId: 'c',
contractNo: 'HT',
contractName: '合同',
products: [
{ contractProductId: 'p', scopeStatus: 'inactive' },
{ contractProductId: 'p2', scopeStatus: 'active' }
]
})
assert.equal(s.form.value.contractBindings[0].products[0].id, 'scope')
assert.equal(s.form.value.contractBindings[0].products[0].scopeStatus, 'inactive')
const search = s.searchBindingContracts('old')
const products = s.loadBindingProducts('c')
s.formVisible.value = false
await s.edit()
s.form.value.attachmentUrls = 'one.png'
h.contractRequests.at(-1).resolve([{ id: 'stale', contractNo: '旧合同' }])
h.productRequests.forEach((r) => r.resolve([{ id: 'stale', productName: '旧产品' }]))
await Promise.all([search, products])
assert.deepEqual(s.bindingContracts.value, [])
assert.deepEqual(s.bindingProducts.value, {})
assert.equal(s.form.value.contractBindings[0].products[1].contractProductId, 'p2')
s.form.value.contractBindings[0].editLocked = true
s.acceptFormBinding({
contractId: 'c',
contractNo: 'HT',
contractName: '合同',
products: [{ contractProductId: 'p2', scopeStatus: 'active' }]
})
assert.equal(s.form.value.contractBindings[0].products.length, 2)
assert.ok(h.messages.some((message: string) => message.includes('不能增删产品')))
s.acceptFormBinding({
contractId: 'c',
contractNo: 'HT',
contractName: '合同',
products: [
{ contractProductId: 'p', scopeStatus: 'active' },
{ contractProductId: 'p2', scopeStatus: 'inactive' }
]
})
assert.equal(s.form.value.contractBindings[0].products[0].id, 'scope')
assert.equal(s.form.value.contractBindings[0].products[0].scopeStatus, 'active')
} finally {
h.stop()
}
@@ -411,19 +528,12 @@ test('移除后重选同一合同保留主关联和产品范围身份', async ()
}
})
test('后发合同搜索覆盖先发,租户切换使保存和读取旧结果失效', async () => {
test('租户切换使保存和读取旧结果失效', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
const first = s.searchBindingContracts('first'),
second = s.searchBindingContracts('second')
h.contractRequests[1].resolve([{ id: 'second', contractNo: 'SECOND', contractName: '' }])
await second
h.contractRequests[0].resolve([{ id: 'first', contractNo: 'FIRST', contractName: '' }])
await first
assert.equal(s.bindingContracts.value[0].id, 'second')
const pending = h.deferSave()
const saving = s.save()
await vue.nextTick()
@@ -449,13 +559,18 @@ test('新增多合同连同证面成功提交一次,移除全部提交明确
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('one')
s.addContractBinding('two')
h.productRequests[0].resolve([{ id: 'p1' }])
h.productRequests[1].resolve([{ id: 'p2' }])
await vue.nextTick()
s.setBindingProducts(0, ['p1'])
s.setBindingProducts(1, ['p2'])
s.acceptFormBinding({
contractId: 'one',
contractNo: 'ONE',
contractName: '合同一',
products: [{ contractProductId: 'p1', scopeStatus: 'active' }]
})
s.acceptFormBinding({
contractId: 'two',
contractNo: 'TWO',
contractName: '合同二',
products: [{ contractProductId: 'p2', scopeStatus: 'active' }]
})
s.form.value.certNo = 'MULTI'
await s.save()
assert.equal(h.writes.length, 1)
@@ -475,58 +590,7 @@ test('新增多合同连同证面成功提交一次,移除全部提交明确
}
})
test('同合同产品后发请求优先,移除重加使移除前请求失效', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
const second = s.loadBindingProducts('c')
h.productRequests[1].resolve([{ id: 'new', productName: '新产品' }])
await second
h.productRequests[0].resolve([{ id: 'old', productName: '旧产品' }])
await vue.nextTick()
assert.equal(s.bindingProducts.value.c[0].id, 'new')
const old = s.loadBindingProducts('c')
s.removeContractBinding(0)
s.addContractBinding('c')
h.productRequests[2].resolve([{ id: 'removed', productName: '移除前' }])
await old
assert.equal(s.bindingProducts.value.c, undefined)
assert.equal(s.bindingProductLoading.value.c, true)
h.productRequests[3].resolve([{ id: 'readded', productName: '重加后' }])
await vue.nextTick()
assert.equal(s.bindingProducts.value.c[0].id, 'readded')
} finally {
h.stop()
}
})
test('产品加载失败可再次展开重试,加载中不能改选择', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
s.form.value.attachmentUrls = 'one.png'
s.addContractBinding('c')
h.productRequests[0].reject(new Error('network'))
await vue.nextTick()
await vue.nextTick()
assert.equal(s.bindingProductLoading.value.c, false)
const retry = s.loadBindingProducts('c')
s.setBindingProducts(0, ['invalid'])
assert.deepEqual(s.form.value.contractBindings[0].products, [])
h.productRequests[1].resolve([{ id: 'p', productName: '产品' }])
await retry
s.setBindingProducts(0, ['p'])
assert.equal(s.form.value.contractBindings[0].products[0].contractProductId, 'p')
} finally {
h.stop()
}
})
test('inline binding adds an empty row, requires contract, and resets products on change', async () => {
test('未填写合同不能保存,确定另一份合同不会改掉已有合同', async () => {
const h = harness(),
s = h.state
try {
@@ -537,14 +601,15 @@ test('inline binding adds an empty row, requires contract, and resets products o
assert.equal(s.form.value.contractBindings[0].contractId, '')
await s.save()
assert.equal(h.writes.length, 0)
s.changeBindingContract(0, 'c1')
h.productRequests.at(-1).resolve([{ id: 'p1', productName: '产品1' }])
await Promise.resolve()
await Promise.resolve()
s.setBindingProducts(0, ['p1'])
s.changeBindingContract(0, 'c2')
assert.deepEqual(s.form.value.contractBindings[0].products, [])
assert.equal(s.form.value.contractBindings[0].contractId, 'c2')
s.acceptFormBinding({
contractId: 'c2',
contractNo: 'HT-2',
contractName: '另一合同',
products: [{ contractProductId: 'p2', scopeStatus: 'active' }]
})
assert.equal(s.form.value.contractBindings[1].contractId, 'c2')
assert.equal(s.form.value.contractBindings[1].products[0].contractProductId, 'p2')
assert.equal(s.form.value.contractBindings[0].contractId, '')
} finally {
h.stop()
}
@@ -570,12 +635,43 @@ test('referenced bindings and references cannot be edited or removed', async ()
await s.edit({ id: 'cert' })
s.removeContractBinding(0)
assert.equal(s.form.value.contractBindings.length, 1)
s.changeBindingContract(0, 'other')
assert.equal(s.form.value.contractBindings[0].contractId, 'c')
s.acceptFormBinding({
contractId: 'c',
contractNo: 'HT',
contractName: '合同',
products: [{ contractProductId: 'other', scopeStatus: 'active' }]
})
assert.equal(s.form.value.contractBindings[0].products[0].contractProductId, 'p1')
s.removeReference(0)
s.addReference()
assert.equal(s.form.value.purchaseRefs.length, 1)
assert.ok(h.messages.some((m) => m.includes('联系管理员')))
assert.ok(h.messages.some((m) => m.includes('不能新增或移除证面引用')))
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
} finally {
h.stop()
}
})
test('linked purchase archive stays locked while an empty archive can be completed', async () => {
const h = harness(),
s = h.state
try {
h.setCert({
referencesEditable: false,
purchaseRefs: [
{ id: 'open', referenceType: 'unknown', referenceNo: 'G35', purchaseCertId: null },
{ id: 'linked', referenceType: 'purchase_permit', referenceNo: 'G1', purchaseCertId: 'p1' }
]
})
s.kind.value = 'transport'
await s.edit({ id: 'cert' })
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[1]), true)
s.purchases.value = [{ id: 'buy', certNo: 'G34260485131004', certType: 'purchase' }]
s.form.value.purchaseRefs[0].purchaseCertId = 'buy'
s.onPurchaseArchiveChange()
assert.equal(s.form.value.purchaseRefs[0].referenceNo, 'G35')
assert.equal(s.referenceRowLocked(s.form.value.purchaseRefs[0]), false)
} finally {
h.stop()
}
@@ -700,3 +796,102 @@ test('saving while transport purchase candidates load refreshes candidates for t
h.stop()
}
})
test('transport recognition copies contracts and products from the matched purchase cert', async () => {
const h = harness(),
s = h.state
try {
await s.edit()
await s.changeFormKind('transport')
s.form.value.attachmentUrls = 'cert.pdf'
await vue.nextTick()
h.setCert({
contractBindings: [
{
id: 'purchase-link',
contractId: 'c1',
linkStatus: 'active',
products: [{ id: 'purchase-scope', contractProductId: 'p1', scopeStatus: 'active' }]
}
]
})
const options = h.deferOptions()
const parsing = s.recognize()
h.parses[0].resolve({
candidates: {
purchaseRefs: [{ referenceType: 'unknown', referenceNo: 'G34260671919592' }]
},
invalidFields: {}
})
options.resolve([{ id: 'buy-1', certNo: 'G34260671919592' }])
await parsing
assert.equal(s.form.value.purchaseRefs[0].purchaseCertId, 'buy-1')
assert.equal(s.form.value.contractBindings.length, 1)
assert.equal(s.form.value.contractBindings[0].contractId, 'c1')
assert.equal(s.form.value.contractBindings[0].products[0].contractProductId, 'p1')
assert.equal(s.form.value.contractBindings[0].products[0].id, undefined)
assert.equal(
s.bindingContracts.value.some((contract: { id: string }) => contract.id === 'old'),
true
)
} finally {
h.stop()
}
})
test('deleting the certificate attachment clears face, bindings and references', () => {
const h = harness(),
s = h.state
try {
s.formKind.value = 'transport'
s.form.value = {
...model.newCert('transport'),
entryMode: 'manual',
attachmentUrls: 'one.png',
remark: '保留备注',
orderNum: 3,
status: 'active',
certNo: 'Y1',
productNameOnCert: '硫酸',
totalQty: '10',
vehicleNumbersJson: ['皖A1'],
purchaseRefs: [{ referenceNo: 'G1' }],
contractBindings: [{ contractId: 'c1', linkStatus: 'active', products: [{ id: 'p1' }] }],
bindingsEditable: true,
referencesEditable: false
}
s.edited.add('certNo')
s.bindingContracts.value = [{ id: 'c1', contractNo: 'HT-1' }]
s.lockedPlates.value = ['皖A1']
s.linkedRefIds.value = ['ref-1']
s.onCertificateAttachmentChange('one.png')
assert.equal(s.form.value.certNo, 'Y1')
assert.equal(s.bindingEpoch.value, 0)
s.onCertificateAttachmentChange('')
assert.equal(s.form.value.certNo, '')
assert.equal(s.form.value.productNameOnCert, '')
assert.equal(s.form.value.totalQty, '')
assert.deepEqual(s.form.value.vehicleNumbersJson, [])
assert.deepEqual(s.form.value.purchaseRefs, [])
assert.deepEqual(s.form.value.contractBindings, [])
assert.equal(s.form.value.remark, '保留备注')
assert.equal(s.form.value.entryMode, 'manual')
assert.equal(s.form.value.orderNum, 3)
assert.equal(s.form.value.status, 'active')
assert.equal(s.form.value.attachmentUrls, '')
assert.equal(s.form.value.referencesEditable, false)
assert.equal(s.edited.has('certNo'), false)
assert.deepEqual(s.bindingContracts.value, [])
assert.deepEqual(s.lockedPlates.value, [])
assert.deepEqual(s.linkedRefIds.value, [])
assert.equal(s.bindingEpoch.value, 1)
const archive = readFileSync(
new URL('../src/views/m/pl/plan-cert/archive-form.vue', import.meta.url),
'utf8'
)
assert.match(archive, /onCertificateAttachmentChange/)
assert.match(archive, /clearCertAfterAttachmentRemoved/)
} finally {
h.stop()
}
})
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { toxicArrangeBlockMessage } from '../src/utils/pl-quota-cert-waiver'
const unbound = '合同HT-1尚未绑定购买证和运输证,请先到合同中绑定后再安排运输'
test('marked plans ignore the local certificate gap and keep source errors', () => {
assert.equal(toxicArrangeBlockMessage({ quotaCertWaived: true, arrangeBlockMessage: '' }, unbound), '')
assert.equal(
toxicArrangeBlockMessage({ quotaCertWaived: true, arrangeBlockMessage: '这一段还没有关联合同' }, unbound),
'这一段还没有关联合同'
)
})
test('unmarked plans still block on the server message or the local certificate gap', () => {
assert.equal(toxicArrangeBlockMessage({ arrangeBlockMessage: '证件余量不足' }, unbound), '证件余量不足')
assert.equal(toxicArrangeBlockMessage({ arrangeBlockMessage: '' }, unbound), unbound)
assert.equal(toxicArrangeBlockMessage(null, unbound), unbound)
})
@@ -77,3 +77,21 @@ test('existing usage table exposes each certificate quantity unit alongside rese
assert.equal(visible[0].quantityUnit, '吨')
assert.equal(visible[1].quantityUnit, '千克')
})
test('viewing certificate usage lists every stop and leaves opposite pairing to submission', () => {
const dialog = parse(
readFileSync(new URL('../src/components/business/pl-plan-quota-dialog/index.vue', import.meta.url), 'utf8')
).descriptor.template!.content
const mobile = parse(
readFileSync(new URL('../src/views/m/pl/plan-cert/CertFacePanel.vue', import.meta.url), 'utf8')
).descriptor.template!.content
const heading = dialog.indexOf('全部装送段用证需求')
const boundary = dialog.indexOf('<section v-else', heading)
const view = dialog.slice(heading, boundary)
assert.equal(dialog.includes('v-if="viewOnly && isPerStop"'), false)
assert.match(view, /:data="plan\.sourceOptions"/)
assert.doesNotMatch(view, /原口径|勾选对应另一侧/)
assert.match(dialog.slice(boundary), /勾选对应另一侧产品行(必选)/)
assert.doesNotMatch(mobile, /原口径:唯一计量侧与装送配对|勾选对应另一侧产品行/)
assert.match(mobile, /v-for="row in sourceOptions"/)
})
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ContractStatistics } from '../src/api/pl/quota-cert'
import { certDetailStatus, contractProductRows } from '../src/views/m/pl/plan-cert/contract-usage-model'
test('contract certificate products keep the web by-product columns', () => {
const detail = {
certificates: [
{
certType: 'purchase',
certId: 'cert',
certNo: 'G-1',
quantityUnit: 'ton',
totalQty: '10',
reservedQty: '2',
usedQty: '1',
availableQty: '7',
sellerName: '销售方',
buyerName: '购买方',
validUseText: '多次有效',
validTo: '2026-12-01',
products: [
{
id: 'scope',
contractProductId: 'product',
productName: '硫酸',
model: '98%',
unit: 'ton',
reservedQty: '2',
usedQty: '1',
scopeStatus: 'inactive'
}
]
}
]
} as ContractStatistics
const [row] = contractProductRows(detail)
assert.equal(row.productName, '硫酸')
assert.equal(row.model, '98%')
assert.equal(row.certKind, '购买证')
assert.equal(row.certNo, 'G-1')
assert.equal(row.totalQty, '10')
assert.equal(row.salePartyName, '销售方')
assert.equal(row.buyPartyName, '购买方')
assert.equal(row.validUseText, '多次有效')
assert.equal(row.expiryAlert, '')
assert.equal(row.availableQty, '7')
assert.equal(row.scopeStatus, 'inactive')
assert.equal(certDetailStatus('active'), '证件已启用')
assert.equal(certDetailStatus('void'), '证件已作废')
})
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const desktop = readFileSync(new URL('../src/views/pl/plan-quota/index.vue', import.meta.url), 'utf8')
const mobile = readFileSync(new URL('../src/views/m/pl/plan-cert/PlanTab.vue', import.meta.url), 'utf8')
test('plan cert lists mark a conversion row and keep the view action', () => {
for (const source of [desktop, mobile]) {
assert.match(source, /needsUnitConversion/)
assert.match(source, /需单位换算/)
assert.match(source, /查看用证/)
}
assert.match(desktop, /quota-conversion-row/)
assert.match(mobile, /is-conversion/)
assert.match(desktop, /查看全部/)
assert.match(desktop, /conversionOnly: showAll\.value \? undefined : true/)
const shell = readFileSync(new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url), 'utf8')
assert.match(shell, /查看全部/)
assert.match(shell, /show-all="showAllPlans"/)
assert.match(mobile, /conversionOnly: props\.showAll \? undefined : true/)
})
+102 -13
View File
@@ -7,6 +7,13 @@ import * as vue from 'vue'
import { useClientColumnFilters } from '../src/composables/useClientColumnFilters'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { getProductUnitLabel } from '../src/utils/crm-product-display'
import { buyPartyName, expiryWarning, salePartyName } from '../src/views/pl/quota-cert/cert-list-face'
import {
PL_PLAN_FOLLOW_STATUS_OPTIONS,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType
} from '../src/utils/pl-display'
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve()
@@ -46,12 +53,24 @@ function pageHarness(kind: 'quota' | 'legal' | 'cert', props: any = { mobile: tr
useServerColumnFilters,
usePersistedListColumns,
useRoute: () => ({ query: {} }),
useRouter: () => ({ push() {}, replace() {} }),
newCert: () => ({}),
commonFields: [],
purchaseFields: [],
transportFields: [],
quotaCertApi: { list: (kind: string) => queue(lists, { kind }) },
useUserStore: () => user,
useDictStore: () => ({
getLabel: (_type: string, value: string) => (value === 'dun' ? '吨' : value || '—'),
getOptions: () => [{ value: 'dun', label: '吨', tagType: '' }]
}),
getProductUnitLabel,
buyPartyName,
expiryWarning,
salePartyName,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
defineProps: () => props,
onMounted: (fn: Function) => mounted.push(fn),
onBeforeUnmount: (fn: Function) => unmount.push(fn),
@@ -123,9 +142,11 @@ for (const kind of ['cert'] as const) {
s = h.state
const first = s.load(),
second = s.load()
h.lists[1].resolve([{ id: 'new' }])
h.lists[2].resolve([{ id: 'new', createTime: '2026-02-01' }])
h.lists[3].resolve([])
await second
h.lists[0].resolve([{ id: 'old' }])
h.lists[0].resolve([{ id: 'old', createTime: '2026-01-01' }])
h.lists[1].resolve([])
await first
assert.equal(s.rows.value[0].id, 'new')
h.stop()
@@ -155,9 +176,10 @@ for (const kind of ['cert'] as const) {
}
]
}
test(`${kind}: filters operate on full rows before pagination, combine, reset and clear hidden columns`, async () => {
test(`${kind}: filters operate on full rows before pagination, combine, clear and hide columns`, async () => {
const h = pageHarness(kind),
s = h.state
if (kind === 'cert') s.expiringSoon.value = false
s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i))
s.page.value = 3
s.setFilterState(kind === 'cert' ? 'productNameOnCert' : 'productNames', { text: '目标' })
@@ -178,7 +200,7 @@ for (const kind of ['cert'] as const) {
s.pageRows.value.map((r: any) => r.id),
['25']
)
s.reset()
s.clearFilter('productNameOnCert')
assert.equal(s.filteredRows.value.length, 45)
s.keyword.value = kind === 'cert' ? 'CERT-25' : 'PLAN-25'
s.page.value = 2
@@ -190,6 +212,7 @@ for (const kind of ['cert'] as const) {
)
const refresh = s.load()
h.lists[0].resolve(Array.from({ length: 45 }, (_, i) => fixture(i)))
h.lists[1].resolve([])
await refresh
assert.deepEqual(
s.pageRows.value.map((r: any) => r.id),
@@ -200,6 +223,7 @@ for (const kind of ['cert'] as const) {
test(`${kind}: numeric and enum filters compare business values`, () => {
const h = pageHarness(kind),
s = h.state
if (kind === 'cert') s.expiringSoon.value = false
s.rows.value = Array.from({ length: 45 }, (_, i) => fixture(i))
s.setFilterState(kind === 'cert' ? 'availableQty' : 'pendingCount', {
op: 'eq',
@@ -233,6 +257,7 @@ for (const kind of ['cert'] as const) {
h.permissions[0].resolve({ all: true })
await init
h.lists[0].resolve([fixture(1)])
h.lists[1].resolve([])
await pending
assert.notEqual(s.rows.value[0]?.id, '1')
assert.equal((kind === 'cert' ? s.canEdit : s.canUpload).value, false)
@@ -240,17 +265,81 @@ for (const kind of ['cert'] as const) {
})
}
}
test('cert: changing kind immediately invalidates an old purchase response', async () => {
test('cert: seven-day switch keeps purchase and transport certs inside the warning window', () => {
const h = pageHarness('cert'),
s = h.state
const pending = s.load()
s.kind.value = 'transport'
assert.equal(h.lists[1].params.kind, 'transport')
h.lists[1].resolve([{ id: 'transport' }])
await flush()
h.lists[0].resolve([{ id: 'purchase' }])
await pending
assert.equal(s.rows.value[0].id, 'transport')
assert.equal(s.expiringSoon.value, false)
assert.equal(s.expiringCount.value, 0)
const day = (offset: number) => {
const date = new Date()
date.setHours(12, 0, 0, 0)
date.setDate(date.getDate() + offset)
const month = String(date.getMonth() + 1).padStart(2, '0')
const value = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${value}`
}
const row = (id: string, certType: string, validTo: string) => ({
id,
certType,
certNo: id,
status: 'active',
validTo,
totalQty: '1',
reservedQty: '0',
usedQty: '0',
availableQty: '1'
})
s.rows.value = [
row('purchase-soon', 'purchase', day(3)),
row('transport-edge', 'transport', day(7)),
row('later', 'purchase', day(8)),
row('expired', 'transport', day(-1))
]
s.expiringSoon.value = true
assert.equal(s.expiringCount.value, 2)
assert.deepEqual(
s.pageRows.value.map((item: { id: string }) => item.id).sort(),
['purchase-soon', 'transport-edge']
)
s.expiringSoon.value = false
assert.equal(s.filteredRows.value.length, 4)
const mobile = readFileSync(
new URL('../src/views/m/pl/plan-cert/index.vue', import.meta.url),
'utf8'
)
assert.match(mobile, /const expiringSoon = ref\(false\)/)
assert.match(mobile, /近7日到期(\{\{ expiringCount \}\}个)/)
assert.doesNotMatch(mobile, /van-switch/)
const web = readFileSync(new URL('../src/views/pl/quota-cert/index.vue', import.meta.url), 'utf8')
assert.match(web, /const expiringSoon = ref\(false\)/)
assert.match(web, /近7日到期(\{\{ expiringCount \}\}个)/)
assert.match(web, /color: #dc2626/)
h.stop()
})
test('cert: list merges both kinds and the type funnel filters one side', async () => {
const h = pageHarness('cert'),
s = h.state
const first = s.load()
const second = s.load()
h.lists[2].resolve([{ id: 'p2', createTime: '2026-01-02' }])
h.lists[3].resolve([{ id: 't2', createTime: '2026-01-03' }])
await second
h.lists[0].resolve([{ id: 'p1', createTime: '2026-01-01' }])
h.lists[1].resolve([{ id: 't1', createTime: '2026-01-01' }])
await first
assert.deepEqual(
s.rows.value.map((row: { id: string; certType: string }) => [row.id, row.certType]),
[
['t2', 'transport'],
['p2', 'purchase']
]
)
s.expiringSoon.value = false
s.setFilterState('certType', { text: '', selected: ['purchase'] })
assert.deepEqual(
s.pageRows.value.map((row: { id: string }) => row.id),
['p2']
)
h.stop()
})
test('plan: actual header filters send server conditions, reset page, preserve refresh and clear hidden conditions', async () => {
+34 -13
View File
@@ -51,13 +51,16 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) {
planQuotaApi: { get: (id: string) => queue(gets, id) },
legalDocumentApi: {
list: (params: any) => queue(lists, params),
listStops: (params: any) => queue(lists, params),
get: (id: string) => queue(gets, id),
getStops: (id: string) => queue(gets, id),
save: (id: string, products: any) => queue(writes, { id, products })
},
fetchGetMenuCodes: () => queue(permissions),
ElMessage: {
warning: (msg: string) => events.push(msg),
success: (msg: string) => events.push(msg)
success: (msg: string) => events.push(msg),
error: (msg: string) => events.push(msg)
}
}
const path =
@@ -102,8 +105,7 @@ function pageHarness(kind: 'quota' | 'legal', props: any = { mobile: true }) {
}
}
}
const response = (kind: string, id: string) =>
kind === 'quota' ? { list: [{ id }], total: 1 } : [{ id }]
const response = (_kind: string, id: string) => ({ list: [{ id }], total: 1 })
const changeScope = (h: ReturnType<typeof pageHarness>, change: string) => {
if (change === 'token') h.user.accessToken = 'b'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
@@ -113,6 +115,7 @@ for (const kind of ['quota', 'legal'] as const) {
test(`${kind}: real query sends keyword/status and latest response wins`, async () => {
const h = pageHarness(kind),
s = h.state
if (kind === 'legal') assert.equal(s.status.value, 'pending')
s.keyword.value = 'PLAN-A'
s.status.value = kind === 'quota' ? 'reserved' : 'pending'
const first = s.load()
@@ -134,10 +137,14 @@ for (const kind of ['quota', 'legal'] as const) {
s = h.state
if (kind === 'legal') {
const init = s.init()
h.permissions[0].resolve({ menuCodes: ['pl:plan-cert:upload'] })
h.permissions[0].resolve({
menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload']
})
await flush()
h.lists[0].resolve([{ id: 'private' }])
h.lists[0].resolve({ list: [{ id: 'private' }], total: 1 })
await init
assert.equal(s.allowed.value, true)
assert.equal(s.canUpload.value, true)
} else s.rows.value = [{ id: 'private' }]
const old = s.load(),
oldRequest = h.lists.at(-1)
@@ -151,7 +158,7 @@ for (const kind of ['quota', 'legal'] as const) {
assert.equal(s.detail.value, undefined)
assert.equal(s.visible.value, false)
assert.equal(s.canUpload.value, false)
h.permissions.at(-1).resolve({ menuCodes: [] })
h.permissions.at(-1).resolve({ menuCodes: ['pl:legal-use-document:list'] })
await flush()
}
const fresh = h.lists.at(-1)
@@ -180,7 +187,7 @@ test('legal: stale list failure cannot overwrite successful newer search', async
s = h.state
const old = s.load(),
fresh = s.load()
h.lists[1].resolve([{ id: 'current' }])
h.lists[1].resolve({ list: [{ id: 'current' }], total: 1 })
await fresh
h.lists[0].reject(new Error('stale network error'))
await old
@@ -197,7 +204,7 @@ test('legal: failed list can be retried and empty success is distinct from failu
assert.ok(s.error.value)
assert.equal(s.loading.value, false)
const retry = s.load()
h.lists[1].resolve([])
h.lists[1].resolve({ list: [], total: 0 })
await retry
assert.equal(s.error.value, '')
assert.deepEqual(s.rows.value, [])
@@ -228,7 +235,8 @@ for (const change of ['token', 'tenant', 'roles']) {
const init = s.init(),
detail = s.open('private')
s.canUpload.value = true
s.detail.value = { id: 'private', products: [{ id: 'line', legalUseCertUrl: 'attachment' }] }
s.draft.value = 'attachment'
s.detail.value = { id: 'private', planProductIds: ['line'] }
const save = s.save()
assert.deepEqual(h.writes[0].params, {
id: 'private',
@@ -261,7 +269,8 @@ test('legal: duplicate save and active uploads are guarded; session clears old u
assert.equal(h.events.length, 1)
changeScope(h, 'tenant')
s.canUpload.value = true
s.detail.value = { id: 'B', products: [{ id: 'new-line', legalUseCertUrl: 'new-url' }] }
s.draft.value = 'new-url'
s.detail.value = { id: 'B', planProductIds: ['new-line'] }
const save = s.save()
await s.save()
assert.equal(h.writes.length, 1)
@@ -321,17 +330,29 @@ test('legal: read-only permission prevents writes even when detail contains atta
s = h.state
const init = s.init()
h.permissions[0].resolve({ menuCodes: [] })
await flush()
h.lists[0].resolve([])
await init
assert.equal(h.lists.length, 0)
assert.equal(s.allowed.value, false)
assert.equal(s.canUpload.value, false)
const detail = s.open('A')
h.gets[0].resolve({ id: 'A', products: [{ id: 'line', legalUseCertUrl: 'url' }] })
h.gets[0].resolve({ id: 'A', planProductIds: ['line'], legalUseCertUrl: 'url' })
await detail
assert.equal(s.visible.value, true)
await s.save()
assert.equal(h.writes.length, 0)
h.stop()
})
test('legal: upload permission without list permission does not load plans', async () => {
const h = pageHarness('legal'),
s = h.state
const init = s.init()
h.permissions[0].resolve({ menuCodes: ['pl:plan-cert:upload'] })
await init
assert.equal(s.allowed.value, false)
assert.equal(s.canUpload.value, false)
assert.equal(h.lists.length, 0)
h.stop()
})
test('plan expansion loads on demand, preserves total during refresh and ignores previous tenant', async () => {
const h = pageHarness('quota', { mobile: false }),
+48 -9
View File
@@ -93,6 +93,7 @@ function harness(fixed?: string) {
defineProps: () => props,
withDefaults: (v: any) => v,
useUserStore: () => user,
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: () => {
const p = deferred()
permissions.push(p)
@@ -144,24 +145,33 @@ function harness(fixed?: string) {
}
}
}
test('five leaves retain IDs and individual permissions', () => {
const group = plRoutes[0].children!.find((r) => r.id === 'PL_GROUP_CERT')!
test('cert tabs keep their identities and legal proof is a sibling menu', () => {
const children = plRoutes[0].children!
const group = children.find((r) => r.id === 'PL_GROUP_CERT')!
const legal = children.find((r) => r.id === 'PL_GROUP_LEGAL')!
assert.equal(group.meta!.title, '运输证件管理')
assert.ok(children.indexOf(legal) === children.indexOf(group) + 1)
assert.equal(legal.meta!.title, '合法使用证明')
assert.deepEqual(
group.children!.map((r) => r.path),
['quota-cert', 'contract-quota', 'certificate-usage', 'plan-cert', 'legal-use-document']
group.children!.filter((r) => !r.meta?.isHide).map((r) => r.path),
['quota-cert', '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.deepEqual(
group.children!.map((r) => r.meta!.menuCode),
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:plan-cert:list',
'pl:legal-use-document:list'
'pl:plan-cert:list'
]
)
assert.equal(legal.children![0].id, 'PL_LEGAL_USE_DOCUMENT')
assert.equal(legal.children![0].meta!.menuCode, 'pl:legal-use-document:list')
const reminder = children.find((r) => r.id === 'PL_GROUP_DOC')!
assert.equal(reminder.meta!.title, '人车证件提醒')
})
test('actual fixed panel loads only current contract and drops old response', async () => {
const h = harness('one')
@@ -345,8 +355,23 @@ test('usage trace renders business columns and resolves contract/product by thei
const rows = new Function(
'defineProps',
'computed',
'defineEmits',
'ref',
'watch',
'withDefaults',
`${code}; return typeof usageRows === 'undefined' ? undefined : usageRows.value`
)(() => ({ mode: 'certificate', detail }), vue.computed)
)(
() => ({ mode: 'certificate', detail }),
vue.computed,
() => () => {},
vue.ref,
vue.watch,
(_props: unknown, defaults: Record<string, unknown>) => ({
...defaults,
mode: 'certificate',
detail
})
)
assert.ok(rows, '追踪表应提供可读的业务列')
assert.deepEqual(
rows.map((r: any) => [r.contractNo, r.productModel, r.partyName, r.address]),
@@ -379,7 +404,8 @@ test('quick search and number header filter are sent as independent conditions',
h.stop()
})
import { bindingPayload } from '../src/views/pl/quota-cert/model'
import { bindingPayload, formatCertQty } from '../src/views/pl/quota-cert/model'
import { getProductUnitLabel } from '../src/utils/crm-product-display'
function scopeDialogHarness() {
const calls: any[] = [],
unmount: Function[] = []
@@ -423,6 +449,11 @@ function scopeDialogHarness() {
quotaCertApi: api,
quotaStatisticsApi: { contract: (...args: any[]) => queue('contract', args) },
bindingPayload,
formatCertQty,
getProductUnitLabel,
useDictStore: () => ({
getLabel: (_type: string, value: string) => (value === 'dun' ? '吨' : value || '—')
}),
ElMessage: { success: () => {} }
}
const scope = vue.effectScope()
@@ -564,11 +595,18 @@ test('refresh and search while permission request is pending do not cancel initi
await s.load()
s.keyword.value = '目标'
s.setFilterState('contractNo', { text: 'HT' })
s.setFilterState('contractName', { text: '名称' })
assert.equal(h.calls.length, 0)
h.permissions[0].resolve({ menuCodes: ['pl:quota-cert:list'] })
await flush()
assert.equal(h.calls.length, 1)
assert.deepEqual(h.calls[0].args[0], { page: 1, pageSize: 20, keyword: '目标', contractNo: 'HT' })
assert.deepEqual(h.calls[0].args[0], {
page: 1,
pageSize: 20,
keyword: '目标',
contractNo: 'HT',
contractName: '名称'
})
h.calls[0].resolve({ list: [{ id: 'loaded' }], total: 1 })
await flush()
assert.equal(s.rows.value[0].id, 'loaded')
@@ -619,6 +657,7 @@ for (const wrapper of [
const calls: string[] = []
const dependencies = {
useUserStore: () => vue.reactive({ accessToken: 'token', getUserInfo: { tenantNo: 'a' } }),
useRouter: () => ({ push() {}, replace() {} }),
fetchGetMenuCodes: async () => {
calls.push('permissions')
return { all: true }
+65 -4
View File
@@ -4,7 +4,10 @@ import {
measuredSources,
configurationPayload,
sourceLabel,
stopUsageRows
stopUsageRows,
unitConversions,
coefficientFromQuantities,
permitQuantityFromCoefficient
} from '../src/components/business/pl-plan-quota-dialog/stop-model'
const load: any = {
sourceStopType: 'load',
@@ -74,8 +77,8 @@ test('all stop products are visible and each certificate keeps its unit and quan
}
const rows = stopUsageRows(data)
assert.equal(rows.length, 3)
assert.equal(rows[0].purchase, 'BUY · 预占 100 吨 · 已用 0 吨')
assert.equal(rows[0].transport, 'MOVE · 预占 100000 千克 · 已用 0 千克')
assert.equal(rows[0].purchase, 'BUY · 预占 100.000 吨 · 已用 0.000 吨')
assert.equal(rows[0].transport, 'MOVE · 预占 100000.000 千克 · 已用 0.000 千克')
assert.equal(rows[1].purchase, '缺配')
assert.equal(rows[2].purchase, '无需两证')
})
@@ -92,7 +95,7 @@ test('draft bindings show zeros in their own units and hidden scope never claims
{ ...delivery, isToxic: true, bindingsReadable: false }
]
})
assert.equal(rows[0].purchase, 'BUY · 已绑定 · 预占 0 桶 · 已用 0 桶')
assert.equal(rows[0].purchase, 'BUY · 已绑定 · 预占 0.000 桶 · 已用 0.000 桶')
assert.equal(rows[1].purchase, '合同或证件不可读取')
})
test('legacy counterpart is traceability only, never a second missing allocation', () => {
@@ -107,3 +110,61 @@ test('legacy counterpart is traceability only, never a second missing allocation
}
assert.equal(stopUsageRows(legacy as any)[1].purchase, '原口径:关联追溯,不另计量')
})
test('unit conversion is only required when the plan unit differs from a bound certificate', () => {
const same = unitConversions(
{
...plan,
stopProducts: [
{
...load,
certificates: [{ certType: 'purchase', certId: 'buy', certNo: 'BUY', quantityUnit: '吨' }]
}
]
},
[load]
)
const different = unitConversions(
{
...plan,
stopProducts: [
{
...load,
certificates: [
{ certType: 'purchase', certId: 'buy', certNo: 'BUY', quantityUnit: '吨' },
{ certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' }
]
}
]
},
[load]
)
assert.deepEqual(same, [])
assert.deepEqual(
different.map((row) => row.cert.certNo),
['MOVE']
)
const labelAndCode = unitConversions(
{
...plan,
stopProducts: [
{
...load,
certificates: [
{ certType: 'purchase', certId: 'buy', certNo: 'BUY', quantityUnit: '吨' },
{ certType: 'transport', certId: 'move', certNo: 'MOVE', quantityUnit: '千克' }
]
}
]
},
[{ ...load, unit: 'dun' }]
)
assert.deepEqual(
labelAndCode.map((row) => row.cert.certNo),
['MOVE']
)
})
test('conversion coefficient scales the certificate quantity and can be recovered', () => {
assert.equal(permitQuantityFromCoefficient(30, '1000'), '30000')
assert.equal(permitQuantityFromCoefficient(30, '0'), '')
assert.equal(coefficientFromQuantities('30.000', '30000'), '1000')
})
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { PlanQuota, QuotaSourceOption } from '../src/api/pl/quota-cert'
import {
initialLegacySelection,
measuredSources
} from '../src/components/business/pl-plan-quota-dialog/stop-model'
function source(partial: Partial<QuotaSourceOption> & Pick<QuotaSourceOption, 'sourceStopType' | 'sourceStopProductId' | 'planProductId'>): QuotaSourceOption {
return {
productName: '硫酸',
quantity: '1',
unit: '吨',
contractId: '',
contractProductId: '',
...partial
}
}
function plan(partial: Partial<PlanQuota>): PlanQuota {
return {
planId: 'plan',
planNo: 'PO',
configuration: {},
sourceOptions: [],
usages: [],
...partial
}
}
test('legacy plan restores the saved measuring side and the opposite pairing', () => {
const value = plan({
measurementVersion: 'legacy_single_side',
sourceOptions: [
source({ sourceStopType: 'load', sourceStopProductId: 'L', planProductId: 'P', partyName: '装货方' }),
source({ sourceStopType: 'delivery', sourceStopProductId: 'D', planProductId: 'P', partyName: '送货方' })
],
configuration: {
sourceMapping: [
{ sourceStopType: 'load', sourceStopProductId: 'L', relatedStopProductIds: ['D'] }
]
}
})
const selection = initialLegacySelection(value)
assert.equal(selection.sides.P, 'load')
assert.deepEqual(selection.pairs.L, ['D'])
assert.deepEqual(
measuredSources(value, selection.sides).map((row) => row.sourceStopProductId),
['L']
)
})
test('legacy plan with both sides and no saved mapping waits for a choice', () => {
const value = plan({
sourceOptions: [
source({ sourceStopType: 'load', sourceStopProductId: 'L', planProductId: 'P' }),
source({ sourceStopType: 'delivery', sourceStopProductId: 'D', planProductId: 'P' })
]
})
const selection = initialLegacySelection(value)
assert.equal(selection.sides.P, '')
assert.deepEqual(measuredSources(value, selection.sides), [])
})
test('a single delivery side is selected, and per-stop plans keep every side', () => {
const legacy = plan({
sourceOptions: [source({ sourceStopType: 'delivery', sourceStopProductId: 'D', planProductId: 'P' })]
})
assert.equal(initialLegacySelection(legacy).sides.P, 'delivery')
const current = plan({
measurementVersion: 'per_stop_v2',
sourceOptions: [
source({ sourceStopType: 'load', sourceStopProductId: 'L', planProductId: 'P' }),
source({ sourceStopType: 'delivery', sourceStopProductId: 'D', planProductId: 'P' })
]
})
assert.deepEqual(
measuredSources(current, {}).map((row) => row.sourceStopProductId),
['L', 'D']
)
})
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { CertificateStatistics } from '../src/api/pl/quota-cert'
import { certListStatus, linkLabel, scopeStatusLabel, usageTrackingRows } from '../src/views/m/pl/plan-cert/usage-model'
test('certificate usage rows keep the contract number, model, party and address from the web detail', () => {
const detail = {
contracts: [
{
id: 'contract',
contractNo: 'SX-1',
products: [{ contractProductId: 'product', model: '98%' }]
}
],
usageItems: [
{
id: 'line',
contractId: 'contract',
contractProductId: 'product',
planNo: 'PO-1',
stopType: 'delivery',
productName: '硫酸',
partyName: '送货方',
address: '仓库',
itemStatus: 'settled'
}
]
} as CertificateStatistics
const [row] = usageTrackingRows(detail)
assert.equal(row.contractNo, 'SX-1')
assert.equal(row.productModel, '98%')
assert.equal(row.partyName, '送货方')
assert.equal(row.address, '仓库')
assert.equal(certListStatus(row.itemStatus), '已核销')
assert.equal(linkLabel('active'), '可安排用证')
assert.equal(linkLabel('inactive'), '停止新增使用')
assert.equal(scopeStatusLabel(null), '历史解绑')
})
+106 -96
View File
@@ -6,6 +6,11 @@ import ts from 'typescript'
import * as vue from 'vue'
import { usePersistedListColumns } from '../src/composables/usePersistedListColumns'
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
import {
PL_PLAN_FOLLOW_STATUS_OPTIONS,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType
} from '../src/utils/pl-display'
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve()
}
@@ -34,7 +39,7 @@ function harness(fixed?: string) {
return pending.promise
}
const api = Object.fromEntries(
['listStops', 'getStops', 'saveStops'].map((method) => [
['listStops', 'getStops', 'save'].map((method) => [
method,
(...args: any[]) => queue(method, args)
])
@@ -55,8 +60,11 @@ function harness(fixed?: string) {
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) }),
usePersistedListColumns,
useServerColumnFilters,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
legalDocumentApi: api,
ElMessage: { success() {}, warning() {} },
ElMessage: { success() {}, warning() {}, error() {} },
onBeforeUnmount: (fn: () => void) => unmount.push(fn)
}
const src = ts.createSourceFile(
@@ -81,7 +89,7 @@ function harness(fixed?: string) {
const state = scope.run(() =>
new Function(
...Object.keys(env),
`${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, open, save, visible, editing, upload, saving, editorLoading, status, search, statusLabel, reset, visibleColumns }`
`${code}; return { rows, details, load, loadDetail, allowed, canUpload, keyword, setFilterState, page, total, expansionStyle, savePlan, onCertDraft, drafts, uploaders, saving, status, search, statusLabel, reset, visibleColumns }`
)(...Object.values(env))
)
return {
@@ -106,28 +114,23 @@ test('global and fixed contract preserve server counts and scope every request',
await flush()
assert.equal(h.calls[0].method, 'listStops')
assert.equal(h.calls[0].args[0].contractId, fixed)
assert.equal(h.calls[0].args[0].status, 'pending')
h.calls[0].resolve({
list: [{ id: 'plan', uploadedCount: 1, requiredCount: fixed ? 1 : 3 }],
total: 45
})
await flush()
assert.equal(s.rows.value[0].requiredCount, fixed ? 1 : 3)
const pending = s.open({ id: 'plan' }, { stopType: 'delivery', stopProductId: 'd2' })
assert.equal(h.calls[1].args[1], fixed)
h.calls[1].resolve({
id: 'plan',
items: [
{ stopType: 'delivery', stopProductId: 'd2', productId: 'same', legalUseCertUrl: 'a,b' }
]
})
await pending
const saving = s.save()
assert.deepEqual(h.calls[2].args, [
'plan',
[{ stopType: 'delivery', stopProductId: 'd2', legalUseCertUrl: 'a,b' }],
fixed
])
h.calls[2].resolve({})
s.details.plan = {
loading: false,
error: false,
data: { id: 'plan', planProductIds: ['pp'], legalUseCertUrl: 'a,b', items: [] }
}
s.drafts.plan = 'a,b'
const saving = s.savePlan('plan')
assert.equal(h.calls[1].method, 'save')
assert.deepEqual(h.calls[1].args, ['plan', [{ id: 'pp', legalUseCertUrl: 'a,b' }]])
h.calls[1].resolve({})
await saving
h.stop()
}
@@ -139,37 +142,40 @@ test('busy uploads refuse saves and failures preserve draft', async () => {
await flush()
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({
id: 'p',
items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'original' }]
})
await opened
s.editing.value.legalUseCertUrl = 'draft'
s.upload.value = { hasUploading: () => true }
await s.save()
assert.equal(h.calls.length, 2)
s.upload.value = { hasUploading: () => false }
const saving = s.save()
h.calls[2].reject(Error('failed'))
s.details.p = {
loading: false,
error: false,
data: { planProductIds: ['pp'], legalUseCertUrl: 'original' }
}
s.drafts.p = 'draft'
s.uploaders.p = { hasUploading: () => true }
await s.savePlan('p')
assert.equal(h.calls.length, 1)
s.uploaders.p = { hasUploading: () => false }
const saving = s.savePlan('p')
h.calls[1].reject(Error('failed'))
await saving
assert.equal(s.editing.value.legalUseCertUrl, 'draft')
assert.equal(s.visible.value, true)
assert.equal(s.drafts.p, 'draft')
assert.equal(s.saving.p, false)
h.stop()
})
for (const change of ['contract', 'tenant', 'close'])
test(`old editor response is discarded after ${change}`, async () => {
for (const change of ['contract', 'tenant'])
test(`old detail is discarded after ${change}`, async () => {
const h = harness('one'),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const old = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const old = s.loadDetail({ id: 'p' })
if (change === 'contract') h.props.contractId = 'two'
if (change === 'tenant') h.user.getUserInfo.tenantNo = 'b'
if (change === 'close') s.visible.value = false
h.calls[1].resolve({ id: 'p', items: [{ stopType: 'load', stopProductId: 'l' }] })
h.calls.find((call) => call.method === 'getStops').resolve({
legalUseCertUrl: 'stale',
planProductIds: ['pp']
})
await old
assert.equal(s.editing.value, undefined)
assert.equal(s.drafts.p, undefined)
h.stop()
})
test('search during permission initialization does not cancel it; filters are independent', async () => {
@@ -177,12 +183,17 @@ test('search during permission initialization does not cancel it; filters are in
s = h.state
s.keyword.value = 'word'
s.setFilterState('planNo', { text: 'PL' })
s.setFilterState('planDate', { from: '2026-11-01', to: '2026-11-04' })
s.setFilterState('licensePlate', { text: '鄂A' })
await s.load()
h.permissions[0].resolve({ all: true })
await flush()
assert.equal(h.calls.length, 1)
assert.equal(h.calls[0].args[0].keyword, 'word')
assert.equal(h.calls[0].args[0].planNo, 'PL')
assert.equal(h.calls[0].args[0].planDateFrom, '2026-11-01')
assert.equal(h.calls[0].args[0].planDateTo, '2026-11-04')
assert.equal(h.calls[0].args[0].licensePlate, '鄂A')
h.stop()
})
test('refresh preserves page and total; not_required is not uploaded', async () => {
@@ -320,6 +331,9 @@ for (const active of [undefined, false])
},
usePersistedListColumns,
useServerColumnFilters,
PL_PLAN_FOLLOW_STATUS_OPTIONS,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
useElementSize: () => ({ width: vue.ref(1000) }),
useListTableViewportHeight: () => ({ tableHeight: vue.ref(400) })
},
@@ -344,36 +358,33 @@ for (const active of [undefined, false])
assert.deepEqual(calls, active === false ? [] : ['proof'])
app.unmount()
})
test('closing and reopening rejects old response and finally without clearing new loading', async () => {
test('a newer detail load is not cleared when the previous request finishes', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const first = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
s.visible.value = false
const second = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l' }] })
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const first = s.loadDetail({ id: 'p' })
const second = s.loadDetail({ id: 'p' })
h.calls[1].resolve({ legalUseCertUrl: 'old', planProductIds: ['old'] })
await first
assert.equal(s.editorLoading.value, true)
assert.equal(s.editing.value, undefined)
h.calls[2].resolve({ items: [{ stopType: 'delivery', stopProductId: 'd' }] })
h.calls[2].resolve({ legalUseCertUrl: 'new', planProductIds: ['new'] })
await second
assert.equal(s.editorLoading.value, false)
assert.equal(s.editing.value.stopProductId, 'd')
assert.equal(s.drafts.p, 'new')
assert.deepEqual(s.details.p.data.planProductIds, ['new'])
h.stop()
})
test('historical attachments have a separate readonly field and viewport aligned expansion', () => {
test('plan certificate is edited below the segments and the expansion stays aligned', () => {
const descriptor = parse(
readFileSync(
new URL('../src/views/pl/legal-use-document/StopDocuments.vue', import.meta.url),
'utf8'
)
).descriptor
assert.match(descriptor.template!.content, /历史计划级资料/)
assert.match(
descriptor.template!.content,
/:model-value="product.legalUseCertUrl \|\| ''"\s+disabled/
)
assert.match(descriptor.template!.content, /合法使用证明/)
assert.doesNotMatch(descriptor.template!.content, /历史计划级资料|维护本段资料/)
assert.match(descriptor.template!.content, /v-model="drafts\[row\.id\]"/)
assert.match(descriptor.styles[0].content, /text-align: left/)
const h = harness()
assert.equal(h.state.expansionStyle.value.width, '1120px')
@@ -438,40 +449,33 @@ test('view permission loads proof only and prevents upload requests', async () =
await flush()
assert.equal(s.allowed.value, true)
assert.equal(s.canUpload.value, false)
await s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
await s.save()
await s.savePlan('p')
assert.deepEqual(
h.calls.map((c) => c.method),
['listStops']
)
h.stop()
})
test('old save completion cannot close or clear a reopened editor', async () => {
test('an older save cannot replace a newer draft', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
const opened = s.open({ id: 'p' }, { stopType: 'load', stopProductId: 'l' })
h.calls[1].resolve({ items: [{ stopType: 'load', stopProductId: 'l', legalUseCertUrl: 'a' }] })
await opened
const oldSave = s.save()
s.visible.value = false
const reopened = s.open({ id: 'p' }, { stopType: 'delivery', stopProductId: 'd' })
h.calls[3].resolve({
items: [{ stopType: 'delivery', stopProductId: 'd', legalUseCertUrl: 'b' }]
})
await reopened
const newSave = s.save()
h.calls[2].resolve({})
h.calls[0].resolve({ list: [], total: 0 })
await flush()
s.details.p = {
loading: false,
error: false,
data: { planProductIds: ['pp'], legalUseCertUrl: 'a' }
}
s.drafts.p = 'a'
const oldSave = s.savePlan('p')
s.drafts.p = 'b'
h.calls[1].resolve({})
await oldSave
assert.equal(s.visible.value, true)
assert.equal(s.saving.value, true)
assert.equal(s.editing.value.stopProductId, 'd')
assert.equal(h.calls.length, 5)
h.calls[4].reject(Error('retry'))
await newSave
assert.equal(s.editing.value.legalUseCertUrl, 'b')
assert.equal(s.saving.value, false)
assert.equal(s.drafts.p, 'b')
assert.equal(h.calls.filter((call) => call.method === 'save').length, 1)
assert.equal(s.saving.p, false)
h.stop()
})
test('old list and permission responses cannot reauthorize another tenant', async () => {
@@ -499,8 +503,7 @@ test('status is a server single filter and combines with the number filter', asy
s = h.state
h.permissions[0].resolve({ all: true })
await flush()
s.status.value = 'uploaded'
s.search()
s.setFilterState('documentStatus', { text: '', selected: ['uploaded'] })
assert.equal(h.calls.at(-1).args[0].status, 'uploaded')
s.setFilterState('planNo', { text: 'PL' })
assert.equal(h.calls.at(-1).args[0].planNo, 'PL')
@@ -523,7 +526,6 @@ test('latest refresh wins and hidden column/reset preserve server filter semanti
assert.equal(s.rows.value[0].id, 'new')
assert.equal(s.total.value, 45)
s.keyword.value = 'quick'
s.status.value = 'pending'
s.page.value = 2
s.setFilterState('planNo', { text: 'PL' })
assert.equal(s.page.value, 1)
@@ -534,12 +536,32 @@ test('latest refresh wins and hidden column/reset preserve server filter semanti
assert.equal(h.calls.at(-1).args[0].status, 'pending')
s.reset()
assert.equal(h.calls.at(-1).args[0].keyword, '')
assert.equal(h.calls.at(-1).args[0].status, undefined)
assert.equal(h.calls.at(-1).args[0].status, 'pending')
h.calls.forEach((c) => c.resolve({ list: [], total: 0 }))
await flush()
h.stop()
})
test('list certificate column saves the finished attachment without a second click', async () => {
const h = harness(),
s = h.state
h.permissions[0].resolve({ menuCodes: ['pl:legal-use-document:list', 'pl:plan-cert:upload'] })
await flush()
h.calls[0].resolve({ list: [], total: 0 })
await flush()
const row = { id: 'plan', legalUseCertUrl: 'old', planProductIds: ['pp'], documentStatus: 'pending' }
s.onCertDraft(row, 'old')
assert.equal(h.calls.length, 1)
s.onCertDraft(row, 'new')
assert.equal(h.calls[1].method, 'save')
assert.deepEqual(h.calls[1].args, ['plan', [{ id: 'pp', legalUseCertUrl: 'new' }]])
h.calls[1].resolve({})
await flush()
assert.equal(h.calls[2].method, 'listStops')
h.calls[2].resolve({ list: [], total: 0 })
await flush()
h.stop()
})
test('expanded segment columns prioritize upload state and attachments', () => {
const template = parse(
readFileSync(
@@ -550,18 +572,6 @@ test('expanded segment columns prioritize upload state and attachments', () => {
const table = template.slice(template.indexOf('<el-table\n'), template.indexOf('</el-table>'))
assert.deepEqual(
[...table.matchAll(/<el-table-column\b[^>]*\blabel="([^"]+)"/g)].map((match) => match[1]),
[
'运输段',
'产品',
'上传情况',
'附件',
'合同编号',
'本段主体',
'规格',
'单位',
'本段位置',
'上传时间',
'操作'
]
['运输段', '产品', '合同编号', '本段主体', '规格', '单位', '本段位置']
)
})
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
formatDriverProductQty,
formatSiteProductQty
} from '../src/components/business/pl-plan-stop-feedback-panel/qty-display'
const echoCode = (_dictType: string, value: string) => value
const dictLabel = (_dictType: string, value: string) => {
if (value === 'dun') return '吨'
if (value === 'kg') return '千克'
return value
}
test('driver feedback shows unit label instead of dict code', () => {
assert.equal(formatDriverProductQty({ actualQty: 12, unit: 'dun' }, echoCode), '12 吨')
assert.equal(formatDriverProductQty({ actualQty: 3, unit: 'kg' }, dictLabel), '3 千克')
assert.equal(formatDriverProductQty({ actualQty: null, unit: 'dun' }, echoCode), '—')
assert.equal(formatDriverProductQty({ actualQty: 1, unit: '' }, echoCode), '1 吨')
})
test('site feedback shows unit label instead of dict code', () => {
assert.deepEqual(formatSiteProductQty({ actualQty: 8, unit: 'dun' }, echoCode), {
qty: '8',
unit: ' 吨'
})
assert.deepEqual(formatSiteProductQty({ quantity: 2, unit: 'ping' }, echoCode), {
qty: '2',
unit: ' 瓶'
})
assert.deepEqual(
formatSiteProductQty({ actualQty: null, quantity: null, unit: 'dun' }, echoCode),
{
qty: '—',
unit: ''
}
)
})