diff --git a/mobile-oa-h5/src/api/market.js b/mobile-oa-h5/src/api/market.js new file mode 100644 index 00000000..86530803 --- /dev/null +++ b/mobile-oa-h5/src/api/market.js @@ -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) +} diff --git a/mobile-oa-h5/src/market/action-policy.js b/mobile-oa-h5/src/market/action-policy.js new file mode 100644 index 00000000..ec061972 --- /dev/null +++ b/mobile-oa-h5/src/market/action-policy.js @@ -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 +} diff --git a/mobile-oa-h5/src/market/api-contract.test.js b/mobile-oa-h5/src/market/api-contract.test.js new file mode 100644 index 00000000..b422bbfb --- /dev/null +++ b/mobile-oa-h5/src/market/api-contract.test.js @@ -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') + } +}) diff --git a/mobile-oa-h5/src/market/module-actions.test.js b/mobile-oa-h5/src/market/module-actions.test.js new file mode 100644 index 00000000..ca4a1de5 --- /dev/null +++ b/mobile-oa-h5/src/market/module-actions.test.js @@ -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) +}) diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.js b/mobile-oa-h5/src/market/presign/presign-form-state.js new file mode 100644 index 00000000..1053c8e1 --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-form-state.js @@ -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 +} diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.test.js b/mobile-oa-h5/src/market/presign/presign-form-state.test.js new file mode 100644 index 00000000..dd4b097a --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-form-state.test.js @@ -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: '' + }) +}) diff --git a/mobile-oa-h5/src/market/presign/presign-presenter.js b/mobile-oa-h5/src/market/presign/presign-presenter.js new file mode 100644 index 00000000..0aa4e967 --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-presenter.js @@ -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) +} diff --git a/mobile-oa-h5/src/market/presign/presign-presenter.test.js b/mobile-oa-h5/src/market/presign/presign-presenter.test.js new file mode 100644 index 00000000..61b8978c --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-presenter.test.js @@ -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), '-') +})