feat: add tested mobile presign contracts and form state.

This commit is contained in:
zlwu committed 2026-10-11 12:49:21 +08:00
1 parent 702ff0e074
commit b28ed3e280
8 files changed
+992

No files matched your search

+163
View File
@@ -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)
}
+152
View File
@@ -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), '-')
})