feat: 保存运输证件、报销付款和开发流程的当前实现。
补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
7325a0aa52
commit
8e9536332a
202 files changed
+19200
-2054
No files matched your search
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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), '历史解绑')
|
||||
})
|
||||
@@ -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: ''
|
||||
}
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user