feat: add tested mobile presign contracts and form state.
This commit is contained in:
1 parent
702ff0e074
commit
b28ed3e280
8 files changed
+992
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import { MARKET_MODULES, MARKET_ROLES } from '../market/routes.js'
|
||||
import { getMarketModule } from '../market/module-registry.js'
|
||||
|
||||
const RESOURCE_BY_MODULE = Object.freeze({
|
||||
presign: 'presign-orders',
|
||||
protocol: 'protocols',
|
||||
'payment-chase': 'payment-chase',
|
||||
payment: 'payments',
|
||||
sample: 'samples',
|
||||
freight: 'freights'
|
||||
})
|
||||
|
||||
function assertContext(context) {
|
||||
if (
|
||||
!MARKET_ROLES.includes(context?.marketRole) ||
|
||||
!MARKET_MODULES.includes(context?.module)
|
||||
) {
|
||||
throw new Error('市场页面配置无效')
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarketResourcePath(context, id) {
|
||||
assertContext(context)
|
||||
const base = `/mini/market/${context.marketRole}/${RESOURCE_BY_MODULE[context.module]}`
|
||||
const normalizedId = String(id ?? '').trim()
|
||||
return normalizedId ? `${base}/${encodeURIComponent(normalizedId)}` : base
|
||||
}
|
||||
|
||||
export function buildMarketFormOptionsPath(context) {
|
||||
assertContext(context)
|
||||
return `/mini/market/${context.marketRole}/${RESOURCE_BY_MODULE[context.module]}/form-options`
|
||||
}
|
||||
|
||||
export function buildMarketAttachmentPath(context) {
|
||||
assertContext(context)
|
||||
return `/mini/market/${context.marketRole}/${context.module}/attachments`
|
||||
}
|
||||
|
||||
export function buildMarketActionPath(context, action, id) {
|
||||
assertContext(context)
|
||||
const roleBase = `/mini/market/${context.marketRole}`
|
||||
const encodedId = encodeURIComponent(String(id ?? '').trim())
|
||||
if (context.module === 'presign' && encodedId) {
|
||||
if (action === 'delete') return buildMarketResourcePath(context, id)
|
||||
if (action === 'convert') return `${buildMarketResourcePath(context, id)}/convert`
|
||||
if (action === 'create-transfer') {
|
||||
return `${buildMarketResourcePath(context, id)}/transfers`
|
||||
}
|
||||
if (['edit-presign-transfer', 'delete-transfer'].includes(action)) {
|
||||
return `${roleBase}/presign-transfers/${encodedId}`
|
||||
}
|
||||
if (['submit-transfer', 'withdraw-transfer'].includes(action)) {
|
||||
const suffix = action === 'submit-transfer' ? 'submit' : 'withdraw'
|
||||
return `${roleBase}/presign-transfers/${encodedId}/${suffix}`
|
||||
}
|
||||
}
|
||||
if (context.module === 'payment') {
|
||||
if (action === 'register-transfer') return `${roleBase}/payment-transfers`
|
||||
if (['edit-transfer', 'submit', 'withdraw'].includes(action) && encodedId) {
|
||||
const suffix = action === 'edit-transfer' ? '' : `/${action}`
|
||||
return `${roleBase}/payment-transfers/${encodedId}${suffix}`
|
||||
}
|
||||
}
|
||||
if (context.module === 'sample' && ['storage-cert', 'insurance'].includes(action) && encodedId) {
|
||||
return `${buildMarketResourcePath(context, id)}/${action}`
|
||||
}
|
||||
if (context.module === 'freight' && ['link', 'unlink'].includes(action) && encodedId) {
|
||||
return `${buildMarketResourcePath(context, id)}/${action}`
|
||||
}
|
||||
throw new Error('不支持的市场业务操作')
|
||||
}
|
||||
|
||||
export async function fetchMarketList(context, params, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: buildMarketResourcePath(context),
|
||||
method: 'GET',
|
||||
data: params,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchMarketDetail(context, id, { signal, params } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: buildMarketResourcePath(context, id),
|
||||
method: 'GET',
|
||||
data: params,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchMarketFormOptions(context, params, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({ url: buildMarketFormOptionsPath(context), method: 'GET', data: params, signal })
|
||||
}
|
||||
|
||||
export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: buildMarketResourcePath(context, id),
|
||||
method: id ? 'PUT' : 'POST',
|
||||
data,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export function marketActionMethod(action) {
|
||||
if (['delete', 'delete-transfer'].includes(action)) return 'DELETE'
|
||||
return ['edit-transfer', 'edit-presign-transfer', 'storage-cert', 'insurance'].includes(action)
|
||||
? 'PUT'
|
||||
: 'POST'
|
||||
}
|
||||
|
||||
export async function performMarketAction(context, action, { id, data } = {}, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: buildMarketActionPath(context, action, id),
|
||||
method: marketActionMethod(action),
|
||||
data,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePresignOrder = (context, id, options) =>
|
||||
performMarketAction(context, 'delete', { id }, options)
|
||||
|
||||
export const createPresignTransfer = (context, orderId, data, options) =>
|
||||
performMarketAction(context, 'create-transfer', { id: orderId, data }, options)
|
||||
|
||||
export const updatePresignTransfer = (context, transferId, data, options) =>
|
||||
performMarketAction(context, 'edit-presign-transfer', { id: transferId, data }, options)
|
||||
|
||||
export const deletePresignTransfer = (context, transferId, options) =>
|
||||
performMarketAction(context, 'delete-transfer', { id: transferId }, options)
|
||||
|
||||
export const submitPresignTransfer = (context, transferId, options) =>
|
||||
performMarketAction(context, 'submit-transfer', { id: transferId }, options)
|
||||
|
||||
export const withdrawPresignTransfer = (context, transferId, options) =>
|
||||
performMarketAction(context, 'withdraw-transfer', { id: transferId }, options)
|
||||
|
||||
export async function uploadMarketAttachment(context, file, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return request({ url: buildMarketAttachmentPath(context), method: 'POST', data, signal })
|
||||
}
|
||||
|
||||
export async function fetchFreightSampleOptions(context, params, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: `${buildMarketResourcePath(context)}/sample-options`,
|
||||
method: 'GET',
|
||||
data: params,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export function marketModuleContract(context) {
|
||||
assertContext(context)
|
||||
return getMarketModule(context.module)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
const ACTIONS = Object.freeze({
|
||||
presign: [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'convert',
|
||||
'delete',
|
||||
'create-transfer',
|
||||
'edit-presign-transfer',
|
||||
'delete-transfer',
|
||||
'submit-transfer',
|
||||
'withdraw-transfer'
|
||||
],
|
||||
protocol: ['view', 'create', 'edit'],
|
||||
'payment-chase': ['view-payment'],
|
||||
payment: ['view', 'register-transfer', 'edit-transfer', 'submit', 'withdraw'],
|
||||
sample: ['view', 'storage-cert', 'insurance'],
|
||||
freight: ['view', 'create', 'edit', 'link', 'unlink']
|
||||
})
|
||||
|
||||
const ACTION_ALIASES = Object.freeze({
|
||||
storageCert: 'storage-cert',
|
||||
registerTransfer: 'register-transfer',
|
||||
editTransfer: 'edit-transfer',
|
||||
'submit-finance': 'submit',
|
||||
'withdraw-transfer': 'withdraw'
|
||||
})
|
||||
|
||||
export function moduleActions(module, role) {
|
||||
return [...(ACTIONS[module] || [])]
|
||||
}
|
||||
|
||||
export function resolveMarketFormAction(module, role, mode, requested) {
|
||||
const allowed = new Set(moduleActions(module, role))
|
||||
const candidate = String(requested || '').trim() || (mode === 'create' ? 'create' : 'edit')
|
||||
return allowed.has(candidate) ? candidate : ''
|
||||
}
|
||||
|
||||
function normalizeAction(action) {
|
||||
if (typeof action === 'string') return ACTION_ALIASES[action] || action
|
||||
const original = String(action?.code || action?.action || action?.key || '').trim()
|
||||
if (!original) return null
|
||||
const { key: _key, action: _action, ...metadata } = action
|
||||
return { ...metadata, code: ACTION_ALIASES[original] || original }
|
||||
}
|
||||
|
||||
export function intersectAllowedActions(module, role, backendActions) {
|
||||
const whitelist = new Set(moduleActions(module, role))
|
||||
return (Array.isArray(backendActions) ? backendActions : [])
|
||||
.map((action) => {
|
||||
const original = typeof action === 'string'
|
||||
? action
|
||||
: String(action?.code || action?.action || action?.key || '').trim()
|
||||
if (!whitelist.has(original)) return normalizeAction(action)
|
||||
if (typeof action === 'string') return action
|
||||
const { key: _key, action: _action, ...metadata } = action
|
||||
return { ...metadata, code: original }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((action) => whitelist.has(typeof action === 'string' ? action : action.code))
|
||||
}
|
||||
|
||||
export function collectAllowedActions(module, role, detail) {
|
||||
if (module === 'presign') {
|
||||
const whitelist = new Set(moduleActions(module, role))
|
||||
const transferActionMap = {
|
||||
edit: 'edit-presign-transfer',
|
||||
delete: 'delete-transfer',
|
||||
submit: 'submit-transfer',
|
||||
withdraw: 'withdraw-transfer'
|
||||
}
|
||||
const nested = []
|
||||
for (const transfer of detail?.transfers || []) {
|
||||
for (const action of transfer?.allowedActions || []) {
|
||||
const normalized = normalizeAction(action)
|
||||
if (!normalized) continue
|
||||
const backendCode = typeof normalized === 'string' ? normalized : normalized.code
|
||||
const code = transferActionMap[backendCode] || backendCode
|
||||
if (!whitelist.has(code)) continue
|
||||
nested.push({
|
||||
...(typeof normalized === 'string' ? {} : normalized),
|
||||
code,
|
||||
transferId: transfer.id,
|
||||
actionKey: `${code}:transfer:${transfer.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
return [
|
||||
...nested,
|
||||
...intersectAllowedActions(
|
||||
module,
|
||||
role,
|
||||
detail?.allowedActions || detail?.actions
|
||||
)
|
||||
]
|
||||
}
|
||||
if (module !== 'payment') {
|
||||
return intersectAllowedActions(module, role, detail?.allowedActions || detail?.actions)
|
||||
}
|
||||
const candidates = []
|
||||
for (const [scheduleIndex, schedule] of (detail?.schedules || []).entries()) {
|
||||
for (const action of intersectAllowedActions(module, role, schedule?.allowedActions)) {
|
||||
candidates.push({
|
||||
...(typeof action === 'string' ? { code: action } : action),
|
||||
scheduleId: schedule.id,
|
||||
actionKey: `${typeof action === 'string' ? action : action.code}:schedule:${schedule.id}`,
|
||||
label: `登记转账·第${schedule.scheduleNo || scheduleIndex + 1}期`
|
||||
})
|
||||
}
|
||||
for (const [transferIndex, transfer] of (schedule?.transfers || []).entries()) {
|
||||
for (const action of intersectAllowedActions(module, role, transfer?.allowedActions)) {
|
||||
const code = typeof action === 'string' ? action : action.code
|
||||
const label = {
|
||||
'edit-transfer': '编辑转账',
|
||||
submit: '提交财务确认',
|
||||
withdraw: '撤回转账'
|
||||
}[code] || code
|
||||
const identity = transfer.transferAmount
|
||||
? `${transfer.transferAmount}元`
|
||||
: `第${transferIndex + 1}笔`
|
||||
candidates.push({
|
||||
...(typeof action === 'string' ? { code: action } : action),
|
||||
transferId: transfer.id,
|
||||
actionKey: `${code}:transfer:${transfer.id}`,
|
||||
label: `${label}·${identity}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const top = intersectAllowedActions(module, role, detail?.allowedActions || detail?.actions)
|
||||
.map((action) => typeof action === 'string' ? { code: action } : action)
|
||||
const nestedCodes = new Set(candidates.map((action) => action.code))
|
||||
return [...candidates, ...top.filter((action) => !nestedCodes.has(action.code))]
|
||||
}
|
||||
|
||||
export function resolveAllowedActionTarget(actions, code, requested = {}) {
|
||||
const candidates = (actions || []).filter((item) =>
|
||||
(typeof item === 'string' ? item : item.code) === code
|
||||
)
|
||||
const requestedSchedule = String(requested.scheduleId || '').trim()
|
||||
const requestedTransfer = String(requested.transferId || '').trim()
|
||||
if (requestedSchedule || requestedTransfer) {
|
||||
return candidates.find((item) =>
|
||||
typeof item !== 'string' &&
|
||||
(!requestedSchedule || String(item.scheduleId || '') === requestedSchedule) &&
|
||||
(!requestedTransfer || String(item.transferId || '') === requestedTransfer)
|
||||
) || null
|
||||
}
|
||||
return candidates.find((item) =>
|
||||
typeof item === 'string' || (!item.scheduleId && !item.transferId)
|
||||
) || null
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildMarketActionPath,
|
||||
buildMarketAttachmentPath,
|
||||
buildMarketFormOptionsPath,
|
||||
createPresignTransfer,
|
||||
deletePresignOrder,
|
||||
deletePresignTransfer,
|
||||
marketActionMethod,
|
||||
buildMarketResourcePath,
|
||||
submitPresignTransfer,
|
||||
updatePresignTransfer,
|
||||
withdrawPresignTransfer
|
||||
} from '../api/market.js'
|
||||
|
||||
const context = { marketRole: 'specialist', module: 'payment' }
|
||||
|
||||
test('builds form option and attachment endpoints without client-controlled scope', () => {
|
||||
assert.equal(buildMarketFormOptionsPath(context), '/mini/market/specialist/payments/form-options')
|
||||
assert.equal(buildMarketAttachmentPath(context), '/mini/market/specialist/payment/attachments')
|
||||
})
|
||||
|
||||
test('uses PUT for update-style market actions', () => {
|
||||
assert.equal(marketActionMethod('edit-transfer'), 'PUT')
|
||||
assert.equal(marketActionMethod('storage-cert'), 'PUT')
|
||||
assert.equal(marketActionMethod('insurance'), 'PUT')
|
||||
assert.equal(marketActionMethod('submit'), 'POST')
|
||||
})
|
||||
|
||||
test('maps payment and freight write actions to their actual backend endpoints', () => {
|
||||
assert.equal(
|
||||
buildMarketActionPath(context, 'register-transfer'),
|
||||
'/mini/market/specialist/payment-transfers'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(context, 'submit', 'transfer 1'),
|
||||
'/mini/market/specialist/payment-transfers/transfer%201/submit'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath({ marketRole: 'manager', module: 'freight' }, 'link', 'f1'),
|
||||
'/mini/market/manager/freights/f1/link'
|
||||
)
|
||||
assert.equal(buildMarketResourcePath(context, 'p1'), '/mini/market/specialist/payments/p1')
|
||||
})
|
||||
|
||||
test('maps every presign write action to the fixed backend route with encoded ids', () => {
|
||||
const presign = { marketRole: 'specialist', module: 'presign' }
|
||||
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'delete', 'order 1'),
|
||||
'/mini/market/specialist/presign-orders/order%201'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'create-transfer', 'order/1'),
|
||||
'/mini/market/specialist/presign-orders/order%2F1/transfers'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'edit-presign-transfer', 'transfer 1'),
|
||||
'/mini/market/specialist/presign-transfers/transfer%201'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'delete-transfer', 'transfer/1'),
|
||||
'/mini/market/specialist/presign-transfers/transfer%2F1'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'submit-transfer', 'transfer 1'),
|
||||
'/mini/market/specialist/presign-transfers/transfer%201/submit'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(presign, 'withdraw-transfer', 'transfer 1'),
|
||||
'/mini/market/specialist/presign-transfers/transfer%201/withdraw'
|
||||
)
|
||||
})
|
||||
|
||||
test('uses the backend HTTP method for each presign action', () => {
|
||||
assert.equal(marketActionMethod('delete'), 'DELETE')
|
||||
assert.equal(marketActionMethod('create-transfer'), 'POST')
|
||||
assert.equal(marketActionMethod('edit-presign-transfer'), 'PUT')
|
||||
assert.equal(marketActionMethod('delete-transfer'), 'DELETE')
|
||||
assert.equal(marketActionMethod('submit-transfer'), 'POST')
|
||||
assert.equal(marketActionMethod('withdraw-transfer'), 'POST')
|
||||
})
|
||||
|
||||
test('exports semantic wrappers for all presign writes', () => {
|
||||
for (const wrapper of [
|
||||
deletePresignOrder,
|
||||
createPresignTransfer,
|
||||
updatePresignTransfer,
|
||||
deletePresignTransfer,
|
||||
submitPresignTransfer,
|
||||
withdrawPresignTransfer
|
||||
]) {
|
||||
assert.equal(typeof wrapper, 'function')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
collectAllowedActions,
|
||||
intersectAllowedActions,
|
||||
moduleActions,
|
||||
resolveMarketFormAction
|
||||
} from './action-policy.js'
|
||||
|
||||
test('each module exposes only its static market action whitelist', () => {
|
||||
assert.deepEqual(moduleActions('payment'), [
|
||||
'view',
|
||||
'register-transfer',
|
||||
'edit-transfer',
|
||||
'submit',
|
||||
'withdraw'
|
||||
])
|
||||
assert.equal(moduleActions('payment').includes('finance-confirm'), false)
|
||||
assert.equal(moduleActions('protocol').includes('audit'), false)
|
||||
assert.equal(moduleActions('sample').includes('qc-conclusion'), false)
|
||||
assert.equal(moduleActions('freight', 'specialist').includes('edit'), true)
|
||||
assert.equal(moduleActions('freight', 'manager').includes('edit'), true)
|
||||
assert.deepEqual(moduleActions('presign'), [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'convert',
|
||||
'delete',
|
||||
'create-transfer',
|
||||
'edit-presign-transfer',
|
||||
'delete-transfer',
|
||||
'submit-transfer',
|
||||
'withdraw-transfer'
|
||||
])
|
||||
})
|
||||
|
||||
test('freight header edit is shown only when the backend authorizes that batch', () => {
|
||||
for (const role of ['specialist', 'manager']) {
|
||||
assert.deepEqual(collectAllowedActions('freight', role, { allowedActions: ['edit'] }), ['edit'])
|
||||
assert.deepEqual(collectAllowedActions('freight', role, { allowedActions: ['link', 'unlink'] }), ['link', 'unlink'])
|
||||
assert.equal(collectAllowedActions('freight', role, { allowedActions: ['link'] }).includes('edit'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves only an exact recursive backend action target', async () => {
|
||||
const { resolveAllowedActionTarget } = await import('./action-policy.js')
|
||||
const actions = [
|
||||
{ code: 'register-transfer', scheduleId: 's1' },
|
||||
{ code: 'edit-transfer', transferId: 't1' }
|
||||
]
|
||||
assert.deepEqual(resolveAllowedActionTarget(actions, 'register-transfer', { scheduleId: 's1' }), actions[0])
|
||||
assert.equal(resolveAllowedActionTarget(actions, 'register-transfer', { scheduleId: 'same-scope-other' }), null)
|
||||
assert.equal(resolveAllowedActionTarget(actions, 'edit-transfer', { transferId: 'same-scope-other' }), null)
|
||||
})
|
||||
|
||||
test('rejects query-driven actions outside the static module whitelist', () => {
|
||||
assert.equal(resolveMarketFormAction('payment', 'specialist', 'edit', 'finance-confirm'), '')
|
||||
assert.equal(resolveMarketFormAction('payment', 'specialist', 'edit', 'register-transfer'), 'register-transfer')
|
||||
assert.equal(resolveMarketFormAction('protocol', 'manager', 'edit', ''), 'edit')
|
||||
assert.equal(resolveMarketFormAction('payment-chase', 'manager', 'create', ''), '')
|
||||
})
|
||||
|
||||
test('collects payment transfer actions with their schedule and transfer targets', () => {
|
||||
assert.deepEqual(
|
||||
collectAllowedActions('payment', 'specialist', {
|
||||
allowedActions: ['register-transfer'],
|
||||
schedules: [{
|
||||
id: 'schedule-1',
|
||||
allowedActions: ['register-transfer'],
|
||||
transfers: [{ id: 'transfer-1', allowedActions: ['submit-finance'] }]
|
||||
}]
|
||||
}).map(({ code, scheduleId, transferId }) => ({ code, scheduleId, transferId })),
|
||||
[
|
||||
{ code: 'register-transfer', scheduleId: 'schedule-1', transferId: undefined },
|
||||
{ code: 'submit', scheduleId: undefined, transferId: 'transfer-1' }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps distinct payment actions for distinct backend targets', () => {
|
||||
const actions = collectAllowedActions('payment', 'manager', {
|
||||
allowedActions: ['register-transfer'],
|
||||
schedules: [
|
||||
{ id: 's1', allowedActions: ['register-transfer'], transfers: [] },
|
||||
{ id: 's2', allowedActions: ['register-transfer'], transfers: [
|
||||
{ id: 't2', allowedActions: ['submit-finance'] },
|
||||
{ id: 't3', allowedActions: ['submit-finance'] }
|
||||
] }
|
||||
]
|
||||
})
|
||||
assert.deepEqual(actions.map((item) => [item.code, item.scheduleId, item.transferId]), [
|
||||
['register-transfer', 's1', undefined],
|
||||
['register-transfer', 's2', undefined],
|
||||
['submit', undefined, 't2'],
|
||||
['submit', undefined, 't3']
|
||||
])
|
||||
})
|
||||
|
||||
test('backend actions are intersected with the module whitelist', () => {
|
||||
assert.deepEqual(
|
||||
intersectAllowedActions('payment', 'specialist', [
|
||||
'submit',
|
||||
{ code: 'withdraw', label: '撤回登记' },
|
||||
'finance-confirm'
|
||||
]),
|
||||
['submit', { code: 'withdraw', label: '撤回登记' }]
|
||||
)
|
||||
assert.deepEqual(
|
||||
intersectAllowedActions('sample', 'manager', [
|
||||
{ key: 'storageCert', label: '办理储存证' },
|
||||
{ key: 'insurance', label: '办理保险' },
|
||||
'qc-conclusion'
|
||||
]),
|
||||
[
|
||||
{ code: 'storage-cert', label: '办理储存证' },
|
||||
{ code: 'insurance', label: '办理保险' }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('presign actions never exceed the backend authorization set', () => {
|
||||
assert.deepEqual(
|
||||
intersectAllowedActions('presign', 'specialist', [
|
||||
'delete',
|
||||
'create-transfer',
|
||||
'withdraw-transfer',
|
||||
'finance-confirm',
|
||||
'admin-only'
|
||||
]),
|
||||
['delete', 'create-transfer', 'withdraw-transfer']
|
||||
)
|
||||
assert.equal(
|
||||
intersectAllowedActions('presign', 'specialist', ['edit']).includes('delete'),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('collects only backend-authorized presign order and nested transfer actions', () => {
|
||||
const actions = collectAllowedActions('presign', 'manager', {
|
||||
allowedActions: ['edit', 'convert', 'delete', 'admin-only'],
|
||||
transfers: [
|
||||
{ id: 'draft', allowedActions: ['edit', 'delete', 'submit', 'finance-confirm'] },
|
||||
{ id: 'pending', allowedActions: ['withdraw'] },
|
||||
{ id: 'confirmed', allowedActions: [] }
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
actions.map((item) => [typeof item === 'string' ? item : item.code, item.transferId]),
|
||||
[
|
||||
['edit-presign-transfer', 'draft'],
|
||||
['delete-transfer', 'draft'],
|
||||
['submit-transfer', 'draft'],
|
||||
['withdraw-transfer', 'pending'],
|
||||
['edit', undefined],
|
||||
['convert', undefined],
|
||||
['delete', undefined]
|
||||
]
|
||||
)
|
||||
assert.equal(actions.some((item) => item.code === 'finance-confirm'), false)
|
||||
assert.equal(actions.some((item) => item.code === 'admin-only'), false)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const ORDER_WRITABLE_FIELDS = Object.freeze([
|
||||
'orderDate',
|
||||
'protocolNo',
|
||||
'motherName',
|
||||
'expectedDeliveryDate',
|
||||
'regionId',
|
||||
'hospitalId',
|
||||
'collectorUserId',
|
||||
'regionManagerUserId',
|
||||
'sampleCollectTime',
|
||||
'payMode',
|
||||
'contractAmount',
|
||||
'orderStatus',
|
||||
'sampleTypeIds',
|
||||
'remark'
|
||||
])
|
||||
|
||||
const TRANSFER_WRITABLE_FIELDS = Object.freeze([
|
||||
'transferAmount',
|
||||
'transferTime',
|
||||
'transferType',
|
||||
'transferAttachments',
|
||||
'remark'
|
||||
])
|
||||
|
||||
function clone(value) {
|
||||
if (Array.isArray(value)) return value.map(clone)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)]))
|
||||
}
|
||||
|
||||
function todayYmd() {
|
||||
const date = new Date()
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}${month}${day}`
|
||||
}
|
||||
|
||||
function stringValue(value, fallback = '') {
|
||||
return value == null ? fallback : String(value)
|
||||
}
|
||||
|
||||
export function createPresignForm(value = {}, options = {}) {
|
||||
const source = value && typeof value === 'object' ? value : {}
|
||||
const defaultCollector = stringValue(options.currentUserId)
|
||||
return {
|
||||
id: stringValue(source.id),
|
||||
orderDate: stringValue(source.orderDate, stringValue(options.today, todayYmd())),
|
||||
protocolNo: stringValue(source.protocolNo),
|
||||
motherName: stringValue(source.motherName),
|
||||
expectedDeliveryDate: stringValue(source.expectedDeliveryDate),
|
||||
regionId: stringValue(source.regionId),
|
||||
hospitalId: stringValue(source.hospitalId),
|
||||
collectorUserId: stringValue(source.collectorUserId, defaultCollector),
|
||||
regionManagerUserId: stringValue(source.regionManagerUserId),
|
||||
sampleCollectTime: stringValue(source.sampleCollectTime),
|
||||
payMode: stringValue(source.payMode, 'dj'),
|
||||
contractAmount: source.contractAmount ?? null,
|
||||
orderStatus: stringValue(source.orderStatus, 'active'),
|
||||
sampleTypeIds: clone(Array.isArray(source.sampleTypeIds) ? source.sampleTypeIds : []),
|
||||
remark: stringValue(source.remark),
|
||||
transfers: clone(Array.isArray(source.transfers) ? source.transfers : []),
|
||||
confirmedDepositAmount: source.confirmedDepositAmount ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export function changePresignRegion(form, regionId) {
|
||||
const next = {
|
||||
...(form && typeof form === 'object' ? form : {}),
|
||||
regionId: stringValue(regionId),
|
||||
hospitalId: ''
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'regionManagerUserId')) {
|
||||
next.regionManagerUserId = ''
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function isPresignLocked(form) {
|
||||
return String(form?.orderStatus || '').trim() === 'converted'
|
||||
}
|
||||
|
||||
export function isPresignTransferLocked(transfer, order) {
|
||||
if (isPresignLocked(order || transfer)) return true
|
||||
const status = String(transfer?.payStatus || '').trim()
|
||||
return Boolean(status && status !== 'dfj')
|
||||
}
|
||||
|
||||
function attachmentUrls(transfer) {
|
||||
if (typeof transfer?.transferAttachments === 'string') {
|
||||
return transfer.transferAttachments
|
||||
}
|
||||
return (Array.isArray(transfer?.attachments) ? transfer.attachments : [])
|
||||
.filter((file) => !file?._uploading && !file?.failed && file?.status !== 'failed')
|
||||
.map((file) => String(file?.fileUrl || file?.url || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
export function buildPresignTransferPayload(transfer = {}) {
|
||||
const source = transfer && typeof transfer === 'object' ? transfer : {}
|
||||
const normalized = {
|
||||
...source,
|
||||
transferAttachments: attachmentUrls(source)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
TRANSFER_WRITABLE_FIELDS
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(normalized, key))
|
||||
.map((key) => [key, clone(normalized[key])])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildPresignOrderPayload(form = {}, { includeTransfers = true } = {}) {
|
||||
const source = form && typeof form === 'object' ? form : {}
|
||||
const payload = Object.fromEntries(
|
||||
ORDER_WRITABLE_FIELDS
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(source, key))
|
||||
.map((key) => [key, clone(source[key])])
|
||||
)
|
||||
if (includeTransfers && Array.isArray(source.transfers)) {
|
||||
payload.transfers = source.transfers.map(buildPresignTransferPayload)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildPresignOrderPayload,
|
||||
buildPresignTransferPayload,
|
||||
changePresignRegion,
|
||||
createPresignForm,
|
||||
isPresignLocked,
|
||||
isPresignTransferLocked
|
||||
} from './presign-form-state.js'
|
||||
|
||||
test('creates every Web-writable field with mobile defaults', () => {
|
||||
assert.deepEqual(createPresignForm({}, { today: '20261011', currentUserId: 'user-1' }), {
|
||||
id: '',
|
||||
orderDate: '20261011',
|
||||
protocolNo: '',
|
||||
motherName: '',
|
||||
expectedDeliveryDate: '',
|
||||
regionId: '',
|
||||
hospitalId: '',
|
||||
collectorUserId: 'user-1',
|
||||
regionManagerUserId: '',
|
||||
sampleCollectTime: '',
|
||||
payMode: 'dj',
|
||||
contractAmount: null,
|
||||
orderStatus: 'active',
|
||||
sampleTypeIds: [],
|
||||
remark: '',
|
||||
transfers: [],
|
||||
confirmedDepositAmount: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes backend detail without sharing nested arrays', () => {
|
||||
const detail = {
|
||||
id: 'order-1',
|
||||
orderDate: '20261001',
|
||||
sampleTypeIds: ['sample-1'],
|
||||
transfers: [{ id: 'transfer-1', attachments: [{ fileUrl: 'proof.pdf' }] }],
|
||||
confirmedDepositAmount: 100
|
||||
}
|
||||
const form = createPresignForm(detail, { today: '20261011', currentUserId: 'user-1' })
|
||||
|
||||
assert.equal(form.orderDate, '20261001')
|
||||
assert.equal(form.payMode, 'dj')
|
||||
assert.deepEqual(form.sampleTypeIds, ['sample-1'])
|
||||
assert.deepEqual(form.transfers, detail.transfers)
|
||||
form.sampleTypeIds.push('sample-2')
|
||||
form.transfers[0].attachments[0].fileUrl = 'changed.pdf'
|
||||
assert.deepEqual(detail.sampleTypeIds, ['sample-1'])
|
||||
assert.equal(detail.transfers[0].attachments[0].fileUrl, 'proof.pdf')
|
||||
})
|
||||
|
||||
test('changing region clears stale hospital and manager selections', () => {
|
||||
assert.deepEqual(
|
||||
changePresignRegion(
|
||||
{ regionId: 'region-1', hospitalId: 'hospital-1', regionManagerUserId: 'manager-1' },
|
||||
'region-2'
|
||||
),
|
||||
{ regionId: 'region-2', hospitalId: '', regionManagerUserId: '' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
changePresignRegion({ regionId: 'region-1', hospitalId: 'hospital-1' }, 'region-2'),
|
||||
{ regionId: 'region-2', hospitalId: '' }
|
||||
)
|
||||
})
|
||||
|
||||
test('locks converted orders and every transfer status except draft payment', () => {
|
||||
assert.equal(isPresignLocked({ orderStatus: 'converted' }), true)
|
||||
assert.equal(isPresignLocked({ orderStatus: 'active' }), false)
|
||||
assert.equal(isPresignTransferLocked({ payStatus: 'dfj' }), false)
|
||||
assert.equal(isPresignTransferLocked({ payStatus: '' }), false)
|
||||
assert.equal(isPresignTransferLocked({ payStatus: 'jfqrz' }), true)
|
||||
assert.equal(isPresignTransferLocked({ payStatus: 'yjf' }), true)
|
||||
assert.equal(
|
||||
isPresignTransferLocked({ payStatus: 'dfj' }, { orderStatus: 'converted' }),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('builds an order payload from only backend-writable camelCase fields', () => {
|
||||
const payload = buildPresignOrderPayload({
|
||||
id: 'order-1',
|
||||
orderDate: '20261011',
|
||||
protocolNo: '',
|
||||
motherName: '张女士',
|
||||
expectedDeliveryDate: '20270101',
|
||||
regionId: 'region-1',
|
||||
hospitalId: 'hospital-1',
|
||||
collectorUserId: 'collector-1',
|
||||
salesUserId: 'must-be-server-owned',
|
||||
regionManagerUserId: 'manager-1',
|
||||
sampleCollectTime: '2026-10-11 08:30:00',
|
||||
payMode: 'dj',
|
||||
contractAmount: 0,
|
||||
orderStatus: 'active',
|
||||
sampleTypeIds: ['sample-1'],
|
||||
remark: '',
|
||||
confirmedDepositAmount: 100,
|
||||
convertedProtocolId: 'protocol-1',
|
||||
createTime: '2026-10-11 09:00:00',
|
||||
updateTime: '2026-10-11 10:00:00',
|
||||
allowedActions: ['edit'],
|
||||
transfers: [{
|
||||
id: 'transfer-1',
|
||||
transferType: 'wxzz',
|
||||
transferAmount: 100,
|
||||
transferTime: '2026-10-11 08:00:00',
|
||||
attachments: [{ fileUrl: 'proof.pdf' }],
|
||||
payStatus: 'dfj',
|
||||
financeConfirmAttachments: 'finance.pdf',
|
||||
financeRemark: '只读',
|
||||
createTime: '2026-10-11 09:00:00',
|
||||
remark: '转账备注'
|
||||
}]
|
||||
})
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
orderDate: '20261011',
|
||||
protocolNo: '',
|
||||
motherName: '张女士',
|
||||
expectedDeliveryDate: '20270101',
|
||||
regionId: 'region-1',
|
||||
hospitalId: 'hospital-1',
|
||||
collectorUserId: 'collector-1',
|
||||
regionManagerUserId: 'manager-1',
|
||||
sampleCollectTime: '2026-10-11 08:30:00',
|
||||
payMode: 'dj',
|
||||
contractAmount: 0,
|
||||
orderStatus: 'active',
|
||||
sampleTypeIds: ['sample-1'],
|
||||
remark: '',
|
||||
transfers: [{
|
||||
transferType: 'wxzz',
|
||||
transferAmount: 100,
|
||||
transferTime: '2026-10-11 08:00:00',
|
||||
transferAttachments: 'proof.pdf',
|
||||
remark: '转账备注'
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
test('builds a transfer payload without ids, status, timestamps, or finance fields', () => {
|
||||
assert.deepEqual(buildPresignTransferPayload({
|
||||
id: 'transfer-1',
|
||||
presignOrderId: 'order-1',
|
||||
transferAmount: null,
|
||||
transferTime: '',
|
||||
transferType: '',
|
||||
transferAttachments: 'kept.pdf',
|
||||
payStatus: 'jfqrz',
|
||||
financeConfirmAttachments: 'finance.pdf',
|
||||
financeConfirmAttachmentFiles: [{ fileUrl: 'finance.pdf' }],
|
||||
financeRemark: '只读',
|
||||
createTime: '2026-10-11 09:00:00',
|
||||
updateTime: '2026-10-11 10:00:00',
|
||||
remark: ''
|
||||
}), {
|
||||
transferAmount: null,
|
||||
transferTime: '',
|
||||
transferType: '',
|
||||
transferAttachments: 'kept.pdf',
|
||||
remark: ''
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
const PRESIGN_STATUS = Object.freeze({
|
||||
draft: Object.freeze({ label: '草稿', tone: 'default' }),
|
||||
active: Object.freeze({ label: '进行中', tone: 'warning' }),
|
||||
converted: Object.freeze({ label: '已转正式', tone: 'success' }),
|
||||
void: Object.freeze({ label: '已作废', tone: 'danger' })
|
||||
})
|
||||
|
||||
function validDateParts(year, month, day) {
|
||||
const date = new Date(Date.UTC(year, month - 1, day))
|
||||
return date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month - 1 &&
|
||||
date.getUTCDate() === day
|
||||
}
|
||||
|
||||
function normalizedYmd(value) {
|
||||
const text = String(value ?? '').trim()
|
||||
const match = /^(?:(\d{4})(\d{2})(\d{2})|(\d{4})-(\d{2})-(\d{2}))$/.exec(text)
|
||||
if (!match) return ''
|
||||
const yearText = match[1] || match[4]
|
||||
const monthText = match[2] || match[5]
|
||||
const dayText = match[3] || match[6]
|
||||
if (!validDateParts(Number(yearText), Number(monthText), Number(dayText))) return ''
|
||||
return `${yearText}-${monthText}-${dayText}`
|
||||
}
|
||||
|
||||
export function formatPresignDate(value) {
|
||||
return normalizedYmd(value) || '-'
|
||||
}
|
||||
|
||||
export function formatPresignDateTime(value) {
|
||||
const text = String(value ?? '').trim()
|
||||
const dateOnly = normalizedYmd(text)
|
||||
if (dateOnly) return dateOnly
|
||||
const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,6})?)?$/.exec(text)
|
||||
if (!match) return '-'
|
||||
const date = normalizedYmd(match[1])
|
||||
const hour = Number(match[2])
|
||||
const minute = Number(match[3])
|
||||
const second = match[4] == null ? null : Number(match[4])
|
||||
if (!date || hour > 23 || minute > 59 || (second != null && second > 59)) return '-'
|
||||
const seconds = match[4] == null ? '' : `:${match[4]}${match[5] || ''}`
|
||||
return `${date} ${match[2]}:${match[3]}${seconds}`
|
||||
}
|
||||
|
||||
export function formatPresignMoney(value) {
|
||||
if (value == null || String(value).trim() === '') return '-'
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) return '-'
|
||||
return `¥${new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount)}`
|
||||
}
|
||||
|
||||
export function presignStatusLabel(value) {
|
||||
const code = String(value ?? '').trim()
|
||||
return PRESIGN_STATUS[code]?.label || code || '-'
|
||||
}
|
||||
|
||||
export function presignStatusTone(value) {
|
||||
return PRESIGN_STATUS[String(value ?? '').trim()]?.tone || 'default'
|
||||
}
|
||||
|
||||
export function presignCardIndex(page, pageSize, position) {
|
||||
const normalizedPage = Number(page)
|
||||
const normalizedPageSize = Number(pageSize)
|
||||
const normalizedPosition = Number(position)
|
||||
if (
|
||||
!Number.isInteger(normalizedPage) || normalizedPage < 1 ||
|
||||
!Number.isInteger(normalizedPageSize) || normalizedPageSize < 1 ||
|
||||
!Number.isInteger(normalizedPosition) || normalizedPosition < 1
|
||||
) {
|
||||
return '-'
|
||||
}
|
||||
return String((normalizedPage - 1) * normalizedPageSize + normalizedPosition)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatPresignDate,
|
||||
formatPresignDateTime,
|
||||
formatPresignMoney,
|
||||
presignCardIndex,
|
||||
presignStatusLabel,
|
||||
presignStatusTone
|
||||
} from './presign-presenter.js'
|
||||
|
||||
test('formats compact backend dates and preserves valid display dates', () => {
|
||||
assert.equal(formatPresignDate('20261011'), '2026-10-11')
|
||||
assert.equal(formatPresignDate('2026-10-11'), '2026-10-11')
|
||||
assert.equal(formatPresignDateTime('2026-10-11T08:09:10'), '2026-10-11 08:09:10')
|
||||
})
|
||||
|
||||
test('returns a safe placeholder for null, empty, or invalid dates', () => {
|
||||
for (const value of [null, undefined, '', 'not-a-date', '20260230', '2026-1011', '202610-11']) {
|
||||
assert.equal(formatPresignDate(value), '-')
|
||||
}
|
||||
assert.equal(formatPresignDateTime('2026-10-11'), '2026-10-11')
|
||||
assert.equal(formatPresignDateTime('bad'), '-')
|
||||
})
|
||||
|
||||
test('formats finite amounts with a currency prefix and two decimals', () => {
|
||||
assert.equal(formatPresignMoney(28800), '¥28,800.00')
|
||||
assert.equal(formatPresignMoney('0'), '¥0.00')
|
||||
for (const value of [null, undefined, '', 'invalid', Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
assert.equal(formatPresignMoney(value), '-')
|
||||
}
|
||||
})
|
||||
|
||||
test('uses the Web presign status labels and mobile semantic tones', () => {
|
||||
assert.equal(presignStatusLabel('draft'), '草稿')
|
||||
assert.equal(presignStatusLabel('active'), '进行中')
|
||||
assert.equal(presignStatusLabel('converted'), '已转正式')
|
||||
assert.equal(presignStatusLabel('void'), '已作废')
|
||||
assert.equal(presignStatusTone('converted'), 'success')
|
||||
assert.equal(presignStatusTone('void'), 'danger')
|
||||
assert.equal(presignStatusLabel('custom'), 'custom')
|
||||
assert.equal(presignStatusLabel(null), '-')
|
||||
})
|
||||
|
||||
test('computes a stable one-based sequence across pages', () => {
|
||||
assert.equal(presignCardIndex(1, 10, 1), '1')
|
||||
assert.equal(presignCardIndex(2, 10, 3), '13')
|
||||
assert.equal(presignCardIndex('bad', 10, 1), '-')
|
||||
})
|
||||
Reference in new issue
Block a user