This commit is contained in:
zlwu committed 2026-10-14 12:32:03 +08:00
1 parent a2125374a8
commit 3950b5c334
157 files changed
+57538 -22359

No files matched your search

+25
View File
@@ -7,6 +7,31 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>中科兴皖 · OA 表单</title>
<!-- 市场菜单:在模块脚本/history API 之前写好 title,避免 web-view 顶栏被首屏默认标题盖住 -->
<script>
(function () {
try {
var path = String((location && location.pathname) || '')
var matched = path.match(
/(?:^|\/)market\/(specialist|manager)\/(presign|protocol|payment-chase|payment|freight)(?:\/|$)/
)
if (!matched) return
var modules = {
presign: '预签约订单',
protocol: '协议单',
'payment-chase': '催缴信息',
payment: '缴费信息',
freight: '运费信息'
}
var roles = { specialist: '专员', manager: '经理' }
var moduleTitle = modules[matched[2]]
if (!moduleTitle) return
document.title = moduleTitle + '(' + roles[matched[1]] + ')'
} catch (e) {
/* ignore */
}
})()
</script>
<!-- 微信小程序 web-view 内返回宿主页:须 JSSDK ≥ 1.3.2(wx.miniProgram.navigateBack) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
</head>
+20 -7
View File
@@ -68,20 +68,19 @@ export function buildMarketActionPath(context, action, id, data = {}) {
}
if (normalizedContext.module === 'payment') {
if (action === 'register-transfer') return `${roleBase}/payment-transfers`
if (['edit-transfer', 'submit', 'withdraw'].includes(action)) {
if (['edit-transfer', 'delete-payment-transfer', 'submit', 'withdraw'].includes(action)) {
const encodedId = encodeId()
const suffix = action === 'edit-transfer' ? '' : `/${action}`
const suffix = ['edit-transfer', 'delete-payment-transfer'].includes(action) ? '' : `/${action}`
return `${roleBase}/payment-transfers/${encodedId}${suffix}`
}
}
if (normalizedContext.module === 'protocol' && ['audit', 'delete'].includes(action)) {
if (normalizedContext.module === 'protocol' && action === 'delete') {
encodeId()
const source = String(data?.source || '').trim()
if (!['cell_storage', 'research_donation'].includes(source)) {
throw new Error('协议业务来源无效')
}
const suffix = action === 'audit' ? '/audit' : ''
return `${buildMarketResourcePath(context, id)}${suffix}?source=${encodeURIComponent(source)}`
return `${buildMarketResourcePath(context, id)}?source=${encodeURIComponent(source)}`
}
if (normalizedContext.module === 'freight' && ['link', 'unlink'].includes(action)) {
encodeId()
@@ -115,6 +114,20 @@ export async function fetchMarketFormOptions(context, params, { signal } = {}) {
return request({ url: buildMarketFormOptionsPath(context), method: 'GET', data: params, signal })
}
export async function fetchMarketProtocolCustomers(context, params, { signal } = {}) {
const { request } = await import('../utils/http.js')
const normalizedContext = assertContext(context)
if (normalizedContext.module !== 'protocol') {
throw new Error('仅协议模块支持客户档案检索')
}
return request({
url: `${buildMarketResourcePath(normalizedContext)}/customers`,
method: 'GET',
data: params,
signal
})
}
export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
const { request } = await import('../utils/http.js')
const normalizedContext = assertContext(context)
@@ -132,7 +145,7 @@ export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
}
export function marketActionMethod(action) {
if (['delete', 'delete-transfer'].includes(action)) return 'DELETE'
if (['delete', 'delete-transfer', 'delete-payment-transfer'].includes(action)) return 'DELETE'
return ['edit-transfer', 'edit-presign-transfer'].includes(action)
? 'PUT'
: 'POST'
@@ -140,7 +153,7 @@ export function marketActionMethod(action) {
export async function performMarketAction(context, action, { id, data } = {}, { signal } = {}) {
const { request } = await import('../utils/http.js')
const requestData = context.module === 'protocol' && ['audit', 'delete'].includes(action)
const requestData = context.module === 'protocol' && action === 'delete'
? Object.fromEntries(Object.entries(data || {}).filter(([key]) => key !== 'source'))
: data
return request({
+7
View File
@@ -9,6 +9,10 @@ import './styles/oa-form.css'
import './styles/oa-btn-loading.css'
import './styles/market.css'
import { registerOaComponents } from './oa/register-components'
import {
applyMarketDocumentTitle,
installMarketDocumentTitleHistoryGuard
} from './market/document-title'
import { captureTokenFromUrl } from './utils/auth'
// 品牌绿主色 — 方案 A 薄荷微风
@@ -22,7 +26,10 @@ document.documentElement.style.setProperty('--van-cell-line-height', '22px')
document.documentElement.style.setProperty('--van-cell-font-size', '14px')
// 须在路由/组件发请求前捕获小程序传入的 ?token=
// replaceState 摘 token 后,微信 web-view 常把标题打回首屏 HTML,故先装 guard 再摘 token
installMarketDocumentTitleHistoryGuard()
captureTokenFromUrl()
applyMarketDocumentTitle()
const router = createAppRouter()
const app = createApp(App)
+5 -1
View File
@@ -22,10 +22,14 @@ test('market API imports its context from a pure contract module', async () => {
assert.equal(moduleResourceName('presign'), 'presign-orders')
})
test('protocol write paths reject missing or invalid source scope', () => {
test('protocol writes reject web-only actions and invalid source scope', () => {
const protocol = { marketRole: 'specialist', module: 'protocol' }
assert.throws(
() => buildMarketActionPath(protocol, 'audit', 'protocol-1'),
/不支持/
)
assert.throws(
() => buildMarketActionPath(protocol, 'delete', 'protocol-1'),
/业务来源无效/
)
assert.throws(
+18 -4
View File
@@ -45,6 +45,11 @@ test('maps payment and freight write actions to their actual backend endpoints',
buildMarketActionPath(context, 'submit', 'transfer 1'),
'/mini/market/specialist/payment-transfers/transfer%201/submit'
)
assert.equal(
buildMarketActionPath(context, 'delete-payment-transfer', 'transfer 1'),
'/mini/market/specialist/payment-transfers/transfer%201'
)
assert.equal(marketActionMethod('delete-payment-transfer'), 'DELETE')
assert.equal(
buildMarketActionPath({ marketRole: 'manager', module: 'freight' }, 'link', 'f1'),
'/mini/market/manager/freights/f1/link'
@@ -52,11 +57,20 @@ test('maps payment and freight write actions to their actual backend endpoints',
assert.equal(buildMarketResourcePath(context, 'p1'), '/mini/market/specialist/payments/p1')
})
test('maps protocol audit and delete to source-scoped backend routes', () => {
const protocol = { marketRole: 'specialist', module: 'protocol' }
test('maps chase read-only detail to a schedule-bound endpoint', () => {
const chase = { marketRole: 'specialist', module: 'payment-chase' }
assert.equal(
buildMarketActionPath(protocol, 'audit', 'p 1', { source: 'cell_storage' }),
'/mini/market/specialist/protocols/p%201/audit?source=cell_storage'
buildMarketResourcePath(chase, 'schedule 1'),
'/mini/market/specialist/payment-chase/schedule%201'
)
})
test('maps protocol delete but rejects web-only protocol audit', () => {
const protocol = { marketRole: 'specialist', module: 'protocol' }
assert.throws(
() => buildMarketActionPath(protocol, 'audit', 'p 1', { source: 'cell_storage' }),
/不支持/
)
assert.equal(
buildMarketActionPath(protocol, 'delete', 'p/1', { source: 'research_donation' }),
@@ -41,10 +41,50 @@ function flattenAttachments(value) {
return []
}
/** 编辑态列表:保留已上传引用与上传中占位,供表单回写 */
export function flattenAttachmentsForEdit(value) {
return flattenAttachments(value)
.map((item) => {
if (typeof item === 'string') {
const url = item.trim()
return url ? { fileUrl: url } : null
}
if (!item || typeof item !== 'object') return null
if (item._uploading || item.uploading) {
const name = String(item.name || item.fileName || '上传中').trim() || '上传中'
return {
_uploading: true,
_pendingId: item._pendingId || item.pendingId || '',
fileName: name,
name
}
}
const url = String(item.fileUrl || item.url || '').trim()
if (!url) return null
const fileName = String(item.fileName || item.name || item.originalName || '').trim()
return fileName ? { fileUrl: url, fileName } : { fileUrl: url }
})
.filter(Boolean)
}
export function normalizeMarketAttachments(value) {
return flattenAttachments(value)
.map((item) => {
const source = typeof item === 'string' ? { url: item } : item
if (source?._uploading || source?.uploading) {
const pendingName = String(
source?.name || source?.fileName || source?.originalName || '上传中'
).trim()
return {
url: '',
name: pendingName || '上传中',
explicitName: true,
isImage: false,
kind: 'other',
uploading: true,
pendingId: source._pendingId || source.pendingId || ''
}
}
const url = String(source?.url || source?.fileUrl || '').trim()
if (!url) return null
const explicitNameValue = source?.name || source?.fileName || source?.originalName
@@ -58,17 +98,21 @@ export function normalizeMarketAttachments(value) {
name,
explicitName,
isImage: isMarketAttachmentImage({ ...source, url, name }),
kind: marketAttachmentKind(name || url)
kind: marketAttachmentKind(name || url),
uploading: false,
pendingId: ''
}
})
.filter(Boolean)
}
export function marketAttachmentRefs(value) {
return normalizeMarketAttachments(value).map((file) => ({
fileUrl: file.url,
...(file.explicitName ? { fileName: file.name } : {})
}))
return normalizeMarketAttachments(value)
.filter((file) => file.url && !file.uploading)
.map((file) => ({
fileUrl: file.url,
...(file.explicitName ? { fileName: file.name } : {})
}))
}
export function appendMarketAttachment(value, attachment) {
@@ -76,5 +120,8 @@ export function appendMarketAttachment(value, attachment) {
}
export function serializeMarketAttachments(value) {
return normalizeMarketAttachments(value).map((file) => file.url).join(',')
return normalizeMarketAttachments(value)
.filter((file) => file.url && !file.uploading)
.map((file) => file.url)
.join(',')
}
@@ -20,14 +20,18 @@ test('normalizes comma-separated attachment urls without exposing encoded names'
name: 'a b.pdf',
explicitName: false,
isImage: false,
kind: 'pdf'
kind: 'pdf',
uploading: false,
pendingId: ''
},
{
url: 'https://cdn.example.com/photo.png?token=1',
name: 'photo.png',
explicitName: false,
isImage: true,
kind: 'other'
kind: 'other',
uploading: false,
pendingId: ''
}
]
)
@@ -47,14 +51,18 @@ test('preserves object file metadata while accepting fileUrl aliases', () => {
name: '费用明细.xlsx',
explicitName: true,
isImage: false,
kind: 'xls'
kind: 'xls',
uploading: false,
pendingId: ''
},
{
url: '/minio/x/form.docx',
name: '知情同意书.docx',
explicitName: true,
isImage: false,
kind: 'doc'
kind: 'doc',
uploading: false,
pendingId: ''
}
]
)
@@ -71,6 +79,43 @@ test('classifies workflow file icons and image extensions', () => {
assert.equal(isMarketAttachmentImage({ name: 'contract.pdf' }), false)
})
test('keeps uploading placeholders visible without treating them as persisted refs', () => {
assert.deepEqual(
normalizeMarketAttachments([
{ fileUrl: '/minio/done.pdf', fileName: '已传.pdf' },
{ _uploading: true, _pendingId: 'p1', fileName: '凭证.jpg' },
{ url: '' }
]),
[
{
url: '/minio/done.pdf',
name: '已传.pdf',
explicitName: true,
isImage: false,
kind: 'pdf',
uploading: false,
pendingId: ''
},
{
url: '',
name: '凭证.jpg',
explicitName: true,
isImage: false,
kind: 'other',
uploading: true,
pendingId: 'p1'
}
]
)
assert.deepEqual(
marketAttachmentRefs([
{ fileUrl: '/minio/done.pdf', fileName: '已传.pdf' },
{ _uploading: true, _pendingId: 'p1', fileName: '凭证.jpg' }
]),
[{ fileUrl: '/minio/done.pdf', fileName: '已传.pdf' }]
)
})
test('keeps URL-derived names implicit after attachment add and remove cycles', () => {
const refs = appendMarketAttachment('/minio/uuid-old.pdf', {
fileUrl: '/minio/uuid-new.pdf',
@@ -14,3 +14,16 @@ export const MARKET_ATTACHMENT_ACCEPT = [
export const MARKET_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
export const PRESIGN_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024
export const PRESIGN_ATTACHMENT_ACCEPT = [
'image/*', 'video/*', '.pdf', '.doc', '.docx', '.xls', '.xlsx',
'.ppt', '.pptx', '.txt', '.zip', '.rar'
].join(',')
export const PROTOCOL_ATTACHMENT_ACCEPT = [
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', '.gif', '.bmp',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.zip', '.rar'
].join(',')
export const PROTOCOL_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024
@@ -4,12 +4,15 @@ import test from 'node:test'
import {
MARKET_ATTACHMENT_ACCEPT,
MARKET_ATTACHMENT_MAX_BYTES,
PROTOCOL_ATTACHMENT_ACCEPT,
PROTOCOL_ATTACHMENT_MAX_BYTES,
PRESIGN_ATTACHMENT_MAX_BYTES
} from './attachment-upload-policy.js'
test('matches backend market attachment size limits', () => {
assert.equal(MARKET_ATTACHMENT_MAX_BYTES, 10 * 1024 * 1024)
assert.equal(PRESIGN_ATTACHMENT_MAX_BYTES, 20 * 1024 * 1024)
assert.equal(PROTOCOL_ATTACHMENT_MAX_BYTES, 20 * 1024 * 1024)
})
test('only accepts file extensions supported by the market backend', () => {
@@ -20,3 +23,13 @@ test('only accepts file extensions supported by the market backend', () => {
assert.doesNotMatch(MARKET_ATTACHMENT_ACCEPT, new RegExp(`(?:^|,)\\${extension}(?:,|$)`))
}
})
test('protocol attachments match the Web image document video and archive contract', () => {
for (const extension of [
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', '.gif', '.bmp',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.zip', '.rar'
]) {
assert.match(PROTOCOL_ATTACHMENT_ACCEPT, new RegExp(`(?:^|,)\\${extension}(?:,|$)`))
}
})
@@ -0,0 +1,44 @@
import { flattenAttachmentsForEdit } from './attachment-presenter.js'
let pendingSeq = 0
export function createPendingMarketAttachment(file) {
pendingSeq += 1
const name = String(file?.name || '上传中').trim() || '上传中'
return {
_uploading: true,
_pendingId: `pending-${Date.now()}-${pendingSeq}`,
fileName: name,
name
}
}
export function listMarketAttachments(value) {
return flattenAttachmentsForEdit(value)
}
export function withPendingMarketAttachment(value, pending) {
return [...listMarketAttachments(value), pending]
}
export function resolvePendingMarketAttachment(value, pendingId, uploaded = {}) {
const id = String(pendingId || '')
const url = String(uploaded?.fileUrl || uploaded?.url || '').trim()
const fileName = String(uploaded?.fileName || uploaded?.name || '').trim()
return listMarketAttachments(value).map((item) => {
if (String(item?._pendingId || '') !== id) return item
if (!url) return item
return fileName
? { ...uploaded, fileUrl: url, fileName }
: { ...uploaded, fileUrl: url }
})
}
export function dropPendingMarketAttachment(value, pendingId) {
const id = String(pendingId || '')
return listMarketAttachments(value).filter((item) => String(item?._pendingId || '') !== id)
}
export function removeMarketAttachmentAt(value, index) {
return listMarketAttachments(value).filter((_, itemIndex) => itemIndex !== index)
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createPendingMarketAttachment,
dropPendingMarketAttachment,
listMarketAttachments,
removeMarketAttachmentAt,
resolvePendingMarketAttachment,
withPendingMarketAttachment
} from './attachment-upload-state.js'
test('pending attachment lifecycle keeps completed files and replaces placeholder', () => {
const pending = createPendingMarketAttachment({ name: '凭证.jpg' })
assert.equal(pending._uploading, true)
assert.ok(pending._pendingId)
assert.equal(pending.fileName, '凭证.jpg')
const withPending = withPendingMarketAttachment('https://a/done.pdf', pending)
assert.deepEqual(withPending, [
{ fileUrl: 'https://a/done.pdf' },
pending
])
const resolved = resolvePendingMarketAttachment(withPending, pending._pendingId, {
fileUrl: 'https://a/凭证.jpg',
fileName: '凭证.jpg'
})
assert.deepEqual(resolved, [
{ fileUrl: 'https://a/done.pdf' },
{ fileUrl: 'https://a/凭证.jpg', fileName: '凭证.jpg' }
])
const dropped = dropPendingMarketAttachment(withPending, pending._pendingId)
assert.deepEqual(dropped, [{ fileUrl: 'https://a/done.pdf' }])
})
test('list and remove keep pending placeholders aligned with display indexes', () => {
const pending = createPendingMarketAttachment({ name: '新.pdf' })
const list = listMarketAttachments([
{ fileUrl: 'https://a/1.pdf', fileName: '一.pdf' },
pending
])
assert.equal(list.length, 2)
assert.deepEqual(removeMarketAttachmentAt(list, 0), [pending])
})
@@ -0,0 +1,114 @@
const STALE_PREVIEW_CODE = 'MARKET_ATTACHMENT_PREVIEW_STALE'
function contextKey(context) {
return `${String(context?.marketRole || '').trim()}:${String(context?.module || '').trim()}`
}
function stalePreviewError() {
const error = new Error('附件预览请求已失效')
error.code = STALE_PREVIEW_CODE
return error
}
export function isAuthorizedImagePreviewStale(error) {
return error?.code === STALE_PREVIEW_CODE
}
export function createAuthorizedImagePreviewResolver(fetchDownloadUrl) {
if (typeof fetchDownloadUrl !== 'function') {
throw new TypeError('附件预览需要授权下载解析器')
}
const cache = new Map()
let activeContextKey = ''
let generation = 0
let disposed = false
function clearRequests() {
generation += 1
for (const entry of cache.values()) entry.controller.abort()
cache.clear()
}
function activateContext(context) {
const nextContextKey = contextKey(context)
if (nextContextKey !== activeContextKey) {
clearRequests()
activeContextKey = nextContextKey
}
return nextContextKey
}
function resolve(context, file) {
if (disposed) return Promise.reject(stalePreviewError())
const requestedContextKey = activateContext(context)
const fileUrl = String(file?.url || file?.fileUrl || '').trim()
if (!fileUrl) return Promise.reject(new Error('附件地址无效'))
const existing = cache.get(fileUrl)
if (existing) return existing.promise
const controller = new AbortController()
const requestGeneration = generation
const explicitFileName = file?.explicitName
? String(file?.name || file?.fileName || '').trim()
: ''
const payload = {
fileUrl,
...(explicitFileName ? { fileName: explicitFileName } : {})
}
let response
try {
response = fetchDownloadUrl(context, payload, { signal: controller.signal })
} catch (error) {
response = Promise.reject(error)
}
let promise
promise = Promise.resolve(response)
.then((result) => {
const downloadUrl = String(result?.downloadUrl || '').trim()
if (!downloadUrl) throw new Error('无法获取附件预览链接')
if (
disposed
|| generation !== requestGeneration
|| activeContextKey !== requestedContextKey
) {
throw stalePreviewError()
}
return downloadUrl
})
.catch((error) => {
if (cache.get(fileUrl)?.promise === promise) cache.delete(fileUrl)
if (
disposed
|| generation !== requestGeneration
|| activeContextKey !== requestedContextKey
) {
throw stalePreviewError()
}
throw error
})
cache.set(fileUrl, { controller, promise })
return promise
}
function evict(fileUrl) {
const normalizedUrl = String(fileUrl || '').trim()
const entry = cache.get(normalizedUrl)
if (!entry) return
entry.controller.abort()
cache.delete(normalizedUrl)
}
function invalidate() {
clearRequests()
}
function dispose() {
disposed = true
clearRequests()
}
return { resolve, evict, invalidate, dispose }
}
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createAuthorizedImagePreviewResolver,
isAuthorizedImagePreviewStale
} from './authorized-image-preview.js'
const specialistProtocol = { marketRole: 'specialist', module: 'protocol' }
const managerProtocol = { marketRole: 'manager', module: 'protocol' }
function deferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
test('returns only the signed image URL and sends the raw URL only to the authorized fetcher', async () => {
const requests = []
const resolver = createAuthorizedImagePreviewResolver(async (context, payload) => {
requests.push({ context, payload })
return { downloadUrl: 'https://signed.test/image-token' }
})
const result = await resolver.resolve(specialistProtocol, {
url: 'market/specialist/protocol/raw-image.png',
name: '储存证.png',
explicitName: true
})
assert.equal(result, 'https://signed.test/image-token')
assert.deepEqual(requests, [
{
context: specialistProtocol,
payload: {
fileUrl: 'market/specialist/protocol/raw-image.png',
fileName: '储存证.png'
}
}
])
assert.notEqual(result, requests[0].payload.fileUrl)
})
test('rejects denied and empty signed responses without falling back to the raw URL', async () => {
const denied = createAuthorizedImagePreviewResolver(async () => {
throw new Error('附件不存在或无权下载')
})
const empty = createAuthorizedImagePreviewResolver(async () => ({ downloadUrl: '' }))
const file = { url: 'market/specialist/protocol/private.png' }
await assert.rejects(denied.resolve(specialistProtocol, file), /无权下载/)
await assert.rejects(empty.resolve(specialistProtocol, file), /无法获取附件预览链接/)
})
test('deduplicates the same image URL within one context', async () => {
const pending = deferred()
let requestCount = 0
const resolver = createAuthorizedImagePreviewResolver(async () => {
requestCount += 1
return pending.promise
})
const first = resolver.resolve(specialistProtocol, { url: 'same.png' })
const second = resolver.resolve(specialistProtocol, {
url: 'same.png',
name: '另一个显示名称.png',
explicitName: true
})
assert.strictEqual(first, second)
assert.equal(requestCount, 1)
pending.resolve({ downloadUrl: 'https://signed.test/same' })
assert.deepEqual(await Promise.all([first, second]), [
'https://signed.test/same',
'https://signed.test/same'
])
})
test('context changes invalidate cached and in-flight signed URLs', async () => {
const specialistRequest = deferred()
const managerRequest = deferred()
const requests = []
const resolver = createAuthorizedImagePreviewResolver(async (context) => {
requests.push(context)
return context.marketRole === 'specialist'
? specialistRequest.promise
: managerRequest.promise
})
const stale = resolver.resolve(specialistProtocol, { url: 'shared.png' })
const current = resolver.resolve(managerProtocol, { url: 'shared.png' })
specialistRequest.resolve({ downloadUrl: 'https://signed.test/stale' })
managerRequest.resolve({ downloadUrl: 'https://signed.test/current' })
await assert.rejects(stale, isAuthorizedImagePreviewStale)
assert.equal(await current, 'https://signed.test/current')
assert.equal(requests.length, 2)
})
test('dispose invalidates an in-flight response so an unmounted component cannot use it', async () => {
const pending = deferred()
const resolver = createAuthorizedImagePreviewResolver(async () => pending.promise)
const stale = resolver.resolve(specialistProtocol, { url: 'late.png' })
resolver.dispose()
pending.resolve({ downloadUrl: 'https://signed.test/late' })
await assert.rejects(stale, isAuthorizedImagePreviewStale)
await assert.rejects(
resolver.resolve(specialistProtocol, { url: 'after-unmount.png' }),
isAuthorizedImagePreviewStale
)
})
+18
View File
@@ -0,0 +1,18 @@
export async function deleteMarketItemsSequentially(items, deleter) {
const records = Array.isArray(items) ? items : []
let succeeded = 0
for (const item of records) {
try {
await deleter(item)
succeeded += 1
} catch (error) {
return {
succeeded,
failed: 1,
unprocessed: Math.max(0, records.length - succeeded - 1),
error
}
}
}
return { succeeded, failed: 0, unprocessed: 0, error: null }
}
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { deleteMarketItemsSequentially } from './batch-delete.js'
test('deletes selected records sequentially and reports every success', async () => {
const calls = []
const result = await deleteMarketItemsSequentially(
[{ id: 'p1' }, { id: 'p2' }],
async (item) => calls.push(item.id)
)
assert.deepEqual(calls, ['p1', 'p2'])
assert.deepEqual(result, { succeeded: 2, failed: 0, unprocessed: 0, error: null })
})
test('stops on the first delete failure and reports unprocessed records', async () => {
const calls = []
const failure = new Error('无权删除')
const result = await deleteMarketItemsSequentially(
[{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }],
async (item) => {
calls.push(item.id)
if (item.id === 'p2') throw failure
}
)
assert.deepEqual(calls, ['p1', 'p2'])
assert.deepEqual(result, { succeeded: 1, failed: 1, unprocessed: 1, error: failure })
})
@@ -3,7 +3,7 @@
<van-uploader
v-if="editable"
class="market-attachment-cards__uploader"
:disabled="uploading"
:disabled="legacyUploading"
:after-read="afterRead"
:max-size="maxSize"
:accept="accept"
@@ -11,78 +11,88 @@
@oversize="showToast(maxSizeMessage)"
>
<div class="market-attachment-card is-add">
<van-loading v-if="uploading" size="22" color="#0a9f6d" />
<van-loading v-if="legacyUploading" size="22" color="#0a9f6d" />
<span v-else class="market-attachment-card__plus">+</span>
<span>{{ uploading ? '上传中…' : '添加' }}</span>
<span>{{ legacyUploading ? '上传中…' : '添加' }}</span>
</div>
</van-uploader>
<article
v-for="(file, index) in files"
:key="`${file.url}:${index}`"
class="market-attachment-card is-file"
:key="file.uploading ? `pending:${file.pendingId || index}` : `${file.url}:${index}`"
class="market-attachment-card"
:class="file.uploading ? 'is-uploading' : 'is-file'"
>
<button
type="button"
class="market-attachment-card__main"
:aria-label="file.isImage ? `预览${file.name}` : `下载${file.name}`"
@click="openAttachment(file, index)"
>
<img
v-if="imageVisible(file, index)"
:src="fullUrl(file.url)"
:alt="file.name"
@error="markImageFailed(file, index)"
/>
<span
v-else
class="market-attachment-file-icon"
:class="`ft-${file.kind || 'other'}`"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path v-if="file.kind === 'xls'" d="M8 13h8M8 17h5" />
</svg>
</span>
<div v-if="file.uploading" class="market-attachment-card__pending">
<van-loading size="22" color="#0a9f6d" />
<span class="market-attachment-card__name" :title="file.name">
{{ shortName(file.name) }}
</span>
<span v-if="!imageVisible(file, index)" class="market-attachment-card__download">
点击下载
</span>
</button>
<button
type="button"
class="market-attachment-card__download-action"
:aria-label="`下载${file.name}`"
@click.stop="downloadAttachment(file)"
>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</svg>
</span>
</button>
<button
v-if="editable"
type="button"
class="market-attachment-card__delete"
:aria-label="`删除${file.name}`"
:disabled="uploading"
@click="$emit('remove', index)"
>
<span>×</span>
</button>
<span class="market-attachment-card__download">上传中…</span>
</div>
<template v-else>
<button
type="button"
class="market-attachment-card__main"
:aria-label="file.isImage ? `预览${file.name}` : `下载${file.name}`"
@click="openAttachment(file)"
>
<img
v-if="imageVisible(file)"
:src="authorizedImageUrl(file)"
:alt="file.name"
@error="markImageFailed(file)"
/>
<span
v-else
class="market-attachment-file-icon"
:class="`ft-${file.kind || 'other'}`"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path v-if="file.kind === 'xls'" d="M8 13h8M8 17h5" />
</svg>
</span>
<span class="market-attachment-card__name" :title="file.name">
{{ shortName(file.name) }}
</span>
<span v-if="!imageVisible(file)" class="market-attachment-card__download">
{{ attachmentHint(file) }}
</span>
</button>
<button
type="button"
class="market-attachment-card__download-action"
:aria-label="`下载${file.name}`"
@click.stop="downloadAttachment(file)"
>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</svg>
</span>
</button>
<button
v-if="editable"
type="button"
class="market-attachment-card__delete"
:aria-label="`删除${file.name}`"
:disabled="uploading"
@click="$emit('remove', index)"
>
<span>×</span>
</button>
</template>
</article>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { showImagePreview, showToast } from 'vant'
import { fetchMarketAttachmentDownloadUrl } from '../../api/market.js'
@@ -92,7 +102,10 @@ import {
MARKET_ATTACHMENT_ACCEPT,
MARKET_ATTACHMENT_MAX_BYTES
} from '../attachment-upload-policy.js'
import { attachmentFullUrl } from '../../utils/attachment.js'
import {
createAuthorizedImagePreviewResolver,
isAuthorizedImagePreviewStale
} from '../authorized-image-preview.js'
import { downloadAttachmentFile } from '../../utils/download-attachment.js'
const props = defineProps({
@@ -110,28 +123,123 @@ const emit = defineEmits(['upload', 'remove'])
const route = useRoute()
const context = computed(() => getMarketRouteContext(route))
const failedImages = ref(new Set())
const contextSignature = computed(
() => `${context.value.marketRole}:${context.value.module}`
)
const authorizedImages = ref({})
const imageStates = ref({})
const files = computed(() => normalizeMarketAttachments(props.modelValue))
const hasPendingUpload = computed(() => files.value.some((file) => file.uploading))
/** 无占位附件时,退回「添加」按钮上的上传中态,兼容旧调用方 */
const legacyUploading = computed(() => props.uploading && !hasPendingUpload.value)
const maxSizeMessage = computed(
() => `单个附件不能超过${Math.floor(props.maxSize / 1024 / 1024)}MB`
)
function fileKey(file, index) {
return `${file.url}:${index}`
const previewResolver = createAuthorizedImagePreviewResolver(
(previewContext, data, options) =>
fetchMarketAttachmentDownloadUrl(previewContext, data, options)
)
let previewLoadVersion = 0
let componentDisposed = false
function authorizedImageUrl(file) {
return String(authorizedImages.value[file.url] || '')
}
function fullUrl(url) {
return attachmentFullUrl(url) || url
function imageVisible(file) {
return file.isImage && Boolean(authorizedImageUrl(file))
}
function imageVisible(file, index) {
return file.isImage && !failedImages.value.has(fileKey(file, index))
function setImageState(fileUrl, state) {
imageStates.value = { ...imageStates.value, [fileUrl]: state }
}
function markImageFailed(file, index) {
failedImages.value = new Set([...failedImages.value, fileKey(file, index)])
function setAuthorizedImage(fileUrl, downloadUrl) {
authorizedImages.value = {
...authorizedImages.value,
[fileUrl]: downloadUrl
}
}
function clearAuthorizedImage(fileUrl) {
const nextImages = { ...authorizedImages.value }
delete nextImages[fileUrl]
authorizedImages.value = nextImages
}
function markImageFailed(file) {
previewResolver.evict(file.url)
clearAuthorizedImage(file.url)
setImageState(file.url, 'error')
}
function attachmentHint(file) {
if (!file.isImage) return '点击下载'
if (imageStates.value[file.url] === 'loading') return '加载中…'
return '点击重试'
}
async function loadAuthorizedImage(file, version, { retry = false } = {}) {
if (!file.isImage || componentDisposed) return ''
if (retry) {
previewResolver.evict(file.url)
clearAuthorizedImage(file.url)
}
setImageState(file.url, 'loading')
try {
const downloadUrl = await previewResolver.resolve(context.value, file)
if (componentDisposed || version !== previewLoadVersion) return ''
setAuthorizedImage(file.url, downloadUrl)
setImageState(file.url, 'ready')
return downloadUrl
} catch (error) {
if (
componentDisposed
|| version !== previewLoadVersion
|| isAuthorizedImagePreviewStale(error)
) {
return ''
}
clearAuthorizedImage(file.url)
setImageState(file.url, 'error')
return ''
}
}
function refreshAuthorizedImages() {
const version = ++previewLoadVersion
const imageFiles = files.value.filter((file) => file.isImage)
const currentUrls = new Set(imageFiles.map((file) => file.url))
authorizedImages.value = Object.fromEntries(
Object.entries(authorizedImages.value).filter(([url]) => currentUrls.has(url))
)
imageStates.value = Object.fromEntries(
Object.entries(imageStates.value).filter(([url]) => currentUrls.has(url))
)
for (const file of imageFiles) loadAuthorizedImage(file, version)
}
watch(
[contextSignature, files],
([nextContext], previous = []) => {
const previousContext = previous[0]
if (previousContext !== undefined && nextContext !== previousContext) {
previewResolver.invalidate()
authorizedImages.value = {}
imageStates.value = {}
}
refreshAuthorizedImages()
},
{ immediate: true }
)
onBeforeUnmount(() => {
componentDisposed = true
previewLoadVersion += 1
previewResolver.dispose()
})
function shortName(value) {
const name = String(value || '未命名文件')
if (name.length <= 12) return name
@@ -150,14 +258,22 @@ function afterRead(value) {
}
}
async function openAttachment(file, index) {
const url = fullUrl(file.url)
if (!url) {
showToast('附件地址无效')
return
}
if (imageVisible(file, index)) {
showImagePreview({ images: [url], closeable: true })
async function openAttachment(file) {
if (file.isImage) {
const version = previewLoadVersion
let authorizedUrl = authorizedImageUrl(file)
if (!authorizedUrl) {
authorizedUrl = await loadAuthorizedImage(file, version, {
retry: imageStates.value[file.url] === 'error'
})
}
if (!authorizedUrl) {
if (!componentDisposed && version === previewLoadVersion) {
showToast('附件不存在或无权预览')
}
return
}
showImagePreview({ images: [authorizedUrl], closeable: true })
return
}
await downloadAttachment(file)
@@ -218,6 +334,26 @@ async function downloadAttachment(file) {
font-weight: 650;
}
.market-attachment-card.is-uploading {
border: 1.5px dashed #9ad9be;
background: linear-gradient(145deg, #f7fdf9, #e9f9f2);
}
.market-attachment-card__pending {
position: relative;
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 6px 20px;
overflow: hidden;
border-radius: inherit;
color: #087f57;
}
.market-attachment-card__plus {
display: grid;
width: 28px;
@@ -20,7 +20,6 @@ import { computed } from 'vue'
const ACTION_LABELS = {
edit: '编辑',
audit: '审核',
delete: '删除',
create: '新增',
convert: '转正式协议',
@@ -28,6 +27,7 @@ const ACTION_LABELS = {
withdraw: '撤回',
'register-transfer': '登记转账',
'edit-transfer': '编辑转账',
'delete-payment-transfer': '删除转账',
'create-transfer': '添加意向金',
'edit-presign-transfer': '编辑意向金',
'delete-transfer': '删除意向金',
@@ -0,0 +1,54 @@
<template>
<article class="market-detail-section__nested-card">
<strong>{{ card.title }}</strong>
<van-tag
v-if="card.statusText"
class="market-tag"
:type="card.statusTone"
round
:title="card.statusText"
>
{{ card.statusText }}
</van-tag>
<div
v-for="field in card.fields"
:key="field.label"
class="market-detail-section__nested-field"
:class="{ 'is-attachments': field.type === 'attachments' }"
>
<small>{{ field.label }}</small>
<MarketAttachmentCards
v-if="field.type === 'attachments'"
:model-value="field.value"
:editable="false"
/>
<van-tag
v-else-if="field.tag"
class="market-tag"
:type="field.tagType || 'primary'"
round
:title="field.value"
>
{{ field.value }}
</van-tag>
<span v-else>{{ field.value }}</span>
</div>
<div v-if="card.cards?.length" class="market-detail-section__subcards">
<MarketDetailNestedCard
v-for="child in card.cards"
:key="child.key"
:card="child"
/>
</div>
</article>
</template>
<script setup>
import MarketAttachmentCards from './MarketAttachmentCards.vue'
defineOptions({ name: 'MarketDetailNestedCard' })
defineProps({
card: { type: Object, required: true }
})
</script>
@@ -18,46 +18,11 @@
</template>
</dl>
<div v-if="normalizedCards.length" class="market-detail-section__nested">
<article v-for="card in normalizedCards" :key="card.key" class="market-detail-section__nested-card">
<strong>{{ card.title }}</strong>
<van-tag v-if="card.statusText" class="market-tag" :type="card.statusTone" round :title="card.statusText">{{ card.statusText }}</van-tag>
<div
v-for="field in card.fields"
:key="field.label"
class="market-detail-section__nested-field"
:class="{ 'is-attachments': field.type === 'attachments' }"
>
<small>{{ field.label }}</small>
<MarketAttachmentCards
v-if="field.type === 'attachments'"
:model-value="field.value"
:editable="false"
/>
<span v-else>{{ field.value }}</span>
</div>
<div v-if="card.cards?.length" class="market-detail-section__subcards">
<article v-for="subcard in card.cards" :key="subcard.key" class="market-detail-section__subcard">
<strong>{{ subcard.title }}</strong>
<van-tag v-if="subcard.statusText" class="market-tag" :type="subcard.statusTone" round :title="subcard.statusText">
{{ subcard.statusText }}
</van-tag>
<div
v-for="field in subcard.fields"
:key="field.label"
class="market-detail-section__nested-field"
:class="{ 'is-attachments': field.type === 'attachments' }"
>
<small>{{ field.label }}</small>
<MarketAttachmentCards
v-if="field.type === 'attachments'"
:model-value="field.value"
:editable="false"
/>
<span v-else>{{ field.value }}</span>
</div>
</article>
</div>
</article>
<MarketDetailNestedCard
v-for="card in normalizedCards"
:key="card.key"
:card="card"
/>
</div>
</section>
</template>
@@ -66,6 +31,7 @@
import { computed } from 'vue'
import { resolveStatusTone } from '../status-tone.js'
import MarketAttachmentCards from './MarketAttachmentCards.vue'
import MarketDetailNestedCard from './MarketDetailNestedCard.vue'
const props = defineProps({
title: { type: String, required: true },
@@ -0,0 +1,147 @@
<template>
<van-popup
:show="show"
position="bottom"
round
closeable
class="market-filter-sheet"
@update:show="emit('update:show', $event)"
>
<div class="market-filter-sheet__header">筛选</div>
<div class="market-filter-sheet__body">
<template v-for="field in fields" :key="field.key">
<van-field
v-if="field.type === 'select'"
:model-value="selectLabel(field)"
:label="field.label"
readonly
clickable
is-link
placeholder="全部"
@click="openSelect(field)"
/>
<div v-else-if="field.type === 'date-range'" class="market-filter-sheet__range">
<div class="market-filter-sheet__range-label">{{ field.label }}</div>
<div class="market-filter-sheet__range-fields">
<van-field
:model-value="rangeValue(field.key, 0)"
type="date"
label="开始"
@update:model-value="updateRange(field.key, 0, $event)"
/>
<van-field
:model-value="rangeValue(field.key, 1)"
type="date"
label="结束"
@update:model-value="updateRange(field.key, 1, $event)"
/>
</div>
</div>
</template>
</div>
<div class="market-filter-sheet__actions">
<van-button block round @click="reset">重置</van-button>
<van-button block round type="primary" color="#0a9f6d" @click="apply">确定</van-button>
</div>
<van-popup v-model:show="selectVisible" position="bottom" round>
<van-picker
:title="activeField?.label || '请选择'"
:columns="pickerColumns"
@confirm="confirmSelect"
@cancel="selectVisible = false"
/>
</van-popup>
</van-popup>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
modelValue: { type: Object, required: true },
fields: { type: Array, default: () => [] },
options: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:show', 'update:modelValue', 'apply', 'reset'])
const draft = ref({})
const activeField = ref(null)
const selectVisible = ref(false)
function cloneValue(value) {
return Object.fromEntries(
Object.entries(value || {}).map(([key, item]) => [key, Array.isArray(item) ? [...item] : item])
)
}
watch(
() => props.show,
(visible) => {
if (visible) draft.value = cloneValue(props.modelValue)
},
{ immediate: true }
)
function fieldOptions(field) {
const rows = field.optionKey ? props.options[field.optionKey] : field.options
return (Array.isArray(rows) ? rows : []).map((item) => ({
text: String(item?.text ?? item?.label ?? item?.name ?? item?.value ?? ''),
value: String(item?.value ?? item?.id ?? item?.code ?? '')
}))
}
const pickerColumns = computed(() => [
{ text: '全部', value: '' },
...fieldOptions(activeField.value || {})
])
function selectLabel(field) {
const selected = String(draft.value?.[field.key] ?? '')
return fieldOptions(field).find((item) => item.value === selected)?.text || ''
}
function openSelect(field) {
activeField.value = field
selectVisible.value = true
}
function confirmSelect({ selectedOptions }) {
if (activeField.value) {
draft.value[activeField.value.key] = String(selectedOptions?.[0]?.value ?? '')
}
selectVisible.value = false
}
function rangeValue(key, index) {
return Array.isArray(draft.value?.[key]) ? String(draft.value[key][index] || '') : ''
}
function updateRange(key, index, value) {
const range = Array.isArray(draft.value?.[key]) ? [...draft.value[key]] : ['', '']
range[index] = String(value || '')
draft.value[key] = range
}
function reset() {
const cleared = Object.fromEntries(
Object.entries(draft.value).map(([key, value]) => [key, Array.isArray(value) ? [] : ''])
)
if (Object.prototype.hasOwnProperty.call(props.modelValue, 'source')) {
cleared.source = props.modelValue.source
}
draft.value = cleared
emit('update:modelValue', cloneValue(cleared))
emit('reset', cloneValue(cleared))
emit('update:show', false)
}
function apply() {
const value = cloneValue(draft.value)
emit('update:modelValue', value)
emit('apply', value)
emit('update:show', false)
}
</script>
@@ -11,6 +11,16 @@
shape="square"
@update:model-value="update"
>{{ field.label }}</van-checkbox>
<div v-else-if="field.type === 'customerSearch'" class="market-form-field__control">
<ProtocolCustomerSearchField
:model-value="modelValue ?? ''"
:disabled="disabled || field.readOnly"
:title="field.role === 'father' ? '选择父亲档案' : '选择母亲档案'"
:search="searchCustomers"
@update:model-value="update"
@select="emit('customer-select', { field, customer: $event })"
/>
</div>
<div v-else-if="field.type === 'select' || field.type === 'multi-select'" class="market-form-field__control">
<OaSelectField
:model-value="modelValue"
@@ -48,6 +58,8 @@
:model-value="modelValue"
:editable="!disabled && !field.readOnly"
:uploading="uploading"
:max-size="attachmentMaxSize"
:accept="attachmentAccept"
@upload="$emit('upload', $event)"
@remove="removeAttachment"
/>
@@ -68,28 +80,54 @@
import { computed, useId } from 'vue'
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
import OaSelectField from '../../oa/components/OaSelectField.vue'
import { marketAttachmentRefs } from '../attachment-presenter.js'
import { removeMarketAttachmentAt } from '../attachment-upload-state.js'
import {
MARKET_ATTACHMENT_ACCEPT,
MARKET_ATTACHMENT_MAX_BYTES,
PROTOCOL_ATTACHMENT_ACCEPT,
PROTOCOL_ATTACHMENT_MAX_BYTES
} from '../attachment-upload-policy.js'
import MarketAttachmentCards from './MarketAttachmentCards.vue'
import ProtocolCustomerSearchField from './ProtocolCustomerSearchField.vue'
const props = defineProps({
field: { type: Object, required: true },
modelValue: { type: [String, Number, Boolean, Array], default: '' },
options: { type: Array, default: () => [] },
uploading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
disabled: { type: Boolean, default: false },
searchCustomers: { type: Function, default: null }
})
const emit = defineEmits(['update:modelValue', 'upload'])
const emit = defineEmits(['update:modelValue', 'upload', 'customer-select'])
const inputType = computed(() => ({ number: 'number', tel: 'tel' })[props.field.type] || 'text')
const attachmentMaxSize = computed(() =>
props.field.attachmentPolicy === 'protocol'
? PROTOCOL_ATTACHMENT_MAX_BYTES
: MARKET_ATTACHMENT_MAX_BYTES
)
const attachmentAccept = computed(() =>
props.field.attachmentPolicy === 'protocol'
? PROTOCOL_ATTACHMENT_ACCEPT
: MARKET_ATTACHMENT_ACCEPT
)
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const controlId = computed(() => `market-field-${instanceId}-${props.field.key}`)
const labelId = computed(() => `${controlId.value}-label`)
async function searchCustomers(keyword) {
if (typeof props.searchCustomers !== 'function') return []
return props.searchCustomers(keyword)
}
function update(value) {
emit('update:modelValue', value)
}
function removeAttachment(index) {
const remaining = marketAttachmentRefs(props.modelValue)
.filter((_, itemIndex) => itemIndex !== index)
const remaining = removeMarketAttachmentAt(props.modelValue, index)
if (remaining.some((item) => item?._uploading)) {
update(remaining)
return
}
update(Array.isArray(props.modelValue)
? remaining
: remaining.map((item) => item.fileUrl).join(','))
@@ -15,8 +15,10 @@
:options="fieldOptions(field, index)"
:uploading="uploading"
:disabled="disabled"
:search-customers="searchCustomers"
@update:model-value="setRepeatValue(index, field, $event)"
@upload="$emit('upload', { section, field, index, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event, index })"
/>
</article>
<van-button plain type="primary" size="small" icon="plus" :disabled="disabled" @click="addRow">添加{{ section.title }}</van-button>
@@ -30,8 +32,10 @@
:options="fieldOptions(field)"
:uploading="uploading"
:disabled="fieldDisabled(field)"
:search-customers="searchCustomers"
@update:model-value="setValue(field, $event)"
@upload="$emit('upload', { section, field, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event })"
/>
</section>
</template>
@@ -48,9 +52,10 @@ const props = defineProps({
source: { type: String, default: '' },
mode: { type: String, default: 'create' },
uploading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
disabled: { type: Boolean, default: false },
searchCustomers: { type: Function, default: null }
})
const emit = defineEmits(['update:modelValue', 'upload', 'field-change'])
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
const value = computed(() => props.modelValue || {})
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const visibleFields = computed(() => (props.section.fields || []).filter(
@@ -1,6 +1,17 @@
<template>
<article class="market-list-card">
<header class="market-list-card__head">
<button
v-if="selectable"
type="button"
class="market-list-card__selector"
:class="{ 'is-selected': selected }"
:aria-label="selected ? '取消选择' : '选择记录'"
:aria-pressed="selected"
@click="$emit('toggle-select', item)"
>
<van-icon :name="selected ? 'passed' : 'circle'" />
</button>
<span class="market-list-card__index">{{ index + 1 }}</span>
<span class="market-list-card__identity">
<strong class="market-list-card__title">{{ card.title }}</strong>
@@ -17,7 +28,7 @@
:class="{ 'is-wide': field.wide }"
>
<span>{{ field.label }}</span>
<van-tag v-if="field.tag" class="market-tag" :type="resolveStatusTone(field.value)" size="medium" :title="field.value">{{ field.value }}</van-tag>
<van-tag v-if="field.tag" class="market-tag" :type="resolveStatusTone(field.value)" size="medium" round :title="field.value">{{ field.value }}</van-tag>
<b v-else :class="{ 'is-money': field.money }">{{ field.value }}</b>
</div>
</div>
@@ -32,13 +43,13 @@
<van-icon name="shield-o" />{{ card.privacyNote }}
</p>
<footer class="market-list-card__actions">
<footer v-if="!selectionMode" class="market-list-card__actions">
<van-button
v-for="action in card.actions"
:key="action"
size="small"
:type="action === 'audit' || action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'"
:plain="action !== 'audit' && action !== 'view' && action !== 'view-payment-detail'"
:type="action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'"
:plain="action !== 'view' && action !== 'view-payment-detail'"
:icon="actionIcon(action)"
@click="$emit('action', action, item)"
>{{ actionLabel(action) }}</van-button>
@@ -55,17 +66,20 @@ const props = defineProps({
item: { type: Object, required: true },
index: { type: Number, required: true },
module: { type: String, required: true },
role: { type: String, required: true }
role: { type: String, required: true },
selectable: { type: Boolean, default: false },
selected: { type: Boolean, default: false },
selectionMode: { type: Boolean, default: false }
})
defineEmits(['action'])
defineEmits(['action', 'toggle-select'])
const card = computed(() => buildMarketCard(props.module, props.role, props.item))
const statusTone = computed(() => resolveStatusTone(card.value.status))
function actionIcon(action) {
return {
view: 'eye-o', edit: 'edit', audit: 'passed', delete: 'delete-o',
view: 'eye-o', edit: 'edit', delete: 'delete-o',
'view-payment': 'balance-o', 'view-payment-detail': 'balance-list-o', 'view-protocol': 'description',
'register-transfer': 'balance-pay', link: 'link-o', unlink: 'disconnect'
}[action] || 'records-o'
@@ -40,4 +40,3 @@ onMounted(() => {
document.body.classList.remove('van-toast--unclickable')
})
</script>
@@ -4,13 +4,17 @@
:editable="!disabled"
:uploading="uploading"
:max-size="PRESIGN_ATTACHMENT_MAX_BYTES"
:accept="PRESIGN_ATTACHMENT_ACCEPT"
@upload="$emit('upload', $event)"
@remove="$emit('remove', $event)"
/>
</template>
<script setup>
import { PRESIGN_ATTACHMENT_MAX_BYTES } from '../attachment-upload-policy.js'
import {
PRESIGN_ATTACHMENT_ACCEPT,
PRESIGN_ATTACHMENT_MAX_BYTES
} from '../attachment-upload-policy.js'
import MarketAttachmentCards from './MarketAttachmentCards.vue'
defineProps({
@@ -25,7 +25,6 @@
<footer v-if="card.actions.length" class="presign-order-card__actions">
<van-button
v-if="can('edit')"
size="small"
icon="edit"
:disabled="Boolean(pendingAction)"
:loading="pendingAction === 'edit'"
@@ -33,7 +32,6 @@
>编辑</van-button>
<van-button
v-if="can('convert')"
size="small"
type="primary"
icon="exchange"
:disabled="Boolean(pendingAction)"
@@ -42,7 +40,6 @@
>转正式</van-button>
<van-button
v-if="can('delete')"
size="small"
type="danger"
plain
icon="delete-o"
@@ -54,7 +54,12 @@
<van-loading v-if="loading" class="presign-select-field__loading" color="#0a9f6d">
检索中…
</van-loading>
<van-empty
v-if="!loading && !filteredOptions.length"
description="暂无选项"
/>
<van-picker
v-else
:title="title"
:model-value="pickerValue"
:columns="filteredOptions"
@@ -69,6 +74,7 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { filterSelectOptions } from '../presign/presign-select-filter.js'
const props = defineProps({
modelValue: { type: [String, Number, Array], default: '' },
@@ -94,13 +100,7 @@ const normalizedOptions = computed(() => props.options.map((option) => ({
value: option?.value ?? option?.id ?? ''
})))
const filteredOptions = computed(() => {
const search = keyword.value.trim().toLowerCase()
if (!search || props.remote) return normalizedOptions.value
return normalizedOptions.value.filter((option) =>
option.text.toLowerCase().includes(search)
)
})
const filteredOptions = computed(() => filterSelectOptions(normalizedOptions.value, keyword.value))
const selectedValues = computed(() => {
if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : []
@@ -4,7 +4,7 @@
<div>
<strong>意向金转账 {{ index + 1 }}</strong>
</div>
<van-tag class="market-tag" :type="statusTone" :title="statusLabel">{{ statusLabel }}</van-tag>
<van-tag class="market-tag" :type="statusTone" round :title="statusLabel">{{ statusLabel }}</van-tag>
</header>
<div class="presign-form-field">
@@ -70,34 +70,37 @@
</div>
<footer v-if="showActions" class="presign-transfer-card__actions">
<div class="presign-transfer-card__actions-start">
<van-button
v-if="can('submit')"
type="primary"
size="small"
icon="passed"
:loading="busyAction === 'submit-transfer'"
:disabled="busy"
@click="$emit('submit-transfer', modelValue)"
>提交财务确认</van-button>
<van-button
v-if="can('withdraw')"
type="warning"
plain
size="small"
icon="revoke"
:loading="busyAction === 'withdraw-transfer'"
:disabled="busy"
@click="$emit('withdraw-transfer', modelValue)"
>撤回</van-button>
</div>
<van-button
v-if="canDelete"
size="small"
class="presign-transfer-card__delete"
type="danger"
plain
size="small"
icon="delete-o"
:disabled="busy"
@click="$emit('remove', modelValue)"
>删除</van-button>
<van-button
v-if="can('submit')"
size="small"
type="primary"
icon="passed"
:loading="busyAction === 'submit-transfer'"
:disabled="busy"
@click="$emit('submit-transfer', modelValue)"
>提交财务确认</van-button>
<van-button
v-if="can('withdraw')"
size="small"
type="warning"
plain
icon="revoke"
:loading="busyAction === 'withdraw-transfer'"
:disabled="busy"
@click="$emit('withdraw-transfer', modelValue)"
>撤回</van-button>
</footer>
</article>
</template>
@@ -106,6 +109,7 @@
import { computed } from 'vue'
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
import OaMoneyField from '../../oa/components/OaMoneyField.vue'
import { removeMarketAttachmentAt } from '../attachment-upload-state.js'
import PresignAttachmentUploader from './PresignAttachmentUploader.vue'
import PresignSelectField from './PresignSelectField.vue'
@@ -167,13 +171,15 @@ function updateField(key, value) {
function removeAttachment(index) {
if (!editable.value) return
const attachments = (props.modelValue.attachments || []).filter((_, itemIndex) => itemIndex !== index)
updateField('attachments', attachments)
updateField('attachments', removeMarketAttachmentAt(props.modelValue.attachments, index))
}
</script>
<style scoped>
.presign-transfer-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 14px;
border: 1px solid #dce9e4;
border-radius: 14px;
@@ -185,7 +191,7 @@ function removeAttachment(index) {
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
margin: 0;
padding-bottom: 12px;
border-bottom: 1px solid #edf3f0;
}
@@ -199,7 +205,7 @@ function removeAttachment(index) {
.presign-transfer-card__finance {
display: grid;
gap: 4px;
margin-top: 12px;
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #f4f8f6;
@@ -212,17 +218,36 @@ function removeAttachment(index) {
.presign-transfer-card__actions {
display: flex;
min-height: 44px;
min-height: 28px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 14px;
justify-content: flex-end;
gap: 6px;
margin: 0;
padding-top: 12px;
border-top: 1px solid #edf3f0;
}
.presign-transfer-card__actions-start {
display: flex;
min-width: 0;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.presign-transfer-card__actions .van-button {
min-height: 36px;
border-radius: 9px;
min-width: 0;
min-height: 28px;
height: 28px;
padding: 0 8px;
font-size: 12px;
border-radius: 7px;
}
.presign-transfer-card__delete {
min-width: 0;
}
</style>
@@ -0,0 +1,220 @@
<template>
<div class="protocol-customer-search">
<van-field
:id="controlId"
:model-value="modelValue ?? ''"
:disabled="disabled"
placeholder="请输入或从档案选择"
@update:model-value="emit('update:modelValue', $event)"
/>
<button
type="button"
class="protocol-customer-search__btn"
:disabled="disabled"
@click="open"
>选档案</button>
<van-popup v-model:show="show" round position="bottom" teleport="body">
<div class="protocol-customer-search__sheet">
<header class="protocol-customer-search__header">
<button type="button" @click="show = false">取消</button>
<strong>{{ title }}</strong>
<span class="protocol-customer-search__spacer" />
</header>
<van-search
v-model="keyword"
shape="round"
placeholder="姓名 / 电话 / 身份证"
@update:model-value="onKeyword"
/>
<van-loading v-if="loading" class="protocol-customer-search__loading" color="#0a9f6d">
检索中…
</van-loading>
<div class="protocol-customer-search__options">
<button
v-for="item in results"
:key="item.id"
type="button"
class="protocol-customer-search__option"
@click="pick(item)"
>
<strong>{{ item.customerName || '—' }}</strong>
<span>{{ item.phoneNo || '无电话' }}</span>
</button>
<van-empty
v-if="!loading && searched && !results.length"
description="未找到客户档案"
/>
<van-empty
v-else-if="!loading && !searched"
description="输入关键字检索客户档案"
/>
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { onBeforeUnmount, ref, useId } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
title: { type: String, default: '选择客户档案' },
search: { type: Function, required: true }
})
const emit = defineEmits(['update:modelValue', 'select'])
const show = ref(false)
const keyword = ref('')
const loading = ref(false)
const searched = ref(false)
const results = ref([])
let searchTimer = null
let searchSeq = 0
const controlId = `market-customer-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
function open() {
if (props.disabled) return
keyword.value = String(props.modelValue || '').trim()
results.value = []
searched.value = false
show.value = true
if (keyword.value) runSearch(keyword.value)
}
function onKeyword(value) {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => runSearch(String(value || '').trim()), 250)
}
async function runSearch(kw) {
const seq = ++searchSeq
if (!kw) {
results.value = []
searched.value = false
loading.value = false
return
}
loading.value = true
try {
const list = await props.search(kw)
if (seq !== searchSeq) return
results.value = Array.isArray(list) ? list : []
searched.value = true
} catch {
if (seq !== searchSeq) return
results.value = []
searched.value = true
} finally {
if (seq === searchSeq) loading.value = false
}
}
function pick(item) {
emit('select', item)
show.value = false
}
onBeforeUnmount(() => {
clearTimeout(searchTimer)
searchSeq += 1
})
</script>
<style scoped>
.protocol-customer-search {
display: flex;
align-items: stretch;
gap: 8px;
}
.protocol-customer-search :deep(.van-field) {
flex: 1;
padding: 0;
background: transparent;
}
.protocol-customer-search__btn {
flex: 0 0 auto;
min-width: 64px;
min-height: 44px;
padding: 0 10px;
border: 1px solid #a7f3d0;
border-radius: 8px;
background: #e9f9f2;
color: #0a9f6d;
font-size: 13px;
font-weight: 600;
}
.protocol-customer-search__sheet {
display: flex;
height: min(70vh, 560px);
flex-direction: column;
}
.protocol-customer-search__header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #edf3f0;
}
.protocol-customer-search__header button {
min-width: 44px;
min-height: 44px;
border: 0;
background: transparent;
color: #6b7f77;
font-size: 14px;
}
.protocol-customer-search__spacer {
min-width: 44px;
}
.protocol-customer-search__options {
flex: 1;
padding: 0 12px 16px;
overflow: auto;
}
.protocol-customer-search__loading {
display: flex;
min-height: 44px;
align-items: center;
justify-content: center;
}
.protocol-customer-search__option {
display: flex;
width: 100%;
min-height: 52px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
padding: 10px 4px;
border: 0;
border-bottom: 1px solid #edf3f0;
background: #fff;
color: #18332a;
text-align: left;
}
.protocol-customer-search__option strong {
font-size: 15px;
font-weight: 600;
}
.protocol-customer-search__option span {
color: #6b7f77;
font-size: 13px;
}
</style>
@@ -109,6 +109,126 @@
:disabled="disabled || isScheduleLocked(sch)"
@click="!disabled && !isScheduleLocked(sch) && openScheduleDate(pIdx, sIdx)"
/>
<div class="protocol-sample-pay__transfer-head">
<span>
已缴 ¥{{ schedulePaid(sch).paid }} · 欠缴 ¥{{ schedulePaid(sch).arrears }}
</span>
<van-button
v-if="!disabled && canAddTransfer(sch)"
size="mini"
type="primary"
plain
@click="addTransfer(pIdx, sIdx)"
>
添加转账
</van-button>
</div>
<article
v-for="(tr, tIdx) in sch.transfers || []"
:key="tr.id || `${pIdx}-${sIdx}-${tIdx}`"
class="protocol-sample-pay__transfer"
>
<header class="protocol-sample-pay__transfer-title">
<strong>转账 {{ tIdx + 1 }}</strong>
<van-tag class="market-tag" :type="payStatusTone(tr.payStatus)" round>
{{ payStatusLabel(tr.payStatus) }}
</van-tag>
</header>
<van-field
:model-value="transferTypeLabel(tr.transferType)"
is-link
readonly
label="转账方式"
placeholder="请选择"
:disabled="disabled || !canEditTransfer(sch, tr)"
@click="!disabled && canEditTransfer(sch, tr) && openTransferTypeSheet(pIdx, sIdx, tIdx)"
/>
<van-field
:model-value="tr.transferAmount == null ? '' : String(tr.transferAmount)"
type="number"
label="转账金额"
placeholder="请输入"
:disabled="disabled || !canEditTransfer(sch, tr)"
@update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { transferAmount: parseAmount(v) })"
/>
<div class="protocol-sample-pay__datetime">
<label>转账时间</label>
<OaDatePicker
:model-value="tr.transferTime || ''"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled="disabled || !canEditTransfer(sch, tr)"
@update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { transferTime: v || '' })"
/>
</div>
<div class="protocol-sample-pay__attach">
<label>转账凭证</label>
<PresignAttachmentUploader
:model-value="ensureTransferAttachments(tr)"
:disabled="disabled || !canEditTransfer(sch, tr)"
:uploading="isTransferUploading(pIdx, sIdx, tIdx)"
@upload="(file) => uploadTransferFile(pIdx, sIdx, tIdx, file)"
@remove="(index) => removeTransferFile(pIdx, sIdx, tIdx, index)"
/>
</div>
<van-field
:model-value="tr.remark || ''"
type="textarea"
rows="2"
maxlength="512"
show-word-limit
label="备注"
placeholder="选填"
:disabled="disabled || !canEditTransfer(sch, tr)"
@update:model-value="(v) => patchTransfer(pIdx, sIdx, tIdx, { remark: v })"
/>
<van-field
v-if="tr.financeRemark"
:model-value="tr.financeRemark"
readonly
label="财务备注"
/>
<div v-if="!disabled && hasTransferAction(sch, tr)" class="protocol-sample-pay__transfer-actions">
<van-button
v-if="canEditTransfer(sch, tr)"
size="small"
type="primary"
plain
:loading="busyKey === transferBusyKey(pIdx, sIdx, tIdx, tr)"
@click="saveTransfer(pIdx, sIdx, tIdx)"
>
保存
</van-button>
<van-button
v-if="canSubmitTransfer(tr)"
size="small"
type="primary"
:loading="busyKey === `submit:${tr.id}`"
@click="submitFinance(tr, pIdx, sIdx, tIdx)"
>
提交财务确认
</van-button>
<van-button
v-if="canWithdrawTransfer(tr)"
size="small"
type="warning"
plain
:loading="busyKey === `withdraw:${tr.id}`"
@click="withdrawFinance(tr, pIdx, sIdx, tIdx)"
>
撤回
</van-button>
<van-button
v-if="canDeleteTransfer(sch, tr)"
size="small"
type="danger"
plain
@click="removeTransfer(pIdx, sIdx, tIdx)"
>
删除
</van-button>
</div>
</article>
</van-cell-group>
</div>
</div>
@@ -120,6 +240,13 @@
close-on-click-action
@select="onPayModeSelect"
/>
<van-action-sheet
v-model:show="transferTypeSheetVisible"
:actions="transferTypeSheetActions"
cancel-text="取消"
close-on-click-action
@select="onTransferTypeSelect"
/>
<van-popup v-model:show="datePickerVisible" position="bottom" round>
<van-date-picker
v-model="datePickerValue"
@@ -135,36 +262,80 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { showConfirmDialog, showToast } from 'vant'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
import { performMarketAction, uploadMarketAttachment } from '../../api/market.js'
import PresignAttachmentUploader from './PresignAttachmentUploader.vue'
import {
applyConsolidateRemainingToPayment,
applyExpandRemainingToInstallment,
applyLumpSumDeadlineToLastDueDate,
applyPreferentialPriceToPayment,
availableSamplePayModes,
calcLastPeriodDueAmount,
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
parsePreferentialPrice,
rebuildSampleSchedules,
resolvePerSamplePaymentDefaults,
samplePaymentHasConfirmedTransfer,
schedulePaidArrears,
shouldAutoFillLastPeriodDue
} from '../protocol-sample-payment.js'
import {
applySavedTransfer,
assertCanSaveTransfer,
buildTransferSavePayload,
canSubmitFinance,
canUseProtocolScheduleAction,
canUseProtocolTransferAction,
canWithdrawFinance,
emptyTransferRow,
ensureTransferAttachments,
PAY_STATUS_LABELS,
patchTransfersAt,
validateTransferRow
} from '../protocol-sample-transfer.js'
import { marketErrorMessage } from '../market-error.js'
import {
createPendingMarketAttachment,
dropPendingMarketAttachment,
removeMarketAttachmentAt,
resolvePendingMarketAttachment,
withPendingMarketAttachment
} from '../attachment-upload-state.js'
import {
applyTransferDeleteResult,
clearTransferDirty,
hasDirtyTransfers,
isTransferDirty,
markTransferDirty
} from '../protocol-transfer-dirty.js'
import { compareMoney, normalizeMoney } from '../money.js'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
samples: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false }
disabled: { type: Boolean, default: false },
protocolId: { type: String, default: '' },
marketRole: { type: String, default: 'specialist' },
transferTypes: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'transfer-dirty-change'])
const lastPeriodDueOverrideByKey = ref({})
const modeSheetVisible = ref(false)
const modeSheetPayIdx = ref(-1)
const modeSheetActions = ref([])
const transferTypeSheetVisible = ref(false)
const transferTypeSheetActions = ref([])
const transferTypeTarget = ref(null)
const datePickerVisible = ref(false)
const datePickerValue = ref(['2026', '10', '13'])
const dateTarget = ref(null)
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2040, 11, 31)
const busyKey = ref('')
const uploadKeys = ref(new Set())
const syncedPayments = computed(() => props.modelValue || [])
@@ -211,6 +382,298 @@ function isScheduleLocked(sch) {
return (sch.transfers || []).some((t) => t.payStatus === 'yjf')
}
function paymentContext() {
return { module: 'payment', marketRole: props.marketRole || 'specialist' }
}
function schedulePaid(sch) {
return schedulePaidArrears(sch?.dueAmount, sch?.transfers || [])
}
function payStatusLabel(status) {
return PAY_STATUS_LABELS[String(status || 'dfj')] || '已付款'
}
function payStatusTone(status) {
const code = String(status || 'dfj')
if (code === 'yjf') return 'success'
if (code === 'jfqrz') return 'warning'
return 'primary'
}
function transferTypeLabel(value) {
const key = String(value || '').trim()
const hit = (props.transferTypes || []).find((opt) => String(opt.value) === key)
return hit?.label || key || '请选择'
}
function parseAmount(raw) {
if (raw === '' || raw == null) return null
return String(raw)
}
function transferBusyKey(pIdx, sIdx, tIdx, tr) {
return `save:${tr?.id || `${pIdx}-${sIdx}-${tIdx}`}`
}
function isTransferUploading(pIdx, sIdx, tIdx) {
return uploadKeys.value.has(`${pIdx}-${sIdx}-${tIdx}`)
}
function getTransferList(pIdx, sIdx) {
return [...(props.modelValue?.[pIdx]?.schedules?.[sIdx]?.transfers || [])]
}
function setTransferList(pIdx, sIdx, transfers) {
const next = patchTransfersAt(props.modelValue, pIdx, sIdx, transfers)
emitList(next)
emit('transfer-dirty-change', hasDirtyTransfers(next))
}
function patchTransfer(pIdx, sIdx, tIdx, patch) {
const transfers = getTransferList(pIdx, sIdx)
transfers[tIdx] = markTransferDirty({ ...transfers[tIdx], ...patch })
setTransferList(pIdx, sIdx, transfers)
}
function addTransfer(pIdx, sIdx) {
const schedule = props.modelValue?.[pIdx]?.schedules?.[sIdx]
if (!canAddTransfer(schedule)) return
const transfers = getTransferList(pIdx, sIdx)
transfers.push(markTransferDirty(emptyTransferRow()))
setTransferList(pIdx, sIdx, transfers)
}
async function removeTransfer(pIdx, sIdx, tIdx) {
const schedule = props.modelValue?.[pIdx]?.schedules?.[sIdx]
const transfer = schedule?.transfers?.[tIdx]
if (!transfer || !canDeleteTransfer(schedule, transfer)) return
try {
await showConfirmDialog({ title: '删除转账', message: '确认删除该笔转账记录吗?' })
} catch {
return
}
if (!transfer.id) {
const transfers = getTransferList(pIdx, sIdx)
transfers.splice(tIdx, 1)
setTransferList(pIdx, sIdx, transfers)
return
}
busyKey.value = `delete:${transfer.id}`
try {
await performMarketAction(paymentContext(), 'delete-payment-transfer', {
id: transfer.id
})
const next = applyTransferDeleteResult(
{ samplePayments: props.modelValue }, pIdx, sIdx, tIdx, true
).samplePayments
emitList(next)
emit('transfer-dirty-change', hasDirtyTransfers(next))
showSuccessToast('转账记录已删除')
} catch (error) {
showToast(marketErrorMessage(error, '删除转账失败'))
} finally {
busyKey.value = ''
}
}
function canAddTransfer(schedule) {
return canUseProtocolScheduleAction(schedule, 'register-transfer')
}
function canEditTransfer(schedule, transfer) {
if (!transfer?.id) return canAddTransfer(schedule)
return canUseProtocolTransferAction(transfer, 'edit-transfer')
}
function canDeleteTransfer(schedule, transfer) {
if (!transfer?.id) return canAddTransfer(schedule)
return canUseProtocolTransferAction(transfer, 'delete-payment-transfer')
}
function canSubmitTransfer(transfer) {
return !isTransferDirty(transfer) &&
canUseProtocolTransferAction(transfer, 'submit') &&
canSubmitFinance(transfer, props.protocolId)
}
function canWithdrawTransfer(transfer) {
return canUseProtocolTransferAction(transfer, 'withdraw') &&
canWithdrawFinance(transfer, props.protocolId)
}
function hasTransferAction(schedule, transfer) {
return canEditTransfer(schedule, transfer) ||
canDeleteTransfer(schedule, transfer) ||
canSubmitTransfer(transfer) ||
canWithdrawTransfer(transfer)
}
function openTransferTypeSheet(pIdx, sIdx, tIdx) {
transferTypeTarget.value = { pIdx, sIdx, tIdx }
transferTypeSheetActions.value = (props.transferTypes || []).map((opt) => ({
name: opt.label || opt.value,
value: opt.value
}))
if (!transferTypeSheetActions.value.length) {
showToast('暂无转账方式选项')
return
}
transferTypeSheetVisible.value = true
}
function onTransferTypeSelect(action) {
const target = transferTypeTarget.value
if (!target) return
patchTransfer(target.pIdx, target.sIdx, target.tIdx, { transferType: action.value })
}
async function saveTransfer(pIdx, sIdx, tIdx) {
const sch = props.modelValue?.[pIdx]?.schedules?.[sIdx]
const tr = sch?.transfers?.[tIdx]
if (!tr || !canEditTransfer(sch, tr)) return
try {
assertCanSaveTransfer({ protocolId: props.protocolId, scheduleId: sch?.id })
} catch (error) {
showToast(error.message)
return
}
const invalid = validateTransferRow(tr)
if (invalid) {
showToast(invalid)
return
}
const key = transferBusyKey(pIdx, sIdx, tIdx, tr)
busyKey.value = key
try {
const payload = buildTransferSavePayload(tr)
let remote
if (tr.id) {
remote = await performMarketAction(paymentContext(), 'edit-transfer', {
id: tr.id,
data: payload
})
} else {
remote = await performMarketAction(paymentContext(), 'register-transfer', {
data: { scheduleId: String(sch.id), ...payload }
})
}
const data = remote?.data || remote || {}
const transfers = getTransferList(pIdx, sIdx)
transfers[tIdx] = clearTransferDirty(applySavedTransfer(transfers[tIdx], data))
setTransferList(pIdx, sIdx, transfers)
showSuccessToast(tr.id ? '转账记录已保存' : '转账记录已创建')
} catch (error) {
showToast(marketErrorMessage(error, '保存转账失败'))
} finally {
busyKey.value = ''
}
}
async function submitFinance(tr, pIdx, sIdx, tIdx) {
if (!canSubmitTransfer(tr)) {
showToast('请先保存该转账记录后再提交财务确认')
return
}
try {
await showConfirmDialog({
title: '提交财务确认',
message: '确认提交该笔转账给财务确认吗?'
})
} catch {
return
}
busyKey.value = `submit:${tr.id}`
try {
const remote = await performMarketAction(paymentContext(), 'submit', { id: tr.id })
const data = remote?.data || remote || {}
const transfers = getTransferList(pIdx, sIdx)
transfers[tIdx] = applySavedTransfer(transfers[tIdx], { ...data, payStatus: data.payStatus || 'jfqrz' })
setTransferList(pIdx, sIdx, transfers)
showSuccessToast('已提交财务确认')
} catch (error) {
showToast(marketErrorMessage(error, '提交失败'))
} finally {
busyKey.value = ''
}
}
async function withdrawFinance(tr, pIdx, sIdx, tIdx) {
if (!canWithdrawTransfer(tr)) return
try {
await showConfirmDialog({
title: '撤回',
message: '确认撤回该笔转账的财务确认申请?撤回后可修改并重新提交。'
})
} catch {
return
}
busyKey.value = `withdraw:${tr.id}`
try {
const remote = await performMarketAction(paymentContext(), 'withdraw', { id: tr.id })
const data = remote?.data || remote || {}
const transfers = getTransferList(pIdx, sIdx)
transfers[tIdx] = applySavedTransfer(transfers[tIdx], { ...data, payStatus: data.payStatus || 'dfj' })
setTransferList(pIdx, sIdx, transfers)
showSuccessToast('已撤回')
} catch (error) {
showToast(marketErrorMessage(error, '撤回失败'))
} finally {
busyKey.value = ''
}
}
async function uploadTransferFile(pIdx, sIdx, tIdx, file) {
if (!file) return
const key = `${pIdx}-${sIdx}-${tIdx}`
const pending = createPendingMarketAttachment(file)
const transfersBegin = getTransferList(pIdx, sIdx)
const begin = { ...transfersBegin[tIdx] }
ensureTransferAttachments(begin)
begin.attachments = withPendingMarketAttachment(begin.attachments, pending)
transfersBegin[tIdx] = markTransferDirty(begin)
setTransferList(pIdx, sIdx, transfersBegin)
uploadKeys.value = new Set([...uploadKeys.value, key])
try {
const uploaded = await uploadMarketAttachment(
{ module: 'payment', marketRole: props.marketRole || 'specialist' },
file
)
const transfers = getTransferList(pIdx, sIdx)
const current = { ...transfers[tIdx] }
ensureTransferAttachments(current)
current.attachments = resolvePendingMarketAttachment(
current.attachments,
pending._pendingId,
uploaded
)
transfers[tIdx] = markTransferDirty(current)
setTransferList(pIdx, sIdx, transfers)
showSuccessToast('附件上传成功')
} catch (error) {
const transfers = getTransferList(pIdx, sIdx)
const current = { ...transfers[tIdx] }
ensureTransferAttachments(current)
current.attachments = dropPendingMarketAttachment(current.attachments, pending._pendingId)
transfers[tIdx] = markTransferDirty(current)
setTransferList(pIdx, sIdx, transfers)
showToast(marketErrorMessage(error, '附件上传失败'))
} finally {
const next = new Set(uploadKeys.value)
next.delete(key)
uploadKeys.value = next
}
}
function removeTransferFile(pIdx, sIdx, tIdx, index) {
const transfers = getTransferList(pIdx, sIdx)
const current = { ...transfers[tIdx] }
ensureTransferAttachments(current)
current.attachments = removeMarketAttachmentAt(current.attachments, index)
transfers[tIdx] = markTransferDirty(current)
setTransferList(pIdx, sIdx, transfers)
}
function activeScheduleIndices(schedules) {
const idx = []
;(schedules || []).forEach((s, i) => {
@@ -237,30 +700,70 @@ function emptyDjPayment(sample) {
})
}
/** 上一轮各样本优惠价,用于判断是否强制同步合同金额 */
const lastPreferentialByCode = ref({})
watch(
() => props.samples,
(samples) => {
const list = [...(props.modelValue || [])]
const byCode = new Map(list.map((p) => [String(p.sampleCode || ''), p]))
() =>
(props.samples || [])
.filter((s) => String(s.sampleStatus || '') !== 'yzf' && String(s.sampleCode || '').trim())
.map((s) => ({
code: String(s.sampleCode),
price: parsePreferentialPrice(s),
sample: s
})),
(rows) => {
const existing = [...(props.modelValue || [])]
const byCode = new Map(existing.map((p) => [String(p.sampleCode || ''), p]))
const next = []
for (const sample of samples || []) {
const code = String(sample.sampleCode || '').trim()
if (!code) continue
if (byCode.has(code)) {
const prev = { ...byCode.get(code), protocolSampleId: sample.id || byCode.get(code).protocolSampleId }
next.push(prev)
const nextPriceMap = {}
for (const row of rows) {
const { code, price, sample } = row
nextPriceMap[code] = price
const prev = byCode.get(code)
if (prev) {
const hadPrev = Object.prototype.hasOwnProperty.call(lastPreferentialByCode.value, code)
const prevPrice = lastPreferentialByCode.value[code]
const priceChanged = hadPrev && price != null && prevPrice !== price
next.push(
applyPreferentialPriceToPayment(
{
...prev,
protocolSampleId: sample.id || prev.protocolSampleId || ''
},
sample,
priceChanged
)
)
} else {
next.push(emptyDjPayment(sample))
}
}
lastPreferentialByCode.value = nextPriceMap
const same =
next.length === list.length &&
next.every((p, i) => p.sampleCode === list[i]?.sampleCode)
next.length === existing.length &&
next.every((p, i) => {
const e = existing[i]
if (!e) return false
return (
p.sampleCode === e.sampleCode &&
p.protocolSampleId === e.protocolSampleId &&
compareMoney(p.contractAmount, e.contractAmount) === 0 &&
String(p.payMode || '') === String(e.payMode || '') &&
JSON.stringify(p.schedules || []) === JSON.stringify(e.schedules || [])
)
})
if (!same) emitList(next)
},
{ deep: true, immediate: true }
)
watch(
() => props.modelValue,
(payments) => emit('transfer-dirty-change', hasDirtyTransfers(payments)),
{ deep: true, immediate: true }
)
function patchPay(pIdx, patch, rebuild = false) {
const list = [...(props.modelValue || [])]
let next = { ...list[pIdx], ...patch }
@@ -274,8 +777,7 @@ function rebuildAt(pIdx) {
}
function onContractChange(pIdx, raw) {
const n = raw === '' || raw == null ? null : Number(raw)
patchPay(pIdx, { contractAmount: Number.isFinite(n) ? n : null }, true)
patchPay(pIdx, { contractAmount: raw === '' || raw == null ? null : String(raw) }, true)
}
function onFqField(pIdx, key, raw) {
@@ -287,10 +789,9 @@ function onDueAmountChange(pIdx, sIdx, raw) {
const list = [...(props.modelValue || [])]
const pay = { ...list[pIdx] }
const schedules = [...(pay.schedules || [])]
const n = raw === '' || raw == null ? null : Number(raw)
schedules[sIdx] = {
...schedules[sIdx],
dueAmount: Number.isFinite(n) ? n : null
dueAmount: raw === '' || raw == null ? null : String(raw)
}
const mode = String(pay.payMode || '')
const key = samplePayKey(pay, pIdx)
@@ -319,8 +820,8 @@ function onDueAmountChange(pIdx, sIdx, raw) {
pay.schedules = schedules
pay.installmentRatios = schedules.map((s) => {
const amt = Number(s.dueAmount)
return Number.isFinite(amt) && amt > 0 ? amt : 0
const amount = normalizeMoney(s.dueAmount)
return amount != null && compareMoney(amount, 0) > 0 ? amount : '0.00'
})
list[pIdx] = pay
emitList(list)
@@ -498,4 +999,47 @@ function onDateConfirm({ selectedValues }) {
color: #0a9f6d;
font-weight: 600;
}
.protocol-sample-pay__transfer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 16px 4px;
color: #64748b;
font-size: 12px;
}
.protocol-sample-pay__transfer {
margin: 8px 12px 12px;
padding: 8px 0 12px;
border-top: 1px solid #e2e8f0;
}
.protocol-sample-pay__transfer-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 4px 8px;
}
.protocol-sample-pay__attach,
.protocol-sample-pay__datetime {
padding: 8px 16px;
}
.protocol-sample-pay__attach label,
.protocol-sample-pay__datetime label {
display: block;
margin-bottom: 6px;
color: #646566;
font-size: 14px;
}
.protocol-sample-pay__transfer-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px 0;
}
.protocol-sample-pay__transfer-actions .van-button,
.protocol-sample-pay__transfer-head .van-button {
min-width: 44px;
min-height: 44px;
}
</style>
@@ -0,0 +1,831 @@
<template>
<section class="market-form-section protocol-sample-transport">
<div class="protocol-sample-transport__toolbar">
<h2>样本及运输</h2>
<div class="protocol-sample-transport__actions">
<van-button size="mini" type="primary" plain :disabled="disabled" @click="openAdd">
添加样本
</van-button>
<van-button size="mini" type="primary" plain :disabled="disabled" @click="openFreightConfig">
配置运费
</van-button>
<van-button size="mini" type="warning" plain :disabled="disabled" @click="unlinkFreight">
取消运费
</van-button>
</div>
</div>
<p class="protocol-sample-transport__hint">规则与管理端「样本及运输」一致</p>
<van-empty v-if="!samples.length" description="请添加样本" image-size="48" />
<article
v-for="(row, index) in samples"
:key="row.id || row._key || index"
class="protocol-sample-transport__card"
>
<header class="protocol-sample-transport__card-head">
<van-checkbox
:model-value="Boolean(row._checked)"
:disabled="disabled"
@update:model-value="(v) => patchRow(index, { _checked: v })"
>
{{ row.sampleCode || `样本 ${index + 1}` }}
</van-checkbox>
<van-tag class="market-tag" type="primary" round>{{ statusLabel(row.sampleStatus) }}</van-tag>
</header>
<div class="protocol-sample-transport__meta">
<span>{{ typeLabel(row.sampleTypeId) }}</span>
<span v-if="showPrice">优惠价 {{ formatMoney(row.price) }}</span>
<span v-if="freightDisplay(row).modeLabel">
{{ freightDisplay(row).modeLabel }}
· {{ formatMoney(freightDisplay(row).amount) }}{{ freightDisplay(row).shared ? '(共用)' : '' }}
</span>
<span v-else class="is-muted">未配置运费</span>
<span v-if="row.etaTime">ETA {{ row.etaTime }}</span>
</div>
<div class="protocol-sample-transport__row-actions">
<van-button size="mini" plain :disabled="disabled" @click="openEdit(index)">编辑</van-button>
<van-button size="mini" type="danger" plain :disabled="disabled" @click="removeRow(index)">
删除
</van-button>
</div>
</article>
<van-popup v-model:show="editorVisible" position="bottom" round :style="{ maxHeight: '88%' }">
<div class="protocol-sample-transport__editor">
<h3>{{ editorIndex < 0 ? '添加样本' : '编辑样本' }}</h3>
<van-field
:model-value="typeLabel(draft.sampleTypeId)"
is-link
readonly
label="样本类型"
placeholder="请选择"
required
@click="openTypeSheet"
/>
<van-field
v-if="autoGenerateCode"
:model-value="draft.sampleCode || '保存后自动生成'"
readonly
label="样本编码"
/>
<van-field
v-else
:model-value="codeLabel(draft.sampleCodeId, draft.sampleCode)"
is-link
readonly
label="样本编码"
placeholder="请选择"
required
@click="openCodeSheet"
/>
<template v-if="showPrice">
<van-field :model-value="formatMoney(draft.originalPrice)" readonly label="原价格(元)" />
<div class="protocol-sample-transport__money-field">
<label>优惠价格(元)</label>
<OaMoneyField v-model="draft.price" :show-upper="false" />
</div>
<van-field
:model-value="draft.storageYears == null ? '' : String(draft.storageYears)"
type="digit"
label="存储年限(年)"
@update:model-value="(v) => (draft.storageYears = parseInteger(v))"
/>
</template>
<van-field
:model-value="statusLabel(draft.sampleStatus)"
is-link
readonly
label="样本状态"
@click="statusSheetVisible = true"
/>
<div class="protocol-sample-transport__datetime">
<label>预计到达时间</label>
<OaDatePicker
:model-value="draft.etaTime || ''"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
@update:model-value="(v) => (draft.etaTime = v || '')"
/>
</div>
<van-field label="特殊样本">
<template #input>
<van-switch
:model-value="Number(draft.isSpecial) === 1"
size="20px"
@update:model-value="onSpecialToggle"
/>
</template>
</van-field>
<van-field
v-model="draft.specialRemark"
type="textarea"
rows="2"
maxlength="256"
show-word-limit
label="特殊说明"
:disabled="Number(draft.isSpecial) !== 1"
placeholder="如小三阳、HBV 携带等"
/>
<div class="protocol-sample-transport__editor-footer">
<van-button block plain @click="editorVisible = false">取消</van-button>
<van-button block type="primary" @click="confirmEditor">确定</van-button>
</div>
</div>
</van-popup>
<van-popup v-model:show="freightVisible" position="bottom" round :style="{ maxHeight: '80%' }">
<div class="protocol-sample-transport__editor">
<h3>配置运费</h3>
<van-tabs v-model:active="freightMode">
<van-tab title="新建批次" name="create" />
<van-tab title="关联已有" name="link" />
</van-tabs>
<template v-if="freightMode === 'create'">
<van-field
:model-value="transportModeLabel(freightForm.transportMode)"
is-link
readonly
label="运输方式"
required
@click="transportSheetVisible = true"
/>
<div class="protocol-sample-transport__money-field is-required">
<label>运费金额</label>
<OaMoneyField
v-model="freightForm.freightAmount"
:show-upper="false"
placeholder="请输入"
/>
</div>
</template>
<template v-else>
<van-radio-group v-model="selectedFreightId">
<van-cell
v-for="item in existingFreights"
:key="item.id"
clickable
:title="item.freightNo || item.id"
:label="`${transportModeLabel(item.transportMode)} · ${formatMoney(item.freightAmount)}`"
@click="selectedFreightId = item.id"
>
<template #right-icon>
<van-radio :name="item.id" />
</template>
</van-cell>
</van-radio-group>
<van-empty v-if="!existingFreights.length" description="暂无运费批次" image-size="48" />
</template>
<div class="protocol-sample-transport__editor-footer">
<van-button block plain @click="freightVisible = false">取消</van-button>
<van-button block type="primary" :loading="freightBusy" @click="confirmFreight">
确定
</van-button>
</div>
</div>
</van-popup>
<van-action-sheet
v-model:show="typeSheetVisible"
:actions="typeSheetActions"
cancel-text="取消"
close-on-click-action
@select="onTypeSelect"
/>
<van-action-sheet
v-model:show="codeSheetVisible"
:actions="codeSheetActions"
cancel-text="取消"
close-on-click-action
@select="onCodeSelect"
/>
<van-action-sheet
v-model:show="statusSheetVisible"
:actions="statusSheetActions"
cancel-text="取消"
close-on-click-action
@select="(a) => (draft.sampleStatus = a.value)"
/>
<van-action-sheet
v-model:show="transportSheetVisible"
:actions="transportSheetActions"
cancel-text="取消"
close-on-click-action
@select="(a) => (freightForm.transportMode = a.value)"
/>
<van-popup v-model:show="unlinkVisible" position="bottom" round :style="{ maxHeight: '80%' }">
<div class="protocol-sample-transport__editor">
<h3>取消运费</h3>
<p class="protocol-sample-transport__hint">勾选要取消运费的样本(同批其它协议样本也可勾选):</p>
<van-checkbox-group v-model="unlinkSelectedKeys">
<van-cell
v-for="item in unlinkItems"
:key="item.rowKey"
clickable
:title="item.sampleCode || item.sampleId"
:label="unlinkItemLabel(item)"
@click="toggleUnlinkKey(item.rowKey)"
>
<template #right-icon>
<van-checkbox :name="item.rowKey" @click.stop />
</template>
</van-cell>
</van-checkbox-group>
<van-empty v-if="!unlinkItems.length" description="无可取消样本" image-size="48" />
<div class="protocol-sample-transport__editor-footer">
<van-button block plain @click="unlinkVisible = false">取消</van-button>
<van-button block type="primary" :loading="unlinkBusy" @click="confirmUnlink">
确定
</van-button>
</div>
</div>
</van-popup>
</section>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
import OaMoneyField from '../../oa/components/OaMoneyField.vue'
import {
fetchMarketDetail,
fetchMarketList,
performMarketAction,
saveMarketRecord
} from '../../api/market.js'
import { marketErrorMessage } from '../market-error.js'
import { formatMoney } from '../money.js'
import {
SAMPLE_STATUS_OPTIONS,
applyFreightAssignments,
applySampleTypeDefaults,
buildFreightCreatePayload,
buildFreightDraft,
buildFreightUnlinkCandidates,
emptySampleRow,
freightDisplay,
partitionFreightConfigSamples,
sampleHasFreight,
validateFreightForm,
validateSampleRow
} from '../protocol-sample-transport.js'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
source: { type: String, default: 'cell_storage' },
protocolId: { type: String, default: '' },
marketRole: { type: String, default: 'specialist' },
sampleTypes: { type: Array, default: () => [] },
sampleCodes: { type: Array, default: () => [] },
transportModes: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const samples = computed(() => props.modelValue || [])
const showPrice = computed(() => props.source === 'cell_storage')
const autoGenerateCode = computed(() => props.source === 'research_donation')
const freightContext = computed(() => ({
module: 'freight',
marketRole: props.marketRole || 'specialist'
}))
const editorVisible = ref(false)
const editorIndex = ref(-1)
const draft = reactive(emptySampleRow())
const typeSheetVisible = ref(false)
const codeSheetVisible = ref(false)
const statusSheetVisible = ref(false)
const transportSheetVisible = ref(false)
const freightVisible = ref(false)
const freightMode = ref('create')
const freightBusy = ref(false)
const freightForm = reactive({ transportMode: '', freightAmount: '' })
const selectedFreightId = ref('')
const existingFreights = ref([])
const freightTargets = ref([])
const unlinkVisible = ref(false)
const unlinkBusy = ref(false)
const unlinkItems = ref([])
const unlinkSelectedKeys = ref([])
const unlinkSeedRows = ref([])
const typeSheetActions = computed(() =>
(props.sampleTypes || []).map((opt) => ({
name: opt.label || opt.typeName || opt.value,
value: opt.value || opt.id
}))
)
const statusSheetActions = SAMPLE_STATUS_OPTIONS.map((opt) => ({
name: opt.label,
value: opt.value
}))
const transportSheetActions = computed(() =>
(props.transportModes || []).map((opt) => ({
name: opt.label || opt.value,
value: opt.value
}))
)
const codeSheetActions = computed(() => {
const used = new Set(
samples.value
.map((row, idx) => (idx === editorIndex.value ? '' : String(row.sampleCodeId || '')))
.filter(Boolean)
)
return (props.sampleCodes || [])
.filter((opt) => !used.has(String(opt.value || opt.id || '')))
.map((opt) => ({
name: opt.label || opt.sampleCode || opt.value,
value: opt.value || opt.id,
sampleCode: opt.sampleCode || opt.label || ''
}))
})
watch(
() => props.source,
() => {
/* source change handled by form remount / parent reset */
}
)
function emitList(list) {
emit('update:modelValue', list)
}
function patchRow(index, patch) {
const list = samples.value.map((row, i) => (i === index ? { ...row, ...patch } : row))
emitList(list)
}
function parseInteger(raw) {
if (raw === '' || raw == null) return null
const text = String(raw).trim()
if (!/^\d+$/.test(text)) return null
const value = Number(text)
return Number.isSafeInteger(value) ? value : null
}
function statusLabel(code) {
return SAMPLE_STATUS_OPTIONS.find((opt) => opt.value === String(code || ''))?.label || '已采集'
}
function typeLabel(id) {
const hit = (props.sampleTypes || []).find((opt) => String(opt.value || opt.id) === String(id || ''))
return hit?.label || hit?.typeName || ''
}
function codeLabel(id, code) {
if (code) return code
const hit = (props.sampleCodes || []).find((opt) => String(opt.value || opt.id) === String(id || ''))
return hit?.sampleCode || hit?.label || ''
}
function transportModeLabel(value) {
const hit = (props.transportModes || []).find((opt) => String(opt.value) === String(value || ''))
return hit?.label || value || ''
}
function openAdd() {
editorIndex.value = -1
Object.assign(draft, emptySampleRow({ source: props.source }))
editorVisible.value = true
}
function openEdit(index) {
editorIndex.value = index
const row = samples.value[index] || {}
Object.assign(draft, emptySampleRow({ source: props.source }), {
...row,
isSpecial: Number(row.isSpecial) === 1 ? 1 : 0
})
editorVisible.value = true
}
function openTypeSheet() {
if (!typeSheetActions.value.length) {
showToast('暂无样本类型')
return
}
typeSheetVisible.value = true
}
function openCodeSheet() {
if (!codeSheetActions.value.length) {
showToast('暂无可选样本编码')
return
}
codeSheetVisible.value = true
}
function onTypeSelect(action) {
const type = (props.sampleTypes || []).find(
(opt) => String(opt.value || opt.id) === String(action.value)
)
Object.assign(
draft,
applySampleTypeDefaults(
{ ...draft, sampleTypeId: action.value },
{
storagePrice: type?.storagePrice ?? type?.price,
storageYears: type?.storageYears
},
{ showPrice: showPrice.value }
)
)
if (autoGenerateCode.value) draft.sampleCode = ''
}
function onCodeSelect(action) {
draft.sampleCodeId = action.value
draft.sampleCode = action.sampleCode || codeLabel(action.value, '')
}
function onSpecialToggle(checked) {
draft.isSpecial = checked ? 1 : 0
if (!checked) draft.specialRemark = ''
}
function confirmEditor() {
const error = validateSampleRow(
{ ...draft },
{ source: props.source, autoGenerateCode: autoGenerateCode.value }
)
if (error) {
showToast(error)
return
}
const row = {
...draft,
isSpecial: Number(draft.isSpecial) === 1 ? 1 : 0,
_key: draft._key || `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
}
delete row._checked
const list = [...samples.value]
if (editorIndex.value < 0) list.push(row)
else list[editorIndex.value] = { ...list[editorIndex.value], ...row }
emitList(list)
editorVisible.value = false
}
async function removeRow(index) {
try {
await showConfirmDialog({ title: '删除样本', message: '确认删除该样本吗?' })
} catch {
return
}
emitList(samples.value.filter((_, i) => i !== index))
}
function selectedRows() {
return samples.value.filter((row) => row._checked)
}
async function openFreightConfig() {
const selected = selectedRows()
if (!selected.length) {
showToast('请先勾选需要配置运费的样本')
return
}
const { configurable, skipped } = partitionFreightConfigSamples(selected)
if (!configurable.length) {
showToast('所选样本均已配置运费')
return
}
if (skipped.length) showToast(`已跳过 ${skipped.length} 个已配置样本`)
freightTargets.value = configurable
freightMode.value = 'create'
freightForm.transportMode = ''
freightForm.freightAmount = ''
selectedFreightId.value = ''
try {
const data = await fetchMarketList(freightContext.value, { page: 1, pageSize: 50 })
existingFreights.value = data?.list || data?.records || []
} catch {
existingFreights.value = []
}
freightVisible.value = true
}
function toFreightItems(rows) {
return rows
.filter((row) => row.id)
.map((row) => ({
sampleSource: props.source,
sampleId: row.id,
protocolId: props.protocolId || undefined,
sampleCode: row.sampleCode || ''
}))
}
function applyFreightLocal(targets, freightPatch) {
emitList(applyFreightAssignments(samples.value, [{ targets, patch: freightPatch }]))
}
function applyFreightLocalAssignments(assignments) {
emitList(applyFreightAssignments(samples.value, assignments))
}
async function confirmFreight() {
const targets = freightTargets.value
if (!targets.length) return
const persisted = targets.filter((row) => row.id)
const drafts = targets.filter((row) => !row.id)
freightBusy.value = true
try {
if (freightMode.value === 'create') {
const error = validateFreightForm(freightForm)
if (error) {
showToast(error)
return
}
const freightPayload = buildFreightCreatePayload({
...freightForm,
items: toFreightItems(persisted)
})
const freightDraft = buildFreightDraft(freightPayload)
let created = null
const assignments = []
if (persisted.length) {
created = await saveMarketRecord(freightContext.value, {
data: freightPayload
})
assignments.push({
targets: persisted,
patch: {
freightId: created?.id || '',
freight: created,
freightDraft: undefined
}
})
}
if (drafts.length) {
assignments.push({
targets: drafts,
patch: {
freightId: created?.id || '',
freight: created
? created
: {
transportMode: freightForm.transportMode,
transportModeLabel: transportModeLabel(freightForm.transportMode),
freightAmount: freightDraft.freightAmount
},
freightDraft: created?.id ? undefined : freightDraft
}
})
}
applyFreightLocalAssignments(assignments)
} else {
if (!selectedFreightId.value) {
showToast('请选择一笔已有运费')
return
}
const target =
existingFreights.value.find((item) => item.id === selectedFreightId.value) || {
id: selectedFreightId.value
}
const assignments = []
if (persisted.length) {
const linked = await performMarketAction(freightContext.value, 'link', {
id: selectedFreightId.value,
data: { items: toFreightItems(persisted) }
})
assignments.push({
targets: persisted,
patch: {
freightId: linked?.id || selectedFreightId.value,
freight: linked || target,
freightDraft: undefined
}
})
}
if (drafts.length) {
assignments.push({
targets: drafts,
patch: {
freightId: target.id,
freight: target,
freightDraft: undefined
}
})
}
applyFreightLocalAssignments(assignments)
}
freightVisible.value = false
showSuccessToast('已配置运费')
} catch (error) {
showToast(marketErrorMessage(error, '配置运费失败'))
} finally {
freightBusy.value = false
}
}
function unlinkItemLabel(item) {
const sourceLabel = item.sampleSource === 'research_donation' ? '科研捐赠' : '细胞存储'
return [
sourceLabel,
item.protocolNo || '—',
item.sampleTypeName || '—'
].join(' · ')
}
function toggleUnlinkKey(rowKey) {
const set = new Set(unlinkSelectedKeys.value)
if (set.has(rowKey)) set.delete(rowKey)
else set.add(rowKey)
unlinkSelectedKeys.value = [...set]
}
async function unlinkFreight() {
const selected = selectedRows().filter((row) => sampleHasFreight(row))
if (!selected.length) {
showToast('请先选择已配置运费的样本')
return
}
const draftsOnly = selected.filter((row) => !row.id && row.freightDraft)
const persisted = selected.filter((row) => row.id)
if (draftsOnly.length && !persisted.length) {
try {
await showConfirmDialog({
title: '取消运费',
message: '确定取消所选样本的运费吗?取消后可再次配置运费。'
})
} catch {
return
}
applyFreightLocal(draftsOnly, {
freightId: '',
freight: null,
freightDraft: undefined
})
showSuccessToast('已取消运费')
return
}
const freightIds = [
...new Set(
persisted
.map((row) => String(row.freightId || row.freight?.id || '').trim())
.filter(Boolean)
)
]
unlinkBusy.value = true
try {
const details = []
for (const freightId of freightIds) {
const detail = await fetchMarketDetail(freightContext.value, freightId)
details.push(detail || { id: freightId, items: [] })
}
const { items, defaultSelectedKeys } = buildFreightUnlinkCandidates(details, persisted, {
protocolId: props.protocolId,
source: props.source
})
unlinkItems.value = items
unlinkSelectedKeys.value = defaultSelectedKeys
unlinkSeedRows.value = [...persisted, ...draftsOnly]
unlinkVisible.value = true
} catch (error) {
showToast(marketErrorMessage(error, '加载运费明细失败'))
} finally {
unlinkBusy.value = false
}
}
async function confirmUnlink() {
const selectedKeys = new Set(unlinkSelectedKeys.value)
const picked = unlinkItems.value.filter((item) => selectedKeys.has(item.rowKey))
if (!picked.length) {
showToast('请勾选要取消运费的样本')
return
}
unlinkBusy.value = true
try {
const byFreight = new Map()
for (const item of picked) {
if (!byFreight.has(item.freightId)) byFreight.set(item.freightId, [])
byFreight.get(item.freightId).push(item)
}
for (const [freightId, rows] of byFreight.entries()) {
await performMarketAction(freightContext.value, 'unlink', {
id: freightId,
data: {
items: rows.map((row) => ({
sampleSource: row.sampleSource,
sampleId: row.sampleId,
protocolId: row.protocolId || props.protocolId || undefined,
sampleCode: row.sampleCode || ''
}))
}
})
}
const clearIds = new Set(picked.map((row) => row.sampleId))
const localSeeds = unlinkSeedRows.value.filter(
(row) => !row.id || clearIds.has(row.id) || row.freightDraft
)
applyFreightLocal(
[
...samples.value.filter((row) => row.id && clearIds.has(row.id)),
...localSeeds.filter((row) => !row.id)
],
{ freightId: '', freight: null, freightDraft: undefined }
)
unlinkVisible.value = false
showSuccessToast('已取消运费')
} catch (error) {
showToast(marketErrorMessage(error, '取消运费失败'))
} finally {
unlinkBusy.value = false
}
}
</script>
<style scoped>
.protocol-sample-transport__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 16px 4px;
}
.protocol-sample-transport__toolbar h2 {
margin: 0;
font-size: 16px;
}
.protocol-sample-transport__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.protocol-sample-transport__actions :deep(.van-button),
.protocol-sample-transport__row-actions :deep(.van-button) {
min-width: 44px;
min-height: 44px;
padding: 0 12px;
}
.protocol-sample-transport__hint {
margin: 0 16px 12px;
color: #64748b;
font-size: 12px;
}
.protocol-sample-transport__card {
margin: 0 12px 12px;
padding: 12px;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
}
.protocol-sample-transport__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.protocol-sample-transport__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 8px;
color: #475569;
font-size: 12px;
}
.protocol-sample-transport__meta .is-muted {
color: #94a3b8;
}
.protocol-sample-transport__row-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.protocol-sample-transport__editor {
padding: 16px 12px calc(16px + env(safe-area-inset-bottom));
}
.protocol-sample-transport__editor h3 {
margin: 0 4px 12px;
font-size: 16px;
}
.protocol-sample-transport__editor-footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.protocol-sample-transport__datetime,
.protocol-sample-transport__money-field {
padding: 8px 16px;
}
.protocol-sample-transport__datetime label,
.protocol-sample-transport__money-field label {
display: block;
margin-bottom: 6px;
color: #646566;
font-size: 14px;
}
.protocol-sample-transport__money-field.is-required label::before {
margin-right: 2px;
color: #ee0a24;
content: '*';
}
</style>
@@ -0,0 +1,583 @@
<template>
<section class="market-form-section protocol-v1-pay">
<div class="protocol-v1-pay__title">
<div>
<h2>缴费信息</h2>
<p>历史整单缴费方案</p>
</div>
<van-tag class="market-tag" type="primary" round>v1</van-tag>
</div>
<article class="protocol-v1-pay__card">
<h3>缴费主表</h3>
<van-cell-group inset>
<van-field
:model-value="payModeLabel(payment.payMode)"
label="缴费模式"
is-link
readonly
:disabled="disabled"
@click="!disabled && (modeSheetVisible = true)"
/>
<div class="protocol-v1-pay__money-field">
<label>合同金额(元)</label>
<OaMoneyField
:model-value="payment.contractAmount"
:disabled="disabled"
@update:model-value="onContractAmountChange"
/>
</div>
<template v-if="payment.payMode === 'dj'">
<div class="protocol-v1-pay__picker-field">
<label>趸交截止时间</label>
<OaDatePicker
:model-value="payment.lumpSumDeadline || ''"
type="date"
value-format="YYYYMMDD"
:disabled="disabled"
@update:model-value="onLumpSumDeadlineChange"
/>
</div>
</template>
<template v-else-if="isLegacyInstallmentMode(payment.payMode)">
<van-field
:model-value="payment.installmentCount == null ? '' : String(payment.installmentCount)"
label="分期期数"
type="digit"
inputmode="numeric"
:disabled="disabled"
@update:model-value="onInstallmentCountChange"
/>
<van-field
:model-value="payment.installmentIntervalMonths == null ? '' : String(payment.installmentIntervalMonths)"
label="每期间隔(月)"
type="text"
inputmode="decimal"
:disabled="disabled"
@update:model-value="(value) => onPaymentPlanFieldChange({ installmentIntervalMonths: value })"
/>
<div class="protocol-v1-pay__picker-field">
<label>分期开始时间</label>
<OaDatePicker
:model-value="payment.installmentStartDate || ''"
type="date"
value-format="YYYYMMDD"
:disabled="disabled"
@update:model-value="(value) => onPaymentPlanFieldChange({ installmentStartDate: value || '' })"
/>
</div>
<van-field
:model-value="payment.installmentAdvanceDays == null ? '' : String(payment.installmentAdvanceDays)"
label="提前催缴预警(天)"
type="digit"
inputmode="numeric"
:disabled="disabled"
@update:model-value="(value) => onPaymentPlanFieldChange({ installmentAdvanceDays: integerValue(value) })"
/>
</template>
</van-cell-group>
</article>
<div class="protocol-v1-pay__details-head">
<div>
<h3>缴费明细</h3>
<p>只有“已付款”草稿可修改或删除</p>
</div>
<van-button
type="primary"
plain
round
:disabled="disabled || !canAddDetail"
@click="addDetail"
>
添加明细
</van-button>
</div>
<van-empty v-if="!details.length" description="暂无缴费明细" image-size="52" />
<article
v-for="(detail, index) in details"
:key="detail.id || `new-${index}`"
class="protocol-v1-pay__card protocol-v1-pay__detail"
:class="{ 'is-locked': !isEditable(detail) }"
>
<header>
<div>
<strong>缴费明细 {{ index + 1 }}</strong>
<span v-if="detail.installmentNo">第 {{ detail.installmentNo }} 期</span>
</div>
<van-tag class="market-tag" :type="statusTone(detail.payStatus)" round>{{ statusLabel(detail.payStatus) }}</van-tag>
</header>
<p v-if="!isEditable(detail)" class="protocol-v1-pay__locked-hint">
当前状态由财务流程维护,本行仅可查看与下载附件
</p>
<van-cell-group inset>
<van-field
:model-value="detail.installmentNo == null ? '' : String(detail.installmentNo)"
label="期次"
type="digit"
inputmode="numeric"
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => patchDetail(index, { installmentNo: integerValue(value) })"
/>
<div class="protocol-v1-pay__money-field">
<label>应缴费金额(元)</label>
<OaMoneyField
:model-value="detail.dueAmount"
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => onDueAmountChange(index, value)"
/>
</div>
<van-field
:model-value="transferTypeLabel(detail.transferType)"
label="转账方式"
is-link
readonly
:disabled="disabled || !isEditable(detail)"
@click="openTransferTypeSheet(index, detail)"
/>
<div class="protocol-v1-pay__money-field">
<label>转账金额(元)</label>
<OaMoneyField
:model-value="detail.transferAmount"
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => patchDetail(index, { transferAmount: value })"
/>
</div>
<div class="protocol-v1-pay__picker-field">
<label>转账时间</label>
<OaDatePicker
:model-value="detail.transferTime || ''"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => patchDetail(index, { transferTime: value || '' })"
/>
</div>
<div class="protocol-v1-pay__picker-field">
<label>应缴费时间</label>
<OaDatePicker
:model-value="detail.installmentDueDate || ''"
type="date"
value-format="YYYYMMDD"
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => patchDetail(index, { installmentDueDate: value || '' })"
/>
</div>
<van-field
:model-value="detail.remark || ''"
label="备注"
type="textarea"
rows="2"
maxlength="512"
show-word-limit
:disabled="disabled || !isEditable(detail)"
@update:model-value="(value) => patchDetail(index, { remark: value })"
/>
</van-cell-group>
<div class="protocol-v1-pay__attachments">
<label>转账凭证</label>
<MarketAttachmentCards
:model-value="detail.transferAttachments || ''"
:editable="!disabled && isEditable(detail)"
:uploading="uploading"
:max-size="PROTOCOL_ATTACHMENT_MAX_BYTES"
:accept="PROTOCOL_ATTACHMENT_ACCEPT"
@upload="(file) => emit('upload', { index, file })"
@remove="(attachmentIndex) => removeAttachment(index, attachmentIndex)"
/>
</div>
<van-button
v-if="!disabled && isEditable(detail)"
class="protocol-v1-pay__delete"
type="danger"
plain
round
@click="removeDetail(index)"
>
删除明细
</van-button>
</article>
<van-action-sheet
v-model:show="modeSheetVisible"
:actions="modeActions"
cancel-text="取消"
close-on-click-action
@select="selectPayMode"
/>
<van-action-sheet
v-model:show="transferSheetVisible"
:actions="transferActions"
cancel-text="取消"
close-on-click-action
@select="selectTransferType"
/>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
import { showConfirmDialog, showToast } from 'vant'
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
import OaMoneyField from '../../oa/components/OaMoneyField.vue'
import { addMoney, normalizeMoney, subtractMoney } from '../money.js'
import { removeMarketAttachmentAt } from '../attachment-upload-state.js'
import {
createLegacyPaymentDetail,
evaluateLegacyPaymentPlanChange,
isLegacyInstallmentMode,
isLegacyPaymentDetailEditable,
rebuildLegacyDetailsForPayMode,
rebuildLegacyInstallmentDetails
} from '../protocol-v1-payment.js'
import {
PROTOCOL_ATTACHMENT_ACCEPT,
PROTOCOL_ATTACHMENT_MAX_BYTES
} from '../attachment-upload-policy.js'
import MarketAttachmentCards from './MarketAttachmentCards.vue'
const props = defineProps({
payment: { type: Object, default: () => ({}) },
paymentDetails: { type: Array, default: () => [] },
transferTypes: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
uploading: { type: Boolean, default: false }
})
const emit = defineEmits([
'update:payment',
'update:paymentDetails',
'upload',
'change'
])
const payment = computed(() => props.payment || {})
const details = computed(() => props.paymentDetails || [])
const modeSheetVisible = ref(false)
const transferSheetVisible = ref(false)
const transferTarget = ref(-1)
const modeActions = [
{ name: '趸交', value: 'dj' },
{ name: '分期', value: 'fq' },
{ name: '趸交+分期', value: 'djfq' },
{ name: '分改趸', value: 'fgd' }
]
const canAddDetail = computed(() => {
if (!isLegacyInstallmentMode(payment.value.payMode)) return true
const count = integerValue(payment.value.installmentCount) || 0
return details.value.length < count
})
const transferActions = computed(() => (props.transferTypes || []).map((option) => ({
name: option.label || option.value,
value: option.value
})))
function integerValue(value) {
if (value == null || String(value).trim() === '') return null
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null
}
function emitChanged(event, value) {
emit(event, value)
emit('change')
}
function emitDetails(value) {
emitChanged('update:paymentDetails', value)
}
function patchDetail(index, patch) {
const next = [...details.value]
next[index] = { ...next[index], ...patch }
emitDetails(next)
}
function isEditable(detail) {
return isLegacyPaymentDetailEditable(detail)
}
function payModeLabel(value) {
return modeActions.find((item) => item.value === String(value || ''))?.name || String(value || '请选择')
}
function statusLabel(value) {
return {
dfj: '已付款',
jfqrz: '付款确认中',
yjf: '付款完成',
tfqrz: '退费确认中',
ytf: '已退费'
}[String(value ?? '')] || '状态异常'
}
function statusTone(value) {
const status = String(value ?? '')
if (status === 'yjf') return 'success'
if (status === 'jfqrz') return 'warning'
if (status === 'dfj') return 'primary'
return 'danger'
}
function transferTypeLabel(value) {
const key = String(value || '')
return transferActions.value.find((item) => String(item.value) === key)?.name || key || '请选择'
}
async function confirmPlanChange(nextPayment) {
const evaluation = evaluateLegacyPaymentPlanChange(
payment.value,
nextPayment,
details.value
)
if (evaluation.action === 'blocked') {
showToast(evaluation.reason)
return false
}
if (evaluation.action === 'confirm') {
try {
await showConfirmDialog({ title: '修改缴费计划', message: evaluation.summary })
} catch {
return false
}
}
return true
}
async function selectPayMode(action) {
const payMode = action.value
const nextPayment = { ...payment.value, payMode }
if (!(await confirmPlanChange(nextPayment))) return
if (isLegacyInstallmentMode(payMode)) {
const count = integerValue(nextPayment.installmentCount) || details.value.length || 1
nextPayment.installmentCount = count
nextPayment.installmentRatios = Array.from(
{ length: count },
(_, index) => nextPayment.installmentRatios?.[index] ?? null
)
}
const nextDetails = rebuildLegacyDetailsForPayMode(
payment.value,
nextPayment,
details.value
)
emitChanged('update:payment', nextPayment)
emitChanged('update:paymentDetails', nextDetails)
}
async function onInstallmentCountChange(value) {
const count = integerValue(value)
const nextPayment = {
...payment.value,
installmentCount: count,
installmentRatios: count
? Array.from({ length: count }, (_, index) => payment.value.installmentRatios?.[index] ?? null)
: []
}
if (!(await confirmPlanChange(nextPayment))) return
emitChanged('update:payment', nextPayment)
if (count) emitDetails(rebuildLegacyInstallmentDetails(nextPayment, details.value))
}
async function onPaymentPlanFieldChange(patch) {
const nextPayment = { ...payment.value, ...patch }
if (!(await confirmPlanChange(nextPayment))) return false
emitChanged('update:payment', nextPayment)
if (isLegacyInstallmentMode(nextPayment.payMode)) {
emitDetails(rebuildLegacyInstallmentDetails(nextPayment, details.value))
}
return true
}
async function onContractAmountChange(value) {
if (!(await onPaymentPlanFieldChange({ contractAmount: value }))) return
if (payment.value.payMode !== 'dj') return
const paid = addMoney(details.value
.filter((detail) => String(detail.payStatus || '') === 'yjf')
.map((detail) => detail.dueAmount))
const remaining = subtractMoney(value || '0', paid)
const next = details.value.map((detail) => ({ ...detail }))
const target = next.findIndex(isEditable)
if (target >= 0) next[target].dueAmount = paid === '0.00' ? normalizeMoney(value) : remaining
emitDetails(next)
}
async function onLumpSumDeadlineChange(value) {
if (!(await onPaymentPlanFieldChange({ lumpSumDeadline: value || '' }))) return
emitDetails(details.value.map((detail) => isEditable(detail)
? { ...detail, installmentDueDate: value || '' }
: detail))
}
async function onDueAmountChange(index, value) {
const next = details.value.map((detail) => ({ ...detail }))
next[index].dueAmount = value
const patch = { contractAmount: addMoney(next.map((detail) => detail.dueAmount)) }
if (isLegacyInstallmentMode(payment.value.payMode)) {
const ratioIndex = (integerValue(next[index].installmentNo) || index + 1) - 1
const ratios = [...(payment.value.installmentRatios || [])]
while (ratios.length <= ratioIndex) ratios.push(null)
ratios[ratioIndex] = normalizeMoney(value)
patch.installmentRatios = ratios
}
const nextPayment = { ...payment.value, ...patch }
if (!(await confirmPlanChange(nextPayment))) return
emitChanged('update:payment', nextPayment)
emitDetails(next)
}
function nextInstallmentNo() {
const used = new Set(details.value.map((detail) => integerValue(detail.installmentNo)).filter(Boolean))
let value = 1
while (used.has(value)) value += 1
return value
}
function addDetail() {
if (!canAddDetail.value) return
const now = new Date()
const pad = (value) => String(value).padStart(2, '0')
const transferTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
emitDetails([
...details.value,
createLegacyPaymentDetail({
installmentNo: isLegacyInstallmentMode(payment.value.payMode) ? nextInstallmentNo() : null,
installmentDueDate: payment.value.payMode === 'dj' ? payment.value.lumpSumDeadline || '' : '',
transferTime
})
])
}
function removeDetail(index) {
if (!isEditable(details.value[index])) return
emitDetails(details.value.filter((_, rowIndex) => rowIndex !== index))
}
function openTransferTypeSheet(index, detail) {
if (props.disabled || !isEditable(detail) || !transferActions.value.length) return
transferTarget.value = index
transferSheetVisible.value = true
}
function selectTransferType(action) {
if (transferTarget.value < 0) return
patchDetail(transferTarget.value, { transferType: action.value })
}
function removeAttachment(detailIndex, attachmentIndex) {
if (!isEditable(details.value[detailIndex])) return
const files = removeMarketAttachmentAt(
details.value[detailIndex].transferAttachments,
attachmentIndex
)
patchDetail(detailIndex, { transferAttachments: files })
}
</script>
<style scoped>
.protocol-v1-pay {
--protocol-green: #0a9f6d;
}
.protocol-v1-pay__title,
.protocol-v1-pay__details-head,
.protocol-v1-pay__detail > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.protocol-v1-pay__title p,
.protocol-v1-pay__details-head p {
margin: 3px 0 0;
color: #738079;
font-size: 12px;
}
.protocol-v1-pay__card {
margin-top: 12px;
padding: 14px 12px;
border: 1px solid #dcebe4;
border-radius: 16px;
background: #fff;
box-shadow: 0 6px 18px rgb(23 74 52 / 6%);
}
.protocol-v1-pay__card h3,
.protocol-v1-pay__details-head h3 {
margin: 0;
color: #183d2e;
font-size: 15px;
}
.protocol-v1-pay__details-head {
margin-top: 18px;
}
.protocol-v1-pay__details-head .van-button,
.protocol-v1-pay__delete {
min-height: 44px;
}
.protocol-v1-pay__detail.is-locked {
border-color: #eadfc9;
background: #fffdfa;
}
.protocol-v1-pay__detail > header span {
display: block;
margin-top: 3px;
color: #738079;
font-size: 12px;
}
.protocol-v1-pay__locked-hint {
margin: 10px 0;
padding: 9px 10px;
border-radius: 10px;
background: #fff7e6;
color: #8b5e16;
font-size: 12px;
line-height: 1.5;
}
.protocol-v1-pay__money-field,
.protocol-v1-pay__picker-field,
.protocol-v1-pay__attachments {
padding: 12px 16px;
}
.protocol-v1-pay__money-field > label,
.protocol-v1-pay__picker-field > label,
.protocol-v1-pay__attachments > label {
display: block;
margin-bottom: 8px;
color: #42584e;
font-size: 13px;
}
.protocol-v1-pay__money-field :deep(.van-cell),
.protocol-v1-pay__picker-field :deep(.van-cell) {
min-height: 44px;
padding: 8px 0;
}
.protocol-v1-pay__attachments {
border-top: 1px solid #edf3f0;
}
.protocol-v1-pay__delete {
width: 100%;
margin-top: 12px;
}
.protocol-v1-pay :deep(.van-button--primary),
.protocol-v1-pay :deep(.van-tag--primary) {
border-color: var(--protocol-green);
background: var(--protocol-green);
}
</style>
+82
View File
@@ -0,0 +1,82 @@
import { resolveMarketDocumentTitleFromPath } from './routes.js'
const HISTORY_GUARD_FLAG = '__zkxwMarketTitleGuard'
const REAPPLY_DELAYS_MS = [0, 50, 200]
export function applyMarketDocumentTitle({
pathname,
search = '',
documentObj
} = {}) {
const doc =
documentObj ||
(typeof document !== 'undefined' ? document : null)
if (!doc) return ''
const title = resolveMarketDocumentTitleFromPath(
pathname ?? (typeof location !== 'undefined' ? location.pathname : ''),
search ?? (typeof location !== 'undefined' ? location.search : '')
)
if (!title) return ''
if (doc.title !== title) doc.title = title
return title
}
/**
* 微信 web-view:摘 token / Vue Router 的 history.replaceState 常把导航栏打回
* 首屏 HTML `<title>`。在 push/replace/popstate 后反复写回业务标题。
*/
export function installMarketDocumentTitleHistoryGuard({
historyObj,
locationObj,
documentObj,
windowObj,
schedule
} = {}) {
const historyRef =
historyObj || (typeof history !== 'undefined' ? history : null)
const locationRef =
locationObj || (typeof location !== 'undefined' ? location : null)
const documentRef =
documentObj || (typeof document !== 'undefined' ? document : null)
const windowRef =
windowObj || (typeof window !== 'undefined' ? window : null)
const scheduleRef =
schedule ||
((fn, ms = 0) => {
setTimeout(fn, ms)
})
if (!historyRef || historyRef[HISTORY_GUARD_FLAG]) return
const reapply = () => {
applyMarketDocumentTitle({
pathname: locationRef?.pathname,
search: locationRef?.search,
documentObj: documentRef
})
}
const wrap = (method) => {
const original = historyRef[method]
if (typeof original !== 'function') return
historyRef[method] = function marketTitleGuardedHistory(...args) {
const result = original.apply(this, args)
reapply()
for (const delay of REAPPLY_DELAYS_MS) {
scheduleRef(reapply, delay)
}
return result
}
}
wrap('pushState')
wrap('replaceState')
historyRef[HISTORY_GUARD_FLAG] = true
if (windowRef && typeof windowRef.addEventListener === 'function') {
windowRef.addEventListener('popstate', reapply)
}
reapply()
}
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyMarketDocumentTitle,
installMarketDocumentTitleHistoryGuard
} from './document-title.js'
test('applies market title onto a document object from pathname', () => {
const documentObj = { title: '中科兴皖 · OA 表单' }
assert.equal(
applyMarketDocumentTitle({
pathname: '/oa-h5/market/manager/freight',
documentObj
}),
'运费信息(经理)'
)
assert.equal(documentObj.title, '运费信息(经理)')
assert.equal(
applyMarketDocumentTitle({
pathname: '/oa-h5/oa-start',
documentObj
}),
''
)
assert.equal(documentObj.title, '运费信息(经理)')
})
test('history guard restores market title after replaceState overrides', () => {
const deferred = []
const historyObj = {
replaceState() {},
pushState() {}
}
const locationObj = {
pathname: '/oa-h5/market/specialist/presign',
search: ''
}
const documentObj = { title: '中科兴皖 · OA 表单' }
const windowObj = {
addEventListener() {}
}
installMarketDocumentTitleHistoryGuard({
historyObj,
locationObj,
documentObj,
windowObj,
schedule: (fn) => deferred.push(fn)
})
historyObj.replaceState({}, '', '/oa-h5/market/specialist/presign')
assert.equal(documentObj.title, '预签约订单(专员)')
// 模拟微信在 history API 后异步把导航栏/title 打回首屏 HTML
documentObj.title = '中科兴皖 · OA 表单'
deferred.forEach((fn) => fn())
assert.equal(documentObj.title, '预签约订单(专员)')
})
+44 -1
View File
@@ -1,4 +1,5 @@
import { serializeMarketAttachments } from './attachment-presenter.js'
import { normalizeMoney } from './money.js'
const FORBIDDEN_KEYS = new Set([
'token',
@@ -75,8 +76,50 @@ export function mergeProtocolSamplePayments(payload, samplePayments) {
delete next.samplePayments
return next
}
if (next.protocolTotalAmount != null && next.protocolTotalAmount !== '') {
next.protocolTotalAmount = normalizeMoney(next.protocolTotalAmount)
}
if (Array.isArray(next.samples)) {
next.samples = next.samples.map((sample) => ({
...sample,
price: normalizeMoney(sample.price),
originalPrice: normalizeMoney(sample.originalPrice),
...(sample.freightDraft
? {
freightDraft: {
...sample.freightDraft,
freightAmount: normalizeMoney(sample.freightDraft.freightAmount)
}
}
: {})
}))
}
if (samplePayments == null) return next
next.samplePayments = sanitizeMarketPayload(samplePayments)
next.samplePayments = sanitizeMarketPayload(samplePayments).map((payment) => ({
...payment,
contractAmount: normalizeMoney(payment.contractAmount),
installmentRatios: (payment.installmentRatios || []).map(normalizeMoney),
schedules: (payment.schedules || []).map(({ transfers: _transfers, ...schedule }) => ({
...schedule,
dueAmount: normalizeMoney(schedule.dueAmount)
}))
}))
return next
}
/** 把父母客户主档 id 合并进 customer(表单字段未声明时仍要提交) */
export function mergeProtocolCustomerLinks(payload, customerSlice) {
const next = { ...(payload || {}) }
const src = customerSlice || next.customer || {}
const customer = { ...(next.customer || {}) }
for (const key of ['customerId', 'fatherCustomerId', 'newbornCustomerId']) {
if (Object.prototype.hasOwnProperty.call(src, key)) {
customer[key] = src[key] == null ? '' : src[key]
}
}
delete customer._linkedMotherCustomerName
delete customer._linkedFatherCustomerName
next.customer = sanitizeMarketPayload(customer)
return next
}
+56 -2
View File
@@ -3,6 +3,7 @@ import test from 'node:test'
import {
buildMarketActionPayload,
mergeProtocolCustomerLinks,
mergeProtocolSamplePayments,
pickConfiguredPayload,
sanitizeMarketPayload,
@@ -10,17 +11,70 @@ import {
} from './form-payload.js'
test('mergeProtocolSamplePayments attaches only for cell_storage', () => {
const pays = [{ sampleCode: 'S1', payMode: 'dj', contractAmount: 1, schedules: [] }]
const pays = [{
sampleCode: 'S1',
payMode: 'dj',
contractAmount: 1,
schedules: [{ scheduleNo: 1, transfers: [{ id: 'transfer-1', payStatus: 'jfqrz' }] }]
}]
const merged = mergeProtocolSamplePayments(
{ source: 'cell_storage', samples: [] },
pays
)
assert.equal(
mergeProtocolSamplePayments({ source: 'cell_storage', samples: [] }, pays).samplePayments.length,
merged.samplePayments.length,
1
)
assert.equal(Object.hasOwn(merged.samplePayments[0].schedules[0], 'transfers'), false)
assert.equal(
mergeProtocolSamplePayments({ source: 'research_donation' }, pays).samplePayments,
undefined
)
})
test('protocol money payload uses fixed two-decimal strings without float coercion', () => {
const next = mergeProtocolSamplePayments(
{
source: 'cell_storage',
protocolTotalAmount: '9999999999999999.99',
samples: [{ price: '0.1', originalPrice: '0.2', freightDraft: { freightAmount: '0.3' } }]
},
[{
sampleCode: 'S1',
payMode: 'fq',
contractAmount: '0.1',
installmentRatios: ['0.2', '9999999999999999.99'],
schedules: [{ scheduleNo: 1, dueAmount: '0.1' }]
}]
)
assert.equal(next.protocolTotalAmount, '9999999999999999.99')
assert.equal(next.samples[0].price, '0.10')
assert.equal(next.samples[0].originalPrice, '0.20')
assert.equal(next.samples[0].freightDraft.freightAmount, '0.30')
assert.equal(next.samplePayments[0].contractAmount, '0.10')
assert.deepEqual(next.samplePayments[0].installmentRatios, ['0.20', '9999999999999999.99'])
assert.equal(next.samplePayments[0].schedules[0].dueAmount, '0.10')
})
test('mergeProtocolCustomerLinks keeps parent customer ids and strips link meta', () => {
const next = mergeProtocolCustomerLinks(
{ source: 'cell_storage', customer: { motherName: '张敏' } },
{
customerId: 'c-m',
fatherCustomerId: 'c-f',
motherName: '张敏',
_linkedMotherCustomerName: '张敏'
}
)
assert.equal(next.customer.customerId, 'c-m')
assert.equal(next.customer.fatherCustomerId, 'c-f')
assert.equal(next.customer.motherName, '张敏')
assert.equal(
Object.prototype.hasOwnProperty.call(next.customer, '_linkedMotherCustomerName'),
false
)
})
test('removes authorization and routing fields recursively from form payloads', () => {
assert.deepEqual(
sanitizeMarketPayload({
@@ -0,0 +1,43 @@
export function freightSampleKey(item = {}) {
return `${String(item.sampleSource || '')}:${String(item.sampleId || '')}`
}
export function mergeFreightSamplePage(currentRows, nextRows, reset = false) {
const result = reset ? [] : [...(Array.isArray(currentRows) ? currentRows : [])]
const indexes = new Map(result.map((item, index) => [freightSampleKey(item), index]))
for (const item of Array.isArray(nextRows) ? nextRows : []) {
const key = freightSampleKey(item)
if (!key || key === ':') continue
if (indexes.has(key)) {
result[indexes.get(key)] = item
} else {
indexes.set(key, result.length)
result.push(item)
}
}
return result
}
export function freightSamplePageFinished(rows, total) {
return (Array.isArray(rows) ? rows.length : 0) >= Math.max(0, Number(total) || 0)
}
export function rememberFreightSampleRows(catalog, rows) {
for (const item of Array.isArray(rows) ? rows : []) {
const key = freightSampleKey(item)
if (key && key !== ':') catalog.set(key, item)
}
return catalog
}
export function reconcileFreightSampleSelection(catalog, selectedKeys, visibleRows) {
rememberFreightSampleRows(catalog, visibleRows)
return Array.from(new Set(Array.isArray(selectedKeys) ? selectedKeys : []))
}
export function selectedFreightSamples(catalog, selectedKeys) {
return (Array.isArray(selectedKeys) ? selectedKeys : [])
.map((key) => catalog.get(key))
.filter(Boolean)
.map((item) => ({ sampleSource: item.sampleSource, sampleId: item.sampleId }))
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
let helpers = {}
try {
helpers = await import('./freight-sample-options.js')
} catch {
// RED: implementation is intentionally absent on the first run.
}
test('merges server pages without duplicates and reports completion from total', () => {
assert.equal(typeof helpers.mergeFreightSamplePage, 'function')
const first = helpers.mergeFreightSamplePage([], [{ sampleSource: 'cell_storage', sampleId: '1' }], true)
const next = helpers.mergeFreightSamplePage(first, [
{ sampleSource: 'cell_storage', sampleId: '1' },
{ sampleSource: 'research_donation', sampleId: '2' }
])
assert.deepEqual(next.map(helpers.freightSampleKey), ['cell_storage:1', 'research_donation:2'])
assert.equal(helpers.freightSamplePageFinished(next, 2), true)
})
test('selection catalog preserves checked rows across filtering and paging', () => {
assert.equal(typeof helpers.reconcileFreightSampleSelection, 'function')
const catalog = new Map()
const page1 = [
{ sampleSource: 'cell_storage', sampleId: '1', protocolNo: 'P-1' },
{ sampleSource: 'cell_storage', sampleId: '2', protocolNo: 'P-2' }
]
const page2 = [{ sampleSource: 'research_donation', sampleId: '3', protocolNo: 'JZ-3' }]
helpers.rememberFreightSampleRows(catalog, page1)
helpers.reconcileFreightSampleSelection(catalog, ['cell_storage:1'], page1)
helpers.rememberFreightSampleRows(catalog, page2)
helpers.reconcileFreightSampleSelection(
catalog,
['cell_storage:1', 'research_donation:3'],
page2
)
assert.deepEqual(
helpers.selectedFreightSamples(catalog, ['cell_storage:1', 'research_donation:3']),
[
{ sampleSource: 'cell_storage', sampleId: '1' },
{ sampleSource: 'research_donation', sampleId: '3' }
]
)
})
test('keeps every selectable row when more than one hundred records arrive in server pages', () => {
let rows = []
for (let page = 0; page < 6; page += 1) {
const next = Array.from({ length: page === 5 ? 5 : 20 }, (_, index) => ({
sampleSource: index % 2 ? 'research_donation' : 'cell_storage',
sampleId: String(page * 20 + index + 1)
}))
rows = helpers.mergeFreightSamplePage(rows, next)
}
assert.equal(rows.length, 105)
assert.equal(helpers.freightSamplePageFinished(rows, 105), true)
})
+107
View File
@@ -0,0 +1,107 @@
const SOURCES = new Set(['cell_storage', 'research_donation'])
const DEFAULTS = Object.freeze({
presign: Object.freeze({ orderStatus: '' }),
protocol: Object.freeze({
protocolStatus: '',
createTimeRange: Object.freeze([]),
sampleCollectTimeRange: Object.freeze([])
}),
'payment-chase': Object.freeze({ chaseType: '', payMode: '' }),
payment: Object.freeze({
payMode: '',
progressStatus: '',
dueDateRange: Object.freeze([])
}),
freight: Object.freeze({
transportMode: '',
sampleSource: '',
createTimeRange: Object.freeze([])
})
})
function text(value) {
return String(value ?? '').trim()
}
function cloneDefaults(module) {
return Object.fromEntries(
Object.entries(DEFAULTS[module] || {}).map(([key, value]) => [
key,
Array.isArray(value) ? [...value] : value
])
)
}
function addText(result, key, value) {
const normalized = text(value)
if (normalized) result[key] = normalized
}
function addRange(result, range, startKey, endKey) {
if (!Array.isArray(range) || range.length !== 2) return
const start = text(range[0])
const end = text(range[1])
if (!start || !end) return
result[startKey] = start
result[endKey] = end
}
export function createListFilters(module, source = '') {
const result = cloneDefaults(module)
if (module === 'protocol') {
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
return {
source: result.source,
protocolStatus: result.protocolStatus,
createTimeRange: result.createTimeRange,
sampleCollectTimeRange: result.sampleCollectTimeRange
}
}
return result
}
export function buildListParams(module, filters = {}) {
const result = {}
if (module === 'presign') {
addText(result, 'orderStatus', filters.orderStatus)
} else if (module === 'protocol') {
const source = text(filters.source)
if (SOURCES.has(source)) result.source = source
addText(result, 'protocolStatus', filters.protocolStatus)
addRange(result, filters.createTimeRange, 'createTimeStart', 'createTimeEnd')
addRange(
result,
filters.sampleCollectTimeRange,
'sampleCollectTimeStart',
'sampleCollectTimeEnd'
)
} else if (module === 'payment-chase') {
addText(result, 'chaseType', filters.chaseType)
addText(result, 'payMode', filters.payMode)
} else if (module === 'payment') {
addText(result, 'payMode', filters.payMode)
addText(result, 'progressStatus', filters.progressStatus)
addRange(
result,
filters.dueDateRange,
'installmentDueDateStart',
'installmentDueDateEnd'
)
} else if (module === 'freight') {
addText(result, 'transportMode', filters.transportMode)
addText(result, 'sampleSource', filters.sampleSource)
addRange(result, filters.createTimeRange, 'createTimeStart', 'createTimeEnd')
result.includeItems = true
}
return result
}
export function activeFilterCount(module, filters = {}) {
const ignored = new Set(module === 'protocol' ? ['source'] : [])
return Object.entries(filters).filter(([key, value]) => {
if (ignored.has(key)) return false
if (Array.isArray(value)) return value.length === 2 && value.every((item) => text(item))
return Boolean(text(value))
}).length
}
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
activeFilterCount,
buildListParams,
createListFilters
} from './list-filters.js'
test('creates isolated defaults for every market module', () => {
const first = createListFilters('protocol', 'cell_storage')
const second = createListFilters('protocol', 'research_donation')
first.createTimeRange.push('2026-10-01')
assert.deepEqual(first, {
source: 'cell_storage',
protocolStatus: '',
createTimeRange: ['2026-10-01'],
sampleCollectTimeRange: []
})
assert.deepEqual(second, {
source: 'research_donation',
protocolStatus: '',
createTimeRange: [],
sampleCollectTimeRange: []
})
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
assert.deepEqual(createListFilters('payment'), { payMode: '', progressStatus: '', dueDateRange: [] })
assert.deepEqual(createListFilters('freight'), { transportMode: '', sampleSource: '', createTimeRange: [] })
})
test('maps every web-equivalent filter to backend query names', () => {
assert.deepEqual(buildListParams('presign', { orderStatus: 'active' }), {
orderStatus: 'active'
})
assert.deepEqual(buildListParams('protocol', {
source: 'cell_storage',
protocolStatus: 'sxz',
createTimeRange: ['2026-10-01', '2026-10-13'],
sampleCollectTimeRange: ['2026-09-01', '2026-09-30']
}), {
source: 'cell_storage',
protocolStatus: 'sxz',
createTimeStart: '2026-10-01',
createTimeEnd: '2026-10-13',
sampleCollectTimeStart: '2026-09-01',
sampleCollectTimeEnd: '2026-09-30'
})
assert.deepEqual(buildListParams('payment', {
payMode: 'fq',
progressStatus: 'bfyj',
dueDateRange: ['2026-10-01', '2026-10-31']
}), {
payMode: 'fq',
progressStatus: 'bfyj',
installmentDueDateStart: '2026-10-01',
installmentDueDateEnd: '2026-10-31'
})
assert.deepEqual(buildListParams('payment-chase', {
chaseType: 'overdue', payMode: 'dj'
}), {
chaseType: 'overdue', payMode: 'dj'
})
assert.deepEqual(buildListParams('freight', {
transportMode: 'cold_chain',
sampleSource: 'cell_storage',
createTimeRange: ['2026-10-01', '2026-10-13']
}), {
transportMode: 'cold_chain',
sampleSource: 'cell_storage',
createTimeStart: '2026-10-01',
createTimeEnd: '2026-10-13',
includeItems: true
})
})
test('drops incomplete ranges and counts only user-selectable filters', () => {
assert.deepEqual(buildListParams('payment', {
payMode: '', progressStatus: 'yjq', dueDateRange: ['2026-10-01']
}), { progressStatus: 'yjq' })
assert.equal(activeFilterCount('protocol', {
source: 'cell_storage',
protocolStatus: 'sxz',
createTimeRange: ['2026-10-01', '2026-10-13'],
sampleCollectTimeRange: []
}), 2)
assert.equal(activeFilterCount('freight', {
transportMode: '', sampleSource: 'research_donation', createTimeRange: []
}), 1)
})
+46 -19
View File
@@ -1,19 +1,20 @@
import { formatMoney } from './money.js'
const SEARCH_PLACEHOLDERS = Object.freeze({
protocol: '搜索母亲姓名 / 样本编码',
protocol: '搜索姓名 / 身份证号 / 电话',
'payment-chase': '搜索协议号 / 客户 / 样本编码',
payment: '搜索协议号 / 客户 / 样本编码',
freight: '搜索批次号 / 母亲姓名 / 样本编码'
})
const PROTOCOL_SEARCH_PLACEHOLDERS = Object.freeze({
cell_storage: '搜索母亲姓名 / 样本编码',
research_donation: '搜索母亲姓名 / 捐赠编号 / 样本编码'
cell_storage: '搜索姓名 / 身份证号 / 电话',
research_donation: '搜索姓名 / 身份证号 / 电话'
})
const ACTION_LABELS = Object.freeze({
view: '查看',
edit: '编辑',
audit: '审核',
delete: '删除',
'view-payment': '查看缴费',
'view-payment-detail': '缴费明细',
@@ -41,6 +42,13 @@ const label = (map, value) => {
if (!key) return '—'
return map[key] || (/[㐀-鿿]/u.test(key) ? key : '未知')
}
const protocolStatusText = (item) => {
const code = String(item.protocolStatus || item.donationStatus || item.status || '').trim()
const base = label(PROTOCOL_STATUS_LABELS, code)
if (code !== 'yzf') return base
const voidTypeLabel = String(item.voidTypeLabel || item.voidType || '').trim()
return voidTypeLabel ? `${base}(${voidTypeLabel})` : base
}
const referenceName = (name, id) => {
const displayName = String(name ?? '').trim()
const referenceId = String(id ?? '').trim()
@@ -57,9 +65,7 @@ const personName = (name, id) => {
}
const field = (labelText, value, options = {}) => ({ label: labelText, value: text(value), ...options })
export function money(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) return '—'
return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return formatMoney(value)
}
export function marketSearchPlaceholder(module, source) {
@@ -85,7 +91,7 @@ function attachmentPresence(urls) {
function protocolCard(item) {
const actions = allowed(item, ['edit', 'delete'])
const isDonation = item.source === 'research_donation'
const status = label(PROTOCOL_STATUS_LABELS, item.protocolStatus || item.donationStatus || item.status)
const status = protocolStatusText(item)
if (isDonation) {
const collectDate = String(item.sampleCollectTime || '').slice(0, 10)
return {
@@ -120,12 +126,18 @@ function protocolCard(item) {
actions: ['view', ...actions]
}
}
const subtitleParts = [
label(SOURCE_LABELS, item.source),
text(item.protocolNo, ''),
text(item.sampleCode, '')
].filter(Boolean)
return {
title: text(item.motherName || item.customerName),
subtitle: `${label(SOURCE_LABELS, item.source)} · ${text(item.sampleCode)}`,
subtitle: subtitleParts.join(' · '),
status,
fields: [
field('状态', status, { tag: true }),
field('协议编号', item.protocolNo, { wide: true }),
field('登记日期', item.registerDate || item.donationDate),
field('母亲姓名', item.motherName || item.customerName),
field('母亲电话', item.motherPhone),
@@ -139,6 +151,7 @@ function protocolCard(item) {
field('采集专员', personName(item.collectorUserName, item.collectorUserId)),
field('市场专员', personName(item.salesUserName, item.salesUserId)),
field('样本采集时间', item.sampleCollectTime, { wide: true }),
field('样本编码', item.sampleCode, { wide: true }),
field('创建时间', item.createTime, { wide: true })
],
actions: ['view', ...actions]
@@ -160,14 +173,15 @@ function chaseCard(item) {
field('期次', `第 ${text(item.scheduleNo, '1')} 期`), field('应缴日', item.dueDate),
field('应缴金额', money(item.dueAmount), { money: true }), field('已缴金额', money(item.paidAmount), { money: true }),
field('欠缴金额', money(item.arrearsAmount), { money: true }),
field('催缴状态', timing, { tag: true }),
field('协议状态', label(PROTOCOL_STATUS_LABELS, item.protocolStatus), { tag: true })
],
actions: ['view-protocol']
actions: ['view-payment', 'view-protocol']
}
}
function paymentCard(item) {
const progress = label({}, item.paymentProgress || item.progress || (Number(item.remainAmount || 0) <= 0 ? '已缴清' : Number(item.paidAmount || 0) > 0 ? '部分缴费' : '待缴费'))
const progress = label({}, item.paymentProgress || item.progress || (Number(item.paidAmount || 0) > 0 ? '部分已缴' : '待缴清'))
const periods = Array.from({ length: 6 }, (_, index) => item.periods?.[index] || {})
const periodFields = periods.flatMap((period, index) => {
const prefix = `第${index + 1}期·`
@@ -182,6 +196,7 @@ function paymentCard(item) {
return {
title: text(item.motherName), subtitle: text(item.sampleCode), status: progress,
fields: [
field('协议号', item.protocolNo, { wide: true }),
field('姓名', item.motherName), field('手机号', item.motherPhone),
field('样本编码', item.sampleCode, { wide: true }), field('签署日期', item.signDate),
field('存储年限', item.storageYears), field('存储项目', item.sampleTypeName, { wide: true }),
@@ -199,9 +214,21 @@ function paymentCard(item) {
}
function freightCard(role, item) {
const first = item.items?.[0] || {}
const rows = Array.isArray(item.items) ? item.items : []
const first = rows[0] || {}
const transportMode = label({}, item.transportModeLabel || item.transportMode)
const source = label(SOURCE_LABELS, first.sampleSource || item.sourceLabel)
const itemFields = rows.flatMap((row, index) => {
const prefix = index === 0 ? '' : `样本${index + 1}·`
return [
field(`${prefix}来源`, label(SOURCE_LABELS, row.sampleSource), { tag: true }),
field(`${prefix}协议号`, row.protocolNo, { wide: true }),
field(`${prefix}母亲姓名`, row.motherName),
field(`${prefix}子女姓名`, row.newbornName),
field(`${prefix}样本编码`, row.sampleCode, { wide: true }),
field(`${prefix}样本类型`, row.sampleTypeName, { wide: true })
]
})
return {
title: text(item.freightNo),
subtitle: `${transportMode} · ${money(item.freightAmount)}`,
@@ -209,14 +236,14 @@ function freightCard(role, item) {
fields: [
field('批次号', item.freightNo, { wide: true }), field('运输方式', transportMode, { tag: true }),
field('运费金额', money(item.freightAmount), { money: true }),
field('关联数', role === 'manager' ? String(item.visibleItemCount || item.items?.length || 0) : '仅本人相关'),
field('来源', source, { tag: true }),
field('母亲姓名', first.motherName), field('子女姓名', first.newbornName),
field('样本编码', first.sampleCode, { wide: true }), field('样本类型', first.sampleTypeName, { wide: true }),
field('创建人', personName(item.creatorName || item.createByName, item.createBy)), field('创建时间', item.createTime)
field('关联数', String(item.visibleItemCount ?? item.items?.length ?? 0)),
...itemFields,
field('创建人', personName(item.creatorName || item.createByName, item.createBy)),
field('创建时间', item.createTime),
field('更新时间', item.updateTime),
field('备注', item.remark, { wide: true })
],
detailTags: role === 'manager' ? (item.items || []).map((row) => `${text(row.motherName)} · ${text(row.sampleCode)}`) : [],
privacyNote: role === 'specialist' ? '同批次其他专员明细已脱敏' : '',
privacyNote: role === 'specialist' ? '仅展示当前权限范围内的关联样本' : '',
actions: allowed(item, ['edit', 'link', 'unlink'])
}
}
+56 -14
View File
@@ -4,18 +4,26 @@ import assert from 'node:assert/strict'
import {
actionLabel,
buildMarketCard,
marketSearchPlaceholder
marketSearchPlaceholder,
money
} from './list-presenter.js'
test('money formats DECIMAL values without losing integer or cent precision', () => {
assert.equal(money('9999999999999999.99'), '¥9,999,999,999,999,999.99')
assert.equal(money('-1000000000000000.5'), '¥-1,000,000,000,000,000.50')
assert.equal(money('0.009'), '¥0.01')
assert.equal(money(''), '—')
})
test('remaining modules use the approved search placeholders', () => {
assert.equal(marketSearchPlaceholder('protocol'), '搜索母亲姓名 / 样本编码')
assert.equal(marketSearchPlaceholder('protocol'), '搜索姓名 / 身份证号 / 电话')
assert.equal(
marketSearchPlaceholder('protocol', 'cell_storage'),
'搜索母亲姓名 / 样本编码'
'搜索姓名 / 身份证号 / 电话'
)
assert.equal(
marketSearchPlaceholder('protocol', 'research_donation'),
'搜索母亲姓名 / 捐赠编号 / 样本编码'
'搜索姓名 / 身份证号 / 电话'
)
assert.equal(marketSearchPlaceholder('payment-chase'), '搜索协议号 / 客户 / 样本编码')
assert.equal(marketSearchPlaceholder('payment'), '搜索协议号 / 客户 / 样本编码')
@@ -32,7 +40,8 @@ test('protocol card exposes web parity fields without truncating to three rows',
allowedActions: ['audit', 'edit', 'delete']
})
assert.equal(card.title, '李安然')
assert.equal(card.subtitle, '细胞存储 · S-001')
assert.equal(card.subtitle, '细胞存储 · XBC-001 · S-001')
assert.ok(card.fields.some((item) => item.label === '协议编号' && item.value === 'XBC-001'))
assert.ok(card.fields.length >= 12)
assert.deepEqual(card.actions, ['view', 'edit', 'delete'])
assert.equal(buildMarketCard('protocol', 'specialist', {
@@ -47,6 +56,25 @@ test('protocol card exposes web parity fields without truncating to three rows',
}).status, '转细胞存储')
})
test('voided protocol cards append the backend void type label like Web', () => {
for (const source of ['cell_storage', 'research_donation']) {
const card = buildMarketCard('protocol', 'specialist', {
id: `void-${source}`,
source,
protocolStatus: 'yzf',
donationStatus: 'yzf',
voidType: 'tenant-custom',
voidTypeLabel: '租户自定义作废',
allowedActions: []
})
assert.equal(card.status, '已作废(租户自定义作废)')
assert.ok(card.fields.some(
(field) => ['状态', '协议状态'].includes(field.label) && field.value === card.status
))
}
})
test('research donation protocol card matches excel-parity field set', () => {
const card = buildMarketCard('protocol', 'specialist', {
source: 'research_donation',
@@ -100,12 +128,13 @@ test('payment card formats summary totals and progress', () => {
signDate: '2026-10-01', storageYears: 20, sampleTypeName: '脐带血',
discountAmount: '1000.00', receivableAmount: '9000.00', remark: '首期已缴',
periods: [{ scheduleNo: 1, dueAmount: 3000, dueDate: '2026-10-10', displayPaidAmount: 3000, payDate: '2026-10-11', arrearsAmount: 0 }],
clearedScheduleCount: 1, activeScheduleCount: 4, paymentProgress: '部分缴费',
clearedScheduleCount: 1, activeScheduleCount: 4, paymentProgress: '部分已缴',
allowedActions: ['register-transfer']
})
assert.equal(card.status, '部分缴费')
assert.equal(card.status, '部分已缴')
assert.equal(card.subtitle, 'S-1')
assert.ok(card.fields.some((field) => field.label === '样本编码' && field.value === 'S-1'))
assert.ok(card.fields.some((field) => field.label === '协议号' && field.value === 'P-1'))
assert.ok(card.fields.some((field) => field.label === '签署日期' && field.value === '2026-10-01'))
assert.ok(card.fields.some((field) => field.label === '存储项目' && field.value === '脐带血'))
assert.ok(card.fields.some((field) => field.label === '第1期·应缴费金额(元)' && field.value === '¥3,000.00'))
@@ -119,27 +148,40 @@ test('payment chase keeps the Web action column while paid totals remain card da
sampleCode: 'S-1', paidAmount: '100.00', dueAmount: '200.00', arrearsAmount: '100.00',
salesUserId: 'sales-raw'
})
assert.deepEqual(card.actions, ['view-protocol'])
assert.deepEqual(card.actions, ['view-payment', 'view-protocol'])
assert.equal(card.fields.find((field) => field.label === '市场专员').value, '未知人员')
assert.ok(card.fields.some((field) => field.label === '催缴状态'))
})
test('specialist freight card masks batch-wide counts while manager sees visible details', () => {
test('freight card shows the scope-safe visible count and protocol number for both roles', () => {
const item = {
freightNo: 'YF-1', transportModeLabel: '冷链配送', freightAmount: '120.00',
visibleItemCount: 2, items: [{ motherName: '李安然', sampleCode: 'S-1', sampleTypeName: '脐带血' }],
visibleItemCount: 2,
createTime: '2026-10-13 09:00:00', updateTime: '2026-10-13 10:00:00', remark: '同批送达',
items: [
{ sampleSource: 'cell_storage', protocolNo: 'P-1', motherName: '李安然', newbornName: '宝宝甲', sampleCode: 'S-1', sampleTypeName: '脐带血' },
{ sampleSource: 'research_donation', protocolNo: 'P-2', motherName: '周雨桐', newbornName: '宝宝乙', sampleCode: 'S-2', sampleTypeName: '脐带' }
],
allowedActions: ['edit', 'link', 'unlink']
}
const specialist = buildMarketCard('freight', 'specialist', item)
const manager = buildMarketCard('freight', 'manager', item)
assert.equal(specialist.privacyNote, '同批次其他专员明细已脱敏')
assert.ok(specialist.fields.some((field) => field.label === '关联数' && field.value === '仅本人相关'))
assert.equal(specialist.privacyNote, '仅展示当前权限范围内的关联样本')
assert.ok(specialist.fields.some((field) => field.label === '关联数' && field.value === '2'))
assert.ok(specialist.fields.some((field) => field.label === '协议号' && field.value === 'P-1'))
assert.equal(manager.privacyNote, '')
assert.ok(manager.fields.some((field) => field.label === '关联数' && field.value === '2'))
assert.deepEqual(manager.detailTags, ['李安然 · S-1'])
assert.ok(manager.fields.some((field) => field.label === '样本2·来源' && field.value === '科研捐赠'))
assert.ok(manager.fields.some((field) => field.label === '样本2·协议号' && field.value === 'P-2'))
assert.ok(manager.fields.some((field) => field.label === '样本2·母亲姓名' && field.value === '周雨桐'))
assert.ok(manager.fields.some((field) => field.label === '样本2·子女姓名' && field.value === '宝宝乙'))
assert.ok(manager.fields.some((field) => field.label === '样本2·样本编码' && field.value === 'S-2'))
assert.ok(manager.fields.some((field) => field.label === '样本2·样本类型' && field.value === '脐带'))
assert.ok(manager.fields.some((field) => field.label === '更新时间' && field.value === '2026-10-13 10:00:00'))
assert.ok(manager.fields.some((field) => field.label === '备注' && field.value === '同批送达'))
})
test('action labels cover module-specific operations', () => {
assert.equal(actionLabel('audit'), '审核')
assert.equal(actionLabel('link'), '关联样本')
assert.equal(actionLabel('unlink'), '取消运费')
assert.equal(actionLabel('internal-action-code'), '操作')
@@ -59,16 +59,29 @@ test('market list visual contract keeps search white, tags filled and actions ri
assert.match(styles, /\.market-list-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
assert.match(styles, /\.presign-order-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?align-self:\s*flex-start;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*112px;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*128px;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?padding:\s*5px 12px;/)
assert.match(styles, /\.market-tag\s*\{[\s\S]*?border-radius:\s*999px;/)
assert.match(
styles,
/\.market-page \.van-tag,\s*\n\.presign-order-page \.van-tag\s*\{[\s\S]*?padding:\s*5px 12px;[\s\S]*?border-radius:\s*999px;/
)
assert.match(card, /class="market-tag"/)
assert.doesNotMatch(card, /<van-tag[^>]*\bplain\b/)
for (const component of [
'./components/MarketDetailSection.vue',
'./components/MarketDetailNestedCard.vue',
'./components/MarketListCard.vue',
'./components/PresignOrderCard.vue',
'./components/PresignTransferCard.vue',
'./components/ProtocolSamplePaymentPanel.vue',
'./components/ProtocolSampleTransportPanel.vue',
'./components/ProtocolV1PaymentPanel.vue',
'../views/market/MarketModuleDetail.vue'
]) {
const content = source(component)
assert.doesNotMatch(content, /<van-tag(?![^>]*class="market-tag")/)
assert.match(content, /<van-tag[\s\S]*?\bround\b/)
}
})
@@ -201,14 +214,15 @@ test('market retry and presign action targets are explicitly at least 44 by 44 p
assert.match(styles, /\.presign-order-card__actions \.van-button\s*\{[^}]*min-width:\s*(?:[4-9]\d|\d{3,})px;[^}]*min-height:\s*(?:[4-9]\d|\d{3,})px;/)
assert.match(styles, /\.presign-order-list__error \.van-button\s*\{[^}]*min-width:\s*(?:[4-9]\d|\d{3,})px;[^}]*min-height:\s*(?:[4-9]\d|\d{3,})px;/)
assert.match(styles, /\.presign-form-section__heading \.van-button\s*\{[^}]*min-width:\s*(?:[4-9]\d|\d{3,})px;[^}]*min-height:\s*(?:[4-9]\d|\d{3,})px;/)
assert.match(styles, /\.presign-add-transfer-btn\s*\{[^}]*min-height:\s*32px;/)
assert.match(styles, /\.market-state \.van-button\s*\{[^}]*min-width:\s*(?:[4-9]\d|\d{3,})px;[^}]*min-height:\s*(?:[4-9]\d|\d{3,})px;/)
assert.match(transferCard, /\.presign-transfer-card__actions \.van-button\s*\{[^}]*min-width:\s*(?:[4-9]\d|\d{3,})px;[^}]*min-height:\s*(?:[4-9]\d|\d{3,})px;/)
assert.match(transferCard, /\.presign-transfer-card__actions \.van-button\s*\{[^}]*min-height:\s*28px;[^}]*height:\s*28px;/)
assert.doesNotMatch(orderCard, /<van-button[\s\S]*?size="small"/)
assert.doesNotMatch(transferCard, /<van-button[\s\S]*?size="small"/)
assert.match(transferCard, /size="small"[\s\S]*?提交财务确认/)
assert.match(transferCard, /presign-transfer-card__delete[\s\S]*?size="small"/)
assert.doesNotMatch(presignList, /presign-order-list__error[\s\S]*?<van-button[^>]*size="small"/)
assert.doesNotMatch(presignForm, /presign-form-section__heading[\s\S]*?<van-button[^>]*size="small"/)
assert.match(presignForm, /presign-add-transfer-btn[\s\S]*?size="small"/)
assert.match(genericList, /class="market-state"[\s\S]*?<van-button[^>]*>重新加载<\/van-button>/)
assert.doesNotMatch(genericList, /class="market-state"[\s\S]*?<van-button[^>]*size="small"/)
assert.match(genericForm, /v-else-if="errorMessage" class="market-state"/)
@@ -254,13 +268,27 @@ test('market forms share attachment cards and preserve their existing value shap
assert.match(content, /import MarketAttachmentCards/)
assert.match(content, /<MarketAttachmentCards/)
}
assert.match(genericField, /marketAttachmentRefs/)
assert.match(genericField, /marketAttachmentRefs[\s\S]*?\.map\(\(item\) => item\.fileUrl\)[\s\S]*?\.join\(','\)/)
assert.match(genericField, /removeMarketAttachmentAt/)
assert.match(genericField, /remaining\.map\(\(item\) => item\.fileUrl\)[\s\S]*?\.join\(','\)/)
assert.match(presignUploader, /@upload="\$emit\('upload', \$event\)"/)
assert.match(presignUploader, /@remove="\$emit\('remove', \$event\)"/)
assert.match(presignUploader, /PRESIGN_ATTACHMENT_MAX_BYTES/)
assert.match(genericForm, /appendMarketAttachment/)
assert.match(genericForm, /createPendingMarketAttachment/)
assert.match(genericForm, /withPendingMarketAttachment/)
assert.match(genericForm, /resolvePendingMarketAttachment/)
assert.match(genericForm, /fileName:\s*uploaded\?\.fileName\s*\|\|\s*file\.name/)
assert.match(genericForm, /fieldsLocked/)
const protocolPay = source('./components/ProtocolSamplePaymentPanel.vue')
const protocolV1Pay = source('./components/ProtocolV1PaymentPanel.vue')
const presignForm = source('../views/market/presign/PresignOrderForm.vue')
for (const content of [protocolPay, protocolV1Pay, presignForm, genericForm]) {
assert.match(content, /attachment-upload-state/)
}
assert.match(protocolPay, /createPendingMarketAttachment/)
assert.match(protocolPay, /withPendingMarketAttachment/)
assert.match(protocolV1Pay, /removeMarketAttachmentAt/)
assert.match(presignForm, /createPendingMarketAttachment/)
})
test('payment detail presents every web field and nested transfer field with Chinese labels', () => {
+16 -3
View File
@@ -13,11 +13,22 @@ test('each module exposes only its static market action whitelist', () => {
'view',
'register-transfer',
'edit-transfer',
'delete-payment-transfer',
'submit',
'withdraw'
])
assert.equal(moduleActions('payment').includes('finance-confirm'), false)
assert.deepEqual(moduleActions('protocol'), ['view', 'create', 'audit', 'edit', 'delete'])
assert.deepEqual(moduleActions('protocol'), ['view', 'create', 'edit', 'delete'])
for (const denied of [
'audit',
'status-change',
'refund',
'invoice',
'finance-confirm',
'finance-reject'
]) {
assert.equal(moduleActions('protocol').includes(denied), false)
}
assert.equal(moduleActions('freight', 'specialist').includes('edit'), true)
assert.equal(moduleActions('freight', 'manager').includes('edit'), true)
assert.deepEqual(moduleActions('presign'), [
@@ -67,11 +78,12 @@ test('collects payment transfer actions with their schedule and transfer targets
schedules: [{
id: 'schedule-1',
allowedActions: ['register-transfer'],
transfers: [{ id: 'transfer-1', allowedActions: ['submit-finance'] }]
transfers: [{ id: 'transfer-1', allowedActions: ['delete-payment-transfer', 'submit-finance'] }]
}]
}).map(({ code, scheduleId, transferId }) => ({ code, scheduleId, transferId })),
[
{ code: 'register-transfer', scheduleId: 'schedule-1', transferId: undefined },
{ code: 'delete-payment-transfer', scheduleId: undefined, transferId: 'transfer-1' },
{ code: 'submit', scheduleId: undefined, transferId: 'transfer-1' }
]
)
@@ -108,9 +120,10 @@ test('backend actions are intersected with the module whitelist', () => {
assert.deepEqual(
intersectAllowedActions('protocol', 'manager', [
{ key: 'audit', label: '审核' },
'edit',
'qc-conclusion'
]),
[{ code: 'audit', label: '审核' }]
['edit']
)
})
+102 -10
View File
@@ -3,17 +3,54 @@ import test from 'node:test'
import { getMarketModule } from './module-registry.js'
test('payment chase uses the singular read-only resource and opens payment detail', () => {
test('payment chase keeps both read-only views on the chase schedule-bound endpoint', () => {
const module = getMarketModule('payment-chase')
assert.equal(module.resource, 'payment-chase')
assert.deepEqual(module.actions.specialist, ['view-payment'])
assert.deepEqual(module.actions.specialist, ['view-payment', 'view-protocol'])
assert.deepEqual(module.detailTarget({ samplePaymentId: 'pay 1', scheduleId: 'sch-1' }), {
module: 'payment',
id: 'pay 1',
query: { scheduleId: 'sch-1' }
module: 'payment-chase',
id: 'sch-1'
})
})
test('payment chase renders payment and protocol payloads with their native detail adapters', () => {
const chase = getMarketModule('payment-chase')
const paymentSections = chase.detailSections({
chaseView: 'payment',
protocolNo: 'P-1',
schedules: [{ id: 'sch-1', scheduleStatus: 'open', dueAmount: '100' }]
})
const protocolSections = chase.detailSections({
chaseView: 'protocol',
source: 'cell_storage',
basic: { protocolNo: 'P-1' }
})
assert.equal(paymentSections[0].key, 'summary')
assert.equal(paymentSections[1].cards.length, 1)
assert.equal(protocolSections[0].key, 'protocol')
})
test('payment detail keeps DECIMAL cents beyond Number safe integer range', () => {
const payment = getMarketModule('payment')
const sections = payment.detailSections({
contractAmount: '9999999999999999.99',
paidAmount: '-1000000000000000.50',
dueRemainAmount: '0.009',
schedules: []
})
const fields = sections.find((section) => section.key === 'summary').fields
assert.equal(
fields.find((field) => field.label === '合同额').value,
'¥9,999,999,999,999,999.99'
)
assert.equal(
fields.find((field) => field.label === '已缴 / 待缴').value,
'¥-1,000,000,000,000,000.50 / ¥0.01'
)
})
test('module adapters map list filters to the actual backend query names', () => {
assert.deepEqual(getMarketModule('presign').listParams({ status: 'draft' }), {
orderStatus: 'draft'
@@ -69,7 +106,16 @@ test('protocol adapters preserve nested detail sections', () => {
attachments: { protocolAttachments: 'a.pdf' }
})
assert.deepEqual(protocol.map((section) => section.key), [
'protocol', 'collection', 'customer', 'samples', 'payment', 'attachments'
'protocol',
'collection',
'customer',
'samples',
'payment',
'storageCert',
'insurance',
'audits',
'statusChange',
'attachments'
])
})
@@ -126,16 +172,21 @@ test('presign detail keeps the Web order fields and transfer cards', () => {
expectedDeliveryDate: '20261231',
contractAmount: '28800',
confirmedDepositAmount: '5000',
sampleTypeNames: ['脐带血', '胎盘'],
orderStatus: 'active',
transfers: [{
id: 't-1', transferType: 'wx', transferAmount: '5000',
transferTime: '2026-10-11 10:00:00', payStatus: 'yjf'
transferTime: '2026-10-11 10:00:00', payStatus: 'yjf', financeRemark: '财务已核对'
}]
})
assert.deepEqual(sections.map((section) => section.key), ['base', 'transfers'])
assert.ok(sections[0].fields.some((field) => field.label === '母亲姓名' && field.value === '张三'))
assert.ok(sections[0].fields.some((field) => field.label === '样本类型' && field.value === '脐带血、胎盘'))
assert.equal(sections[1].raw.transfers[0].statusLabel, '付款完成')
assert.ok(sections[1].cards[0].fields.some(
(field) => field.label === '财务备注' && field.value === '财务已核对'
))
})
test('payment adapters initialize only editable fields from real detail shapes', () => {
@@ -167,7 +218,7 @@ test('payment detail recognizes the converted cell-storage protocol status', ()
test('payment detail presents real backend progress, schedule statuses, and combined pay mode', () => {
const sections = getMarketModule('payment').detailSections({
payMode: 'djfq',
paymentProgress: '部分缴费',
paymentProgress: '部分已缴',
schedules: [
{ id: 'open', scheduleStatus: 'open' },
{ id: 'cleared', scheduleStatus: 'cleared' },
@@ -178,8 +229,8 @@ test('payment detail presents real backend progress, schedule statuses, and comb
const schedules = sections.find((section) => section.key === 'schedules').cards
assert.equal(summary.fields.find((field) => field.label === '缴费方式').value, '趸交+分期')
assert.equal(summary.fields.find((field) => field.label === '缴费进度').value, '部分缴费')
assert.equal(schedules.find((card) => card.key === 'open').statusText, '待缴费')
assert.equal(summary.fields.find((field) => field.label === '缴费进度').value, '部分已缴')
assert.equal(schedules.find((card) => card.key === 'open').statusText, '待缴清')
assert.equal(schedules.find((card) => card.key === 'cleared').statusText, '已缴清')
assert.equal(schedules.find((card) => card.key === 'void').statusText, '已作废')
})
@@ -193,6 +244,47 @@ test('protocol detail recognizes the combined lump-sum and installment pay mode'
assert.equal(payment.fields.find((field) => field.label === '缴费模式').value, '趸交+分期')
})
test('protocol read-only history uses Web audit and void type labels', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'cell_storage',
basic: {
protocolStatus: 'yzf',
voidType: 'tenant-custom',
voidTypeLabel: '租户自定义作废'
},
audits: [{ id: 'audit-1', auditStatus: 'shth' }]
})
const audits = sections.find((section) => section.key === 'audits')
const status = sections.find((section) => section.key === 'statusChange')
assert.equal(audits.cards[0].statusText, '审核退回')
assert.equal(
audits.cards[0].fields.find((field) => field.label === '审核状态').value,
'审核退回'
)
assert.equal(
status.fields.find((field) => field.label === '作废类型').value,
'租户自定义作废'
)
})
test('donation detail shows void type and preserves unknown original values', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'research_donation',
basic: {
donationStatus: 'yzf',
voidType: 'legacy-unknown',
voidTypeLabel: 'legacy-unknown'
}
})
const status = sections.find((section) => section.key === 'statusChange')
assert.equal(
status.fields.find((field) => field.label === '作废类型').value,
'legacy-unknown'
)
})
test('protocol source is selectable only while creating and exposes all attachment fields', () => {
const fields = getMarketModule('protocol').formSections.flatMap((section) => section.fields)
assert.equal(fields.find((field) => field.key === 'source').createOnly, true)
+11 -1
View File
@@ -29,6 +29,16 @@ export default {
},
listParams: ({ status } = {}) => ({ transportMode: status || '', includeItems: true }),
statusOptions: [{ text: '全部运输方式', value: '' }],
filterFields: [
{ key: 'transportMode', label: '运输方式', type: 'select', optionKey: 'transportModes' },
{
key: 'sampleSource', label: '样本来源', type: 'select', options: [
{ text: '细胞存储', value: 'cell_storage' },
{ text: '科研捐赠', value: 'research_donation' }
]
},
{ key: 'createTimeRange', label: '创建时间', type: 'date-range' }
],
detailSections(detail) {
return [
{
@@ -71,7 +81,7 @@ export default {
},
sampleOptions(action, detailItems, optionRows) {
if (action === 'unlink') return Array.isArray(detailItems) ? detailItems : []
return (Array.isArray(optionRows) ? optionRows : []).filter((item) => !item.freightId)
return Array.isArray(optionRows) ? optionRows : []
},
formSections: [{ key: 'base', title: '运费批次', fields: [
{ key: 'transportMode', label: '运输方式', type: 'select', optionKey: 'transportModes', required: true },
@@ -1,19 +1,44 @@
import paymentModule from './payment.js'
import protocolModule from './protocol.js'
export default {
key: 'payment-chase',
resource: 'payment-chase',
readOnly: true,
actions: { specialist: ['view-payment'], manager: ['view-payment'] },
actions: {
specialist: ['view-payment', 'view-protocol'],
manager: ['view-payment', 'view-protocol']
},
listParams: ({ status } = {}) => ({ chaseType: status || '' }),
statusOptions: [
{ text: '全部催缴', value: '' },
{ text: '已逾期', value: 'overdue' },
{ text: '未来 30 天', value: 'upcoming' }
],
filterFields: [
{
key: 'chaseType', label: '催缴类型', type: 'select', options: [
{ text: '仅逾期', value: 'overdue' },
{ text: '仅未来30天', value: 'upcoming' }
]
},
{
key: 'payMode', label: '缴费方式', type: 'select', options: [
{ text: '趸交', value: 'dj' },
{ text: '分期', value: 'fq' },
{ text: '分改趸', value: 'fgd' }
]
}
],
detailTarget(item) {
return {
module: 'payment',
id: String(item?.samplePaymentId || ''),
query: item?.scheduleId ? { scheduleId: String(item.scheduleId) } : {}
module: 'payment-chase',
id: String(item?.scheduleId || '')
}
},
detailSections(detail) {
return detail?.chaseView === 'protocol'
? protocolModule.detailSections(detail)
: paymentModule.detailSections(detail)
}
}
+27 -9
View File
@@ -1,15 +1,17 @@
import { formatMoney } from '../money.js'
const PAY_MODE = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' })
const PROTOCOL_STATUS = Object.freeze({
dsx: '待生效', dqr: '待确认', sxz: '生效中', ygq: '已过期',
yzf: '已作废', sdfq: '属地废弃', zckc: '转细胞存储', zky: '转科研'
})
const PAYMENT_PROGRESS = Object.freeze({
dfj: '待缴费', bfyj: '部分已缴', yjq: '已缴清',
待缴费: '待缴费', 部分缴费: '部分缴费', 已缴清: '已缴清'
dfj: '待缴清', bfyj: '部分已缴', yjq: '已缴清',
待缴清: '待缴清', 待缴费: '待缴清', 部分已缴: '部分已缴', 部分缴费: '部分已缴', 已缴清: '已缴清'
})
const SCHEDULE_STATUS = Object.freeze({
open: '待缴费', cleared: '已缴清', void: '已作废',
pending: '待缴费', partial: '部分到账', paid: '已缴清', closed: '已关闭'
open: '待缴清', cleared: '已缴清', void: '已作废',
pending: '待缴清', partial: '部分到账', paid: '已缴清', closed: '已关闭'
})
const PAY_STATUS = Object.freeze({
dfj: '已付款', jfqrz: '付款确认中', yjf: '付款完成', tfqrz: '退费确认中', ytf: '已退费'
@@ -27,10 +29,7 @@ function label(map, value) {
}
function money(value) {
if (value == null || value === '') return '—'
const amount = Number(value)
if (!Number.isFinite(amount)) return String(value)
return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return formatMoney(value)
}
function displayDate(value) {
@@ -52,7 +51,8 @@ function transferCard(transfer, index) {
{ label: '转账金额', value: money(transfer?.transferAmount) },
{ label: '转账时间', value: transfer?.transferTime || '—' },
{ label: '转账凭证', type: 'attachments', value: transfer?.transferAttachments || '' },
{ label: '备注', value: transfer?.remark || '—' }
{ label: '备注', value: transfer?.remark || '—' },
{ label: '财务备注', value: transfer?.financeRemark || '—' }
]
}
}
@@ -83,6 +83,24 @@ export default {
},
listParams: ({ status } = {}) => ({ payMode: status || '' }),
statusOptions: [{ text: '全部缴费模式', value: '' }],
filterFields: [
{
key: 'payMode', label: '缴费方式', type: 'select', options: [
{ text: '趸交', value: 'dj' },
{ text: '分期', value: 'fq' },
{ text: '分改趸', value: 'fgd' },
{ text: '趸交+分期', value: 'djfq' }
]
},
{
key: 'progressStatus', label: '缴费进度', type: 'select', options: [
{ text: '待缴清', value: 'dfj' },
{ text: '部分已缴', value: 'bfyj' },
{ text: '已缴清', value: 'yjq' }
]
},
{ key: 'dueDateRange', label: '应缴费时间', type: 'date-range' }
],
detailSections(detail) {
const progress = label(PAYMENT_PROGRESS, detail?.progressStatus || detail?.paymentProgress)
const protocolStatus = label(PROTOCOL_STATUS, detail?.protocolStatus)
+18 -1
View File
@@ -66,6 +66,16 @@ export default {
{ text: '已转正式', value: 'converted' },
{ text: '已作废', value: 'void' }
],
filterFields: [
{
key: 'orderStatus', label: '订单状态', type: 'select', options: [
{ text: '草稿', value: 'draft' },
{ text: '进行中', value: 'active' },
{ text: '已转正式', value: 'converted' },
{ text: '已作废', value: 'void' }
]
}
],
statusLabel(detail) {
return presignStatus(detail?.orderStatus)
},
@@ -101,6 +111,12 @@ export default {
{ label: '采集经理', value: person(detail?.regionManagerUserName, detail?.regionManagerUserId) },
{ label: '采集时间', value: displayValue(formatPresignDateTime(detail?.sampleCollectTime)) },
{ label: '缴费方式', value: dictionaryLabel({ fq: '分期', dj: '趸交' }, detail?.payMode) },
{
label: '样本类型',
value: Array.isArray(detail?.sampleTypeNames) && detail.sampleTypeNames.length
? detail.sampleTypeNames.filter(Boolean).join('、') || '—'
: '—'
},
{ label: '合同金额', value: displayValue(formatPresignMoney(detail?.contractAmount)) },
{ label: '到账金额', value: displayValue(formatPresignMoney(detail?.confirmedDepositAmount)) },
{ label: '订单状态', value: presignStatus(status), tag: true, tagType: presignStatusTone(status) },
@@ -131,7 +147,8 @@ export default {
? transfer.attachments
: transfer.transferAttachments || ''
},
{ label: '备注', value: transfer.remark || '—' }
{ label: '备注', value: transfer.remark || '—' },
{ label: '财务备注', value: transfer.financeRemark || '—' }
]
}))
}
+483 -31
View File
@@ -1,3 +1,10 @@
import { groupLegacyPaymentPeriods } from '../protocol-v1-payment.js'
import {
formatProductionReceptionDate,
productionReceptionFieldValue,
resolveProductionReceptionFields
} from '../protocol-production-reception.js'
const STATUS_LABELS = Object.freeze({
dsx: '待生效', dqr: '待确认', sxz: '生效中', ygq: '已过期',
yzf: '已作废', sdfq: '属地废弃', zky: '转科研', zckc: '转细胞存储'
@@ -8,6 +15,17 @@ const DELIVERY_LABELS = Object.freeze({ sc: '顺产', pgc: '剖宫产' })
const SAMPLE_STATUS_LABELS = Object.freeze({
ycj: '已采集', jcz: '检测中', yrk: '已入库', yzf: '已作废'
})
const AUDIT_STATUS_LABELS = Object.freeze({
shtg: '审核通过', shth: '审核退回', dsh: '待审核'
})
const VOID_TYPE_LABELS = Object.freeze({
khhc: '客户毁存', jcbhg: '检测不合格'
})
const PAYMENT_STATUS_LABELS = Object.freeze({
dfj: '已付款', jfqrz: '付款确认中', yjf: '付款完成',
tfqrz: '退费确认中', ytf: '已退费'
})
const SCHEDULE_STATUS_LABELS = Object.freeze({ open: '待缴清', cleared: '已缴清', void: '已作废' })
function value(source, key) {
const result = source?.[key]
@@ -19,6 +37,13 @@ function label(map, source) {
return map[key] || (key ? (/[㐀-鿿]/u.test(key) ? key : '未知') : '—')
}
function voidTypeText(source) {
const backendLabel = String(source?.voidTypeLabel || '').trim()
if (backendLabel) return backendLabel
const code = String(source?.voidType || '').trim()
return VOID_TYPE_LABELS[code] || code || '—'
}
function person(name, id) {
const displayName = String(name || '').trim()
const referenceId = String(id || '').trim()
@@ -42,6 +67,292 @@ function fields(source, definitions) {
}))
}
function checkedItems(items) {
const names = (items || [])
.filter((item) => item?.itemName)
.map((item) => `${item.itemName}:${item.checked ? '是' : '否'}`)
return names.length ? names.join('、') : '—'
}
function transferAttachmentValue(transfer) {
return transfer?.attachments || transfer?.transferAttachmentFiles || transfer?.transferAttachments || ''
}
function transferTypeText(transfer) {
return String(transfer?.transferTypeLabel || transfer?.transferType || '').trim() || '—'
}
function namedItems(items) {
return (items || [])
.map((item) => typeof item === 'string'
? item
: item?.itemName || item?.name || item?.itemCode || item?.id)
.filter(Boolean)
.join('、') || '—'
}
function qcResultCard(result, index, prefix) {
const unit = String(result?.paramUnit || '').trim()
return {
key: String(result?.id || `${prefix}-result-${index}`),
title: `检测结果 ${index + 1}`,
fields: [
{ label: '参数名称', value: value(result, 'paramName') },
{ label: '参数单位', value: unit || '—' },
{ label: '参数值', value: value(result, 'paramValue') }
]
}
}
function qcTaskCard(task, index, prefix) {
return {
key: String(task?.id || `${prefix}-task-${index}`),
title: task?.itemName || task?.taskName || task?.taskNo || `任务 ${index + 1}`,
statusText: value(task, 'taskStatusLabel'),
fields: [
{ label: '任务号', value: value(task, 'taskNo') },
{ label: '任务类型', value: value(task, 'taskTypeLabel') },
{ label: '检测项目', value: value(task, 'itemName') },
{ label: '检测机构', value: value(task, 'companyName') },
{ label: '任务状态', value: value(task, 'taskStatusLabel'), tag: true },
{ label: '结论', value: value(task, 'conclusionLabel'), tag: true },
{ label: '完成时间', value: value(task, 'completedTime') },
{ label: '备注', value: value(task, 'remark') },
{ label: '任务附件', value: task?.attachmentFiles || task?.thirdPartyAttachments || '', type: 'attachments' }
],
cards: (task?.results || []).map((result, resultIndex) =>
qcResultCard(result, resultIndex, `${prefix}-task-${index}`)
)
}
}
function qcBatchCard(batch, index, prefix) {
return {
key: String(batch?.id || `${prefix}-batch-${index}`),
title: `检测批次 ${index + 1}`,
fields: [
{ label: '批次编号', value: value(batch, 'batchNo') },
{ label: '批次名称', value: value(batch, 'batchName') },
{ label: '负责人', value: value(batch, 'assigneeUserNames') },
{ label: '实验室检测', value: batch?.hasLab ? '是' : '否' },
{ label: '第三方检测', value: batch?.hasThirdParty ? '是' : '否' },
{ label: '实验室项目', value: namedItems(batch?.labItems || batch?.labItemIds) },
{ label: '第三方项目', value: namedItems(batch?.thirdPartyItems || batch?.thirdPartyItemIds) },
{ label: '任务总数', value: value(batch, 'taskCount') },
{ label: '已完成任务数', value: value(batch, 'completedTaskCount') }
],
cards: (batch?.tasks || []).map((task, taskIndex) =>
qcTaskCard(task, taskIndex, `${prefix}-batch-${index}`)
)
}
}
function qcCard(info, title, index = 0) {
return {
key: String(info?.id || `${title}-${index}`),
title,
statusText: value(info, 'workOrderStatusLabel'),
fields: [
{ label: '工单号', value: value(info, 'workOrderNo') },
{ label: '业务类型', value: value(info, 'qcBizTypeLabel') },
{ label: '工单状态', value: value(info, 'workOrderStatusLabel'), tag: true },
{ label: '触发来源', value: value(info, 'triggerSourceLabel') },
{ label: '质检对象编码', value: value(info, 'cellProductCode') },
{ label: '质检对象名称', value: value(info, 'cellProductName') },
{ label: '生产工单号', value: value(info, 'productionWorkOrderNo') },
{ label: '包含实验室检测', value: info?.hasLab ? '是' : '否' },
{ label: '包含第三方检测', value: info?.hasThirdParty ? '是' : '否' },
{ label: '实验室检测项目', value: namedItems(info?.labItems) },
{ label: '第三方检测项目', value: namedItems(info?.thirdPartyItems) },
{ label: '配置时间', value: value(info, 'configuredTime') },
{ label: '检测批次', value: (info?.inspectionBatches || []).map((row) => row?.batchNo || row?.batchName).filter(Boolean).join('、') || '—' }
],
cards: [
...(info?.inspectionBatches || []).map((batch, batchIndex) =>
qcBatchCard(batch, batchIndex, `${title}-${index}`)
),
...(info?.tasks || []).map((task, taskIndex) =>
qcTaskCard(task, taskIndex, `${title}-${index}`)
)
]
}
}
function productionMaterialCard(item, index, kind, prefix) {
const input = kind === 'input'
return {
key: String(item?.id || `${prefix}-${kind}-${index}`),
title: `${input ? '投入物' : '产出物'} ${index + 1}`,
fields: [
{ label: input ? '投入物类型' : '产出物类型', value: value(item, input ? 'inputType' : 'outputType') },
{ label: input ? '投入物编码' : '产出物编码', value: value(item, input ? 'inputCode' : 'outputCode') },
{ label: input ? '投入物名称' : '产出物名称', value: value(item, input ? 'inputName' : 'outputName') }
]
}
}
function sampleLifecycleCards(item, source) {
const cards = []
const reception = item?.sampleReception
if (reception) {
cards.push({
key: `${item?.id || item?.sampleCode}-reception`,
title: '质检接收',
statusText: value(reception, 'acceptConclusionLabel'),
fields: [
{ label: '接收时间', value: value(reception, 'receptionTime') },
{ label: '经手人', value: value(reception, 'handlerName') },
{ label: '接收温度(℃)', value: value(reception, 'receiveTemperature') },
{ label: '结论', value: value(reception, 'acceptConclusionLabel'), tag: true },
{ label: '实验室入库编码', value: value(reception, 'labStorageCode') },
{ label: '检查项', value: checkedItems(reception?.checkResults) },
{ label: '备注', value: value(reception, 'remark') },
{ label: '接收附件', value: reception?.receiveAttachmentFiles || reception?.receiveAttachments || '', type: 'attachments' }
]
})
}
const productionReception = item?.productionReception || reception?.productionReception
if (productionReception) {
const productionFields = resolveProductionReceptionFields(
source,
item?.sampleTypeName || ''
)
cards.push({
key: `${item?.id || item?.sampleCode}-production-reception`,
title: '生产接收',
fields: [
{
label: '处理时间',
value: formatProductionReceptionDate(productionReception?.processTime)
},
{ label: '处理人', value: value(productionReception, 'handlerName') },
...productionFields.map((field) => ({
label: field.label,
value: productionReceptionFieldValue(field, productionReception)
})),
{ label: '备注', value: value(productionReception, 'remark') }
]
})
}
if (item?.qcInfo) cards.push(qcCard(item.qcInfo, '门控质检'))
;(item?.processQcInfoList || []).forEach((info, index) => cards.push(qcCard(info, `过程质检 ${index + 1}`, index)))
const production = item?.productionInfo
if (production) {
cards.push({
key: `${item?.id || item?.sampleCode}-production`,
title: '生产信息',
statusText: value(production, 'workOrderStatusLabel'),
fields: [
{ label: '工单号', value: value(production, 'workOrderNo') },
{ label: '工单状态', value: value(production, 'workOrderStatusLabel'), tag: true }
],
cards: (production?.tasks || []).map((task, index) => ({
key: String(task?.id || `production-task-${index}`),
title: `生产任务 ${index + 1}`,
statusText: value(task, 'taskStatusLabel'),
fields: [
{ label: '任务名称', value: value(task, 'taskName') },
{ label: '任务号', value: value(task, 'taskNo') },
{ label: '任务状态', value: value(task, 'taskStatusLabel'), tag: true },
{ label: '开始时间', value: value(task, 'startedTime') },
{ label: '完成时间', value: value(task, 'completedTime') },
{ label: '生产结论', value: value(task, 'conclusionLabel'), tag: true }
],
cards: [
...(task?.inputs || []).map((input, inputIndex) =>
productionMaterialCard(input, inputIndex, 'input', `production-task-${index}`)
),
...(task?.outputs || []).map((output, outputIndex) =>
productionMaterialCard(output, outputIndex, 'output', `production-task-${index}`)
)
]
}))
})
}
const release = item?.storageReleaseInfo
if (release) {
cards.push({
key: `${item?.id || item?.sampleCode}-storage-release`,
title: '入库放行',
statusText: value(release, 'storageStatusLabel'),
fields: [
{ label: '生产结论', value: value(release, 'prodReviewConclusionLabel'), tag: true },
{ label: '质检结论', value: value(release, 'qcReviewConclusionLabel'), tag: true },
{ label: '入库状态', value: value(release, 'storageStatusLabel'), tag: true },
{ label: '生产审核人', value: value(release, 'prodReviewByName') },
{ label: '生产审核时间', value: value(release, 'prodReviewTime') },
{ label: '生产审核备注', value: value(release, 'prodReviewRemark') },
{ label: '质检审核人', value: value(release, 'qcReviewByName') },
{ label: '质检审核时间', value: value(release, 'qcReviewTime') },
{ label: '质检审核备注', value: value(release, 'qcReviewRemark') },
{ label: '质检附件', value: release?.qcReviewAttachments || '', type: 'attachments' },
{ label: '生产附件', value: release?.prodReviewAttachments || '', type: 'attachments' }
]
})
}
return cards
}
function paymentScheduleCards(pay) {
return (pay?.schedules || []).map((schedule, scheduleIndex) => ({
key: String(schedule?.id || `schedule-${scheduleIndex}`),
title: `第 ${schedule?.scheduleNo || scheduleIndex + 1} 期`,
statusText: label(SCHEDULE_STATUS_LABELS, schedule?.scheduleStatus),
fields: [
{ label: '期次', value: value(schedule, 'scheduleNo') },
{ label: '应缴金额', value: value(schedule, 'dueAmount') },
{ label: '实缴金额', value: value(schedule, 'paidAmount') },
{ label: '欠缴金额', value: value(schedule, 'arrearsAmount') },
{ label: '应缴日期', value: value(schedule, 'dueDate') },
{ label: '状态', value: label(SCHEDULE_STATUS_LABELS, schedule?.scheduleStatus), tag: true }
],
cards: (schedule?.transfers || []).map((transfer, transferIndex) => ({
key: String(transfer?.id || `transfer-${transferIndex}`),
title: `转账 ${transferIndex + 1}`,
statusText: label(PAYMENT_STATUS_LABELS, transfer?.payStatus),
fields: [
{ label: '转账方式', value: transferTypeText(transfer) },
{ label: '转账金额', value: value(transfer, 'transferAmount') },
{ label: '转账时间', value: value(transfer, 'transferTime') },
{ label: '转账凭证', value: transferAttachmentValue(transfer), type: 'attachments' },
{ label: '备注', value: value(transfer, 'remark') },
{ label: '财务备注', value: value(transfer, 'financeRemark') },
{ label: '状态', value: label(PAYMENT_STATUS_LABELS, transfer?.payStatus), tag: true }
]
}))
}))
}
function legacyPaymentDetailCards(payment, details) {
return groupLegacyPaymentPeriods({ payment, details }).map((period, index) => ({
key: `legacy-payment-${period.scheduleNo || index + 1}`,
title: `第 ${period.scheduleNo || index + 1} 期`,
statusText: label(SCHEDULE_STATUS_LABELS, period.scheduleStatus),
fields: [
{ label: '期次', value: String(period.scheduleNo || index + 1) },
{ label: '应缴金额', value: period.dueAmount == null ? '—' : String(period.dueAmount) },
{ label: '确认实缴金额', value: String(period.paidAmount) },
{ label: '欠缴金额', value: period.arrearsAmount == null ? '—' : String(period.arrearsAmount) },
{ label: '应缴日期', value: period.dueDate || '—' },
{ label: '期状态', value: label(SCHEDULE_STATUS_LABELS, period.scheduleStatus), tag: true }
],
cards: period.transfers.map((detail, transferIndex) => ({
key: String(detail?.id || `legacy-transfer-${period.scheduleNo}-${transferIndex}`),
title: `转账 ${transferIndex + 1}`,
statusText: label(PAYMENT_STATUS_LABELS, detail?.payStatus),
fields: [
{ label: '转账方式', value: transferTypeText(detail) },
{ label: '转账金额', value: value(detail, 'transferAmount') },
{ label: '转账时间', value: value(detail, 'transferTime') },
{ label: '转账凭证', value: transferAttachmentValue(detail), type: 'attachments' },
{ label: '备注', value: value(detail, 'remark') },
{ label: '财务备注', value: value(detail, 'financeRemark') },
{ label: '状态', value: label(PAYMENT_STATUS_LABELS, detail?.payStatus), tag: true }
]
}))
}))
}
export default {
key: 'protocol',
resource: 'protocols',
@@ -55,6 +366,20 @@ export default {
{ text: '细胞存储', value: 'cell_storage' },
{ text: '科研捐赠', value: 'research_donation' }
],
filterFields: [
{
key: 'protocolStatus', label: '协议状态', type: 'select', source: 'cell_storage', options: [
{ text: '待生效', value: 'dsx' },
{ text: '生效中', value: 'sxz' },
{ text: '已过期', value: 'ygq' },
{ text: '已作废', value: 'yzf' },
{ text: '属地废弃', value: 'sdfq' },
{ text: '转科研', value: 'zky' }
]
},
{ key: 'createTimeRange', label: '创建时间', type: 'date-range', source: 'cell_storage' },
{ key: 'sampleCollectTimeRange', label: '样本采集时间', type: 'date-range', source: 'cell_storage' }
],
statusLabel(detail) {
const basic = detail?.protocol || detail?.basic || detail || {}
const status = detail?.source === 'research_donation'
@@ -129,16 +454,72 @@ export default {
statusText: label(SAMPLE_STATUS_LABELS, item?.sampleStatusLabel || item?.sampleStatus),
fields: fields(item, [
['sampleTypeName', '样本类型', { map: () => referenceName(item?.sampleTypeName, item?.sampleTypeId) }],
['sampleCode', '样本编码'], ['collectAmount', '采集量'],
['unitName', '计量单位', { map: () => referenceName(item?.unitName, item?.unitId) }],
['price', '单价'], ['originalPrice', '原价'], ['storageYears', '存储年限'],
['sampleCode', '样本编码'],
['sampleStatus', '样本状态', { map: () => label(SAMPLE_STATUS_LABELS, item?.sampleStatusLabel || item?.sampleStatus) }],
['price', '优惠价'], ['originalPrice', '原价'], ['storageYears', '存储年限'],
[
'transportMode',
'运输方式',
{
map: () =>
item?.freight?.transportModeLabel ||
item?.freight?.transportMode ||
'—'
}
],
[
'freightAmount',
'运费金额',
{
map: () => {
if (item?.freight?.freightAmount == null) return '—'
const shared = Number(item.freight.linkedSampleCount || 0) > 1
return `${item.freight.freightAmount}${shared ? '(共用)' : ''}`
}
}
],
['etaTime', '预计到达时间', { map: () => item?.etaTime || item?.transport?.etaTime || '' }],
['isSpecial', '是否特殊样本', { map: (itemValue) => Number(itemValue) === 1 ? '是' : '否' }],
['specialRemark', '特殊说明'], ['remark', '备注']
])
['specialRemark', '特殊说明'],
['remark', '备注']
]),
cards: sampleLifecycleCards(item, source)
}))
}
]
const depositTransfers = Array.isArray(detail?.depositTransfers) ? detail.depositTransfers : []
const depositHint = detail?.presignDepositHint
if (detail?.source === 'cell_storage' && (depositHint || depositTransfers.length)) {
const amt = Number(depositHint?.confirmedDepositAmount || 0)
const cnt = Number(depositHint?.confirmedTransferCount || 0)
values.push({
key: 'deposit',
title: '意向金(关联预签约)',
fields: depositHint
? [{
label: '已确认意向金',
value: `¥${amt.toFixed(2)}(${cnt} 笔);与预签同一数据,不自动写入样本缴费`
}]
: [],
cards: depositTransfers.map((tr, index) => ({
key: String(tr?.id || `deposit-${index}`),
title: `意向金转账 ${index + 1}`,
statusText: label(
{ dfj: '已付款', jfqrz: '付款确认中', yjf: '付款完成', tfqrz: '退费确认中', ytf: '已退费' },
tr?.payStatus
),
fields: [
{ label: '转账金额', value: value(tr, 'transferAmount') },
{ label: '转账时间', value: value(tr, 'transferTime') },
{ label: '转账方式', value: transferTypeText(tr) },
{ label: '转账凭证', value: transferAttachmentValue(tr), type: 'attachments' },
{ label: '备注', value: value(tr, 'remark') },
{ label: '财务备注', value: value(tr, 'financeRemark') },
{ label: '状态', value: label(PAYMENT_STATUS_LABELS, tr?.payStatus), tag: true }
]
}))
})
}
if (detail?.source === 'cell_storage' || detail?.payment || detail?.samplePayments) {
const samplePays = Array.isArray(detail?.samplePayments) ? detail.samplePayments : []
if (samplePays.length) {
@@ -163,12 +544,14 @@ export default {
]),
...((pay?.schedules || []).map((sch, sIdx) => ({
label: `第 ${sch?.scheduleNo || sIdx + 1} 期`,
value: `应缴 ${sch?.dueAmount ?? '—'} / ${sch?.dueDate || '—'}`
value: `应缴 ${sch?.dueAmount ?? '—'} / 实缴 ${sch?.paidAmount ?? '—'} / 欠缴 ${sch?.arrearsAmount ?? '—'} / ${sch?.dueDate || '—'}`
})))
]
],
cards: paymentScheduleCards(pay)
}))
})
} else {
const legacyDetails = Array.isArray(detail?.paymentDetails) ? detail.paymentDetails : []
values.push({
key: 'payment',
title: '缴费信息',
@@ -179,10 +562,94 @@ export default {
['installmentStartDate', '分期开始时间'], ['installmentAdvanceDays', '提前催缴预警(天)'],
['remark', '缴费备注']
]),
cards: []
cards: legacyPaymentDetailCards(detail?.payment || {}, legacyDetails)
})
}
}
if (detail?.source === 'cell_storage') {
const cert = detail?.storageCert
values.push({
key: 'storageCert',
title: '储存证',
fields: cert
? [
{ label: '办理状态', value: Number(cert.makeStatus) === 1 || cert.handled ? '已办理' : '未办理', tag: true },
{ label: '办理日期', value: value(cert, 'makeDate') },
{ label: '办理人', value: person(cert.handlerUserName, cert.handlerUserId) },
{ label: '办理时间', value: value(cert, 'handleTime') },
{ label: '备注', value: value(cert, 'remark') },
{
label: '储存证图片',
value: cert.certImageUrls || '',
type: 'attachments'
}
]
: [{ label: '办理状态', value: '暂无储存证记录' }],
cards: []
})
const insurance = detail?.fullmoonTodo || {}
values.push({
key: 'insurance',
title: '保险 / 满月待办',
fields: [
{ label: '办理状态', value: insurance.handled ? '已办理' : '未办理', tag: true },
{ label: '满月日期', value: value(insurance, 'fullmoonDate') },
{ label: '保险公司', value: value(insurance, 'insuranceCompanyName') },
{ label: '保险类型', value: value(insurance, 'insuranceType') },
{ label: '保单号', value: value(insurance, 'insurancePolicyNo') },
{ label: '生效日', value: value(insurance, 'insuranceEffectiveDate') },
{ label: '到期日', value: value(insurance, 'insuranceExpiryDate') },
{ label: '办理人', value: person(insurance.handlerUserName, insurance.handlerUserId) },
{ label: '办理时间', value: value(insurance, 'handleTime') },
{ label: '办理备注', value: value(insurance, 'handleRemark') },
{
label: '保险附件',
value: insurance.insuranceAttachments || '',
type: 'attachments'
}
],
cards: []
})
}
const audits = Array.isArray(detail?.audits) ? detail.audits : []
values.push({
key: 'audits',
title: '审核记录',
fields: audits.length ? [] : [{ label: '记录', value: '暂无审核记录' }],
cards: audits.map((row, index) => ({
key: String(row?.id || `audit-${index}`),
title: `审核 ${index + 1}`,
statusText: label(AUDIT_STATUS_LABELS, row?.auditStatus),
fields: [
{ label: '审核状态', value: label(AUDIT_STATUS_LABELS, row?.auditStatus), tag: true },
{ label: '审核人', value: person(row?.auditorUserName, row?.auditorUserId) },
{ label: '审核意见', value: value(row, 'auditRemark') },
{ label: '时间', value: value(row, 'createTime') }
]
}))
})
const commonStatusFields = [
['effectiveTime', '协议生效时间'],
['effectiveDesc', '协议生效描述'],
['expireTime', '协议过期时间'],
['expireDesc', '协议过期描述'],
['voidTime', '协议作废时间'],
['voidDesc', '协议作废描述'],
['voidType', '作废类型']
]
const sourceStatusFields = source === 'research_donation'
? [['convertTime', '转细胞存储时间'], ['convertDesc', '转细胞存储描述']]
: [['toResearchTime', '转科研时间'], ['toResearchDesc', '转科研描述']]
const statusFields = [...commonStatusFields, ...sourceStatusFields].map(([key, textLabel]) => ({
label: textLabel,
value: key === 'voidType' ? voidTypeText(basic) : value(basic, key)
})).filter((item) => item.value !== '—')
values.push({
key: 'statusChange',
title: '状态变更记录',
fields: statusFields.length ? statusFields : [{ label: '记录', value: '暂无状态变更记录' }],
cards: []
})
values.push({
key: 'attachments',
title: '附件信息',
@@ -221,14 +688,14 @@ export default {
{ key: 'remark', label: '备注', type: 'textarea' }
] },
{ key: 'customer', title: '客户信息', nested: true, fields: [
{ key: 'motherName', label: '母亲姓名', required: true },
{ key: 'motherName', label: '母亲姓名', type: 'customerSearch', role: 'mother', required: true },
{ key: 'motherPhone', label: '联系电话', type: 'tel' },
{ key: 'motherIdCard', label: '母亲身份证号' },
{ key: 'motherEmail', label: '母亲邮箱' },
{ key: 'motherBirthYm', label: '母亲出生年月' },
{ key: 'motherEthnic', label: '母亲民族', type: 'select', optionKey: 'ethnicities' },
{ key: 'motherAddress', label: '母亲家庭住址', type: 'textarea' },
{ key: 'fatherName', label: '父亲姓名' },
{ key: 'fatherName', label: '父亲姓名', type: 'customerSearch', role: 'father' },
{ key: 'fatherPhone', label: '父亲电话', type: 'tel' },
{ key: 'fatherIdCard', label: '父亲身份证号' },
{ key: 'newbornName', label: '新生儿姓名' },
@@ -247,28 +714,13 @@ export default {
{ key: 'apgar5min', label: '新生儿评分(5分钟)', type: 'number' },
{ key: 'remark', label: '备注', type: 'textarea' }
] },
{ key: 'samples', title: '样本信息', repeat: true, fields: [
{ key: 'sampleTypeId', label: '样本类型', type: 'select', optionKey: 'sampleTypes' },
{ key: 'sampleCodeId', label: '样本编码记录', type: 'select', optionKey: 'sampleCodes' },
{ key: 'sampleCode', label: '样本编码' },
{ key: 'collectAmount', label: '采集量', type: 'number' },
{ key: 'unitId', label: '计量单位', type: 'select', optionKey: 'units' },
{ key: 'price', label: '单价', type: 'number', source: 'cell_storage' },
{ key: 'originalPrice', label: '原价', type: 'number', source: 'cell_storage' },
{ key: 'storageYears', label: '存储年限', type: 'number', source: 'cell_storage' },
{ key: 'etaTime', label: '预计到达时间', type: 'datetime' },
{ key: 'isSpecial', label: '是否特殊样本', type: 'tabs', options: [
{ label: '否', value: 0 }, { label: '是', value: 1 }
] },
{ key: 'specialRemark', label: '特殊说明', type: 'textarea' },
{ key: 'remark', label: '备注', type: 'textarea' }
] },
// 样本及运输改由 ProtocolSampleTransportPanel 托管(对齐 Web)
{ key: 'attachments', title: '附件与确认', nested: true, fields: [
{ key: 'protocolAttachments', label: '协议附件', type: 'upload', source: 'cell_storage' },
{ key: 'consentFormUrls', label: '知情同意书', type: 'upload' },
{ key: 'healthSurveyFormUrls', label: '健康调查表', type: 'upload', source: 'cell_storage' },
{ key: 'attachmentUrls', label: '其他附件', type: 'upload', source: 'research_donation' },
{ key: 'infectiousDiseaseReportUrls', label: '传染病报告', type: 'upload', source: 'research_donation' }
{ key: 'protocolAttachments', label: '协议附件', type: 'upload', attachmentPolicy: 'protocol', source: 'cell_storage' },
{ key: 'consentFormUrls', label: '知情同意书', type: 'upload', attachmentPolicy: 'protocol' },
{ key: 'healthSurveyFormUrls', label: '健康调查表', type: 'upload', attachmentPolicy: 'protocol', source: 'cell_storage' },
{ key: 'attachmentUrls', label: '其他附件', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' },
{ key: 'infectiousDiseaseReportUrls', label: '传染病报告', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' }
] },
{ key: 'confirm', title: '提交确认', nested: true, uiOnly: true, fields: [
{ key: 'confirmed', label: '我已核对以上信息', type: 'checkbox', required: true }
+65
View File
@@ -0,0 +1,65 @@
/**
* Parse a decimal amount into integer cents without passing through binary
* floating point. Values with more than two decimal places are rounded half up.
*/
export function moneyToMinorUnits(value) {
if (value == null || String(value).trim() === '') return null
const match = String(value).trim().match(/^([+-]?)(\d+)(?:\.(\d*))?$/)
if (!match) return null
const negative = match[1] === '-'
const fraction = (match[3] || '').padEnd(3, '0')
let cents = BigInt(match[2]) * 100n + BigInt(fraction.slice(0, 2) || '0')
if (fraction[2] >= '5') cents += 1n
return negative && cents !== 0n ? -cents : cents
}
export function minorUnitsToMoney(value) {
let cents
try {
cents = typeof value === 'bigint' ? value : BigInt(value)
} catch {
return null
}
const negative = cents < 0n
const absolute = negative ? -cents : cents
const whole = absolute / 100n
const fraction = String(absolute % 100n).padStart(2, '0')
return `${negative ? '-' : ''}${whole}.${fraction}`
}
export function normalizeMoney(value) {
const cents = moneyToMinorUnits(value)
return cents == null ? null : minorUnitsToMoney(cents)
}
export function addMoney(values) {
const cents = (values || []).reduce((sum, value) => {
const parsed = moneyToMinorUnits(value)
return parsed == null ? sum : sum + parsed
}, 0n)
return minorUnitsToMoney(cents)
}
export function subtractMoney(left, right) {
const leftCents = moneyToMinorUnits(left) || 0n
const rightCents = moneyToMinorUnits(right) || 0n
return minorUnitsToMoney(leftCents - rightCents)
}
export function compareMoney(left, right) {
const leftCents = moneyToMinorUnits(left) || 0n
const rightCents = moneyToMinorUnits(right) || 0n
if (leftCents === rightCents) return 0
return leftCents > rightCents ? 1 : -1
}
export function formatMoney(value) {
const normalized = normalizeMoney(value)
if (normalized == null) return '—'
const sign = normalized.startsWith('-') ? '-' : ''
const unsigned = sign ? normalized.slice(1) : normalized
const [integer, cents] = unsigned.split('.')
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${sign}${grouped}.${cents}`
}
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addMoney,
compareMoney,
minorUnitsToMoney,
moneyToMinorUnits,
subtractMoney
} from './money.js'
test('money arithmetic adds decimal strings without binary floating point drift', () => {
assert.equal(addMoney(['0.1', '0.2']), '0.30')
assert.equal(subtractMoney('0.3', '0.1'), '0.20')
assert.equal(compareMoney('0.30', '0.3'), 0)
})
test('money conversion rounds half up and supports negative and empty values', () => {
assert.equal(moneyToMinorUnits('1.005'), 101n)
assert.equal(moneyToMinorUnits('-1.005'), -101n)
assert.equal(moneyToMinorUnits('1.004'), 100n)
assert.equal(moneyToMinorUnits(''), null)
assert.equal(addMoney(['', null, '-0.25']), '-0.25')
assert.equal(minorUnitsToMoney(-5n), '-0.05')
})
test('money conversion preserves the full DECIMAL(18,2) boundary', () => {
assert.equal(moneyToMinorUnits('9999999999999999.99'), 999999999999999999n)
assert.equal(
minorUnitsToMoney(moneyToMinorUnits('9999999999999999.99')),
'9999999999999999.99'
)
})
@@ -14,6 +14,8 @@ test('presign select uses Vant picker and supports searchable multi-select', asy
assert.match(source, /<van-search/)
assert.match(source, /<van-checkbox-group/)
assert.match(source, /emit\('search'/)
assert.match(source, /filterSelectOptions/)
assert.doesNotMatch(source, /props\.remote\) return normalizedOptions/)
assert.doesNotMatch(source, /<select\b/)
})
@@ -22,12 +24,16 @@ test('presign attachment control uploads immediately and renders attachment card
const cards = await componentSource('MarketAttachmentCards')
assert.match(source, /<MarketAttachmentCards/)
assert.match(source, /:accept="PRESIGN_ATTACHMENT_ACCEPT"/)
assert.match(source, /@upload="\$emit\('upload', \$event\)"/)
assert.match(source, /@remove="\$emit\('remove', \$event\)"/)
assert.match(cards, /<van-uploader/)
assert.match(cards, /:after-read="afterRead"/)
assert.match(cards, /attachmentFullUrl/)
assert.match(cards, /createAuthorizedImagePreviewResolver/)
assert.match(cards, /:src="authorizedImageUrl\(file\)"/)
assert.match(cards, /downloadAttachmentFile/)
assert.match(cards, /is-uploading/)
assert.match(cards, /上传中/)
assert.match(cards, /width:\s*84px/)
})
@@ -36,6 +42,13 @@ test('presign transfer card is controlled by backend allowedActions', async () =
assert.match(source, /allowedActions/)
assert.match(source, /submit-transfer/)
assert.match(source, /withdraw-transfer/)
assert.match(source, /min-height:\s*44px/)
assert.match(source, /presign-transfer-card__delete/)
assert.match(source, /justify-content:\s*flex-end/)
assert.match(source, /gap:\s*14px/)
assert.match(source, /\.presign-transfer-card__actions \.van-button\s*\{[^}]*min-height:\s*28px;[^}]*height:\s*28px;[^}]*padding:\s*0 8px;[^}]*font-size:\s*12px;/)
assert.match(source, /提交财务确认<\/van-button>/)
assert.match(source, /size="small"[\s\S]*?提交财务确认/)
assert.match(source, /size="small"[\s\S]*?撤回/)
assert.match(source, /presign-transfer-card__delete[\s\S]*?size="small"/)
assert.doesNotMatch(source, /<small[^>]*>\s*\{\{\s*modelValue\.id\s*\}\}\s*<\/small>/)
})
@@ -1,3 +1,5 @@
import { normalizeMoneyInput } from '../../oa/money-field-model.js'
const ORDER_WRITABLE_FIELDS = Object.freeze([
'orderDate',
'motherName',
@@ -120,12 +122,20 @@ function attachmentUrls(transfer) {
return ''
}
function normalizedAmount(value) {
const normalized = normalizeMoneyInput(value)
return normalized === '' ? null : normalized
}
export function buildPresignTransferPayload(transfer = {}) {
const source = transfer && typeof transfer === 'object' ? transfer : {}
const normalized = {
...source,
transferAttachments: attachmentUrls(source)
}
if (Object.prototype.hasOwnProperty.call(source, 'transferAmount')) {
normalized.transferAmount = normalizedAmount(source.transferAmount)
}
return Object.fromEntries(
TRANSFER_WRITABLE_FIELDS
.filter((key) => Object.prototype.hasOwnProperty.call(normalized, key))
@@ -140,6 +150,9 @@ export function buildPresignOrderPayload(form = {}, { includeTransfers = true }
.filter((key) => Object.prototype.hasOwnProperty.call(source, key))
.map((key) => [key, clone(source[key])])
)
if (Object.prototype.hasOwnProperty.call(payload, 'contractAmount')) {
payload.contractAmount = normalizedAmount(payload.contractAmount)
}
if (includeTransfers && Array.isArray(source.transfers)) {
payload.transfers = source.transfers.map(buildPresignTransferPayload)
}
@@ -153,13 +153,13 @@ test('builds an order payload from only backend-writable camelCase fields', () =
regionManagerUserId: 'manager-1',
sampleCollectTime: '2026-10-11 08:30:00',
payMode: 'dj',
contractAmount: 0,
contractAmount: '0.00',
orderStatus: 'active',
sampleTypeIds: ['sample-1'],
remark: '',
transfers: [{
transferType: 'wxzz',
transferAmount: 100,
transferAmount: '100.00',
transferTime: '2026-10-11 08:00:00',
transferAttachments: 'proof.pdf',
remark: '转账备注'
@@ -191,6 +191,16 @@ test('builds a transfer payload without ids, status, timestamps, or finance fiel
})
})
test('normalizes presign contract and transfer amounts as exact two-decimal strings', () => {
const boundary = '9999999999999999.99'
assert.equal(buildPresignOrderPayload({ contractAmount: boundary }).contractAmount, boundary)
assert.equal(buildPresignOrderPayload({ contractAmount: '1.005' }).contractAmount, '1.01')
assert.equal(buildPresignTransferPayload({ transferAmount: boundary }).transferAmount, boundary)
assert.equal(buildPresignTransferPayload({ transferAmount: '1.005' }).transferAmount, '1.01')
assert.equal(buildPresignOrderPayload({ contractAmount: '' }).contractAmount, null)
assert.equal(buildPresignTransferPayload({ transferAmount: '' }).transferAmount, null)
})
test('uses the current attachment array instead of a stale backend URL string', () => {
assert.equal(buildPresignTransferPayload({
transferAttachments: 'old.pdf,removed.pdf',
@@ -9,6 +9,7 @@ import {
} from './presign-presenter.js'
const CARD_ACTIONS = new Set(['edit', 'convert', 'delete'])
const PAY_MODE_LABELS = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' })
function displayText(value) {
return String(value ?? '').trim() || '-'
@@ -18,6 +19,20 @@ function actionCode(action) {
return typeof action === 'string' ? action : String(action?.code || '').trim()
}
function payModeLabel(value) {
const key = String(value || '').trim()
return PAY_MODE_LABELS[key] || (key ? '未知' : '-')
}
export function buildPresignListParams({ keyword = '', orderStatus = '', page = 1, pageSize = 10 } = {}) {
return {
keyword: String(keyword || '').trim(),
orderStatus: String(orderStatus || '').trim(),
page: Math.max(1, Number(page) || 1),
pageSize: Math.max(1, Number(pageSize) || 10)
}
}
export function buildPresignCardModel(item = {}, index = 0, role = 'specialist', pageSize = 10) {
const status = String(item.orderStatus || '').trim()
const safeIndex = Number.isSafeInteger(index) && index >= 0 ? index : 0
@@ -54,6 +69,17 @@ export function buildPresignCardModel(item = {}, index = 0, role = 'specialist',
value: formatPresignMoney(item.confirmedDepositAmount),
money: true
},
{ key: 'regionName', label: '采集地区', value: displayText(item.regionName) },
{ key: 'hospitalName', label: '采集医院', value: displayText(item.hospitalName) },
{ key: 'collectorUserName', label: '采集专员', value: displayText(item.collectorUserName) },
{ key: 'regionManagerUserName', label: '采集经理', value: displayText(item.regionManagerUserName) },
{
key: 'sampleCollectTime',
label: '采集时间',
value: formatPresignDateTime(item.sampleCollectTime)
},
{ key: 'payMode', label: '缴费模式', value: payModeLabel(item.payMode) },
{ key: 'remark', label: '备注', value: displayText(item.remark) },
{
key: 'orderStatus',
label: '状态',
@@ -13,6 +13,14 @@ test('provides an executable model for presign card and list behavior', () => {
assert.equal(typeof listModel.mergePresignPage, 'function')
assert.equal(typeof listModel.canLoadPresignPage, 'function')
assert.equal(typeof listModel.presignCardActionEvent, 'function')
assert.equal(typeof listModel.buildPresignListParams, 'function')
})
test('builds list params with Web-equivalent order status filtering', () => {
assert.deepEqual(
listModel.buildPresignListParams({ keyword: ' 张三 ', orderStatus: 'active', page: 2, pageSize: 10 }),
{ keyword: '张三', orderStatus: 'active', page: 2, pageSize: 10 }
)
})
test('builds every Web list field with Task 3 formatters and authorized actions', () => {
@@ -24,6 +32,13 @@ test('builds every Web list field with Task 3 formatters and authorized actions'
expectedDeliveryDate: '2026-12-31',
contractAmount: '28800',
confirmedDepositAmount: '5000.5',
regionName: '合肥区域',
hospitalName: '省妇幼',
collectorUserName: '采集员甲',
regionManagerUserName: '经理乙',
sampleCollectTime: '2026-10-10T09:30:00',
payMode: 'fq',
remark: '客户备注',
orderStatus: 'active',
createTime: '2026-10-11T08:09:10',
allowedActions: ['edit', 'convert', 'delete', 'unsafe-action']
@@ -40,6 +55,13 @@ test('builds every Web list field with Task 3 formatters and authorized actions'
{ key: 'expectedDeliveryDate', label: '预产期', value: '2026-12-31' },
{ key: 'contractAmount', label: '合同金额', value: '¥28,800.00', money: true },
{ key: 'confirmedDepositAmount', label: '到账金额', value: '¥5,000.50', money: true },
{ key: 'regionName', label: '采集地区', value: '合肥区域' },
{ key: 'hospitalName', label: '采集医院', value: '省妇幼' },
{ key: 'collectorUserName', label: '采集专员', value: '采集员甲' },
{ key: 'regionManagerUserName', label: '采集经理', value: '经理乙' },
{ key: 'sampleCollectTime', label: '采集时间', value: '2026-10-10 09:30:00' },
{ key: 'payMode', label: '缴费模式', value: '分期' },
{ key: 'remark', label: '备注', value: '客户备注' },
{ key: 'orderStatus', label: '状态', value: '进行中', tone: 'warning' },
{ key: 'createTime', label: '创建时间', value: '2026-10-11 08:09:10' }
])
@@ -0,0 +1,38 @@
/**
* 选择器弹层选项过滤。
* remote 检索会异步替换候选池,输入时应仍做本地过滤,否则合并了 staff 的列表会看起来「搜了没变化」。
*/
export function filterSelectOptions(options, keyword) {
const list = Array.isArray(options) ? options : []
const search = String(keyword || '').trim().toLowerCase()
if (!search) return list
return list.filter((option) => {
const text = String(option?.text ?? option?.label ?? option?.name ?? '')
return text.toLowerCase().includes(search)
})
}
/**
* 采集专员 / 采集经理候选。
* 有检索词时只用远程结果(+ 当前选中回显),避免把地区 staff 全量拼回去抵消过滤。
*/
export function buildPersonOptions(
remoteOptions,
hydratedOptions,
selectedValue,
selectedLabel,
{ keyword = '', mergeReferenceOptions } = {}
) {
if (typeof mergeReferenceOptions !== 'function') {
throw new Error('mergeReferenceOptions is required')
}
const searching = Boolean(String(keyword || '').trim())
const scoped = searching
? [...(remoteOptions || [])]
: [...(remoteOptions || []), ...(hydratedOptions || [])]
return mergeReferenceOptions(
scoped,
{ value: selectedValue, label: selectedLabel },
{ fallbackLabel: '未知人员' }
)
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mergeReferenceOptions } from '../reference-options.js'
import { buildPersonOptions, filterSelectOptions } from './presign-select-filter.js'
test('filterSelectOptions filters by label even when caller uses remote search', () => {
const options = [
{ value: 'u1', text: '张三' },
{ value: 'u2', text: '李四' },
{ value: 'u3', label: '张伟' }
]
assert.deepEqual(
filterSelectOptions(options, '张').map((item) => item.value),
['u1', 'u3']
)
assert.deepEqual(filterSelectOptions(options, ''), options)
assert.deepEqual(filterSelectOptions(options, ' '), options)
})
test('buildPersonOptions keeps staff pool when keyword empty', () => {
const out = buildPersonOptions(
[{ value: 'remote-1', label: '远程甲' }],
[{ value: 'staff-1', label: '地区乙' }],
'staff-1',
'地区乙',
{ keyword: '', mergeReferenceOptions }
)
assert.deepEqual(
out.map((item) => item.value),
['remote-1', 'staff-1']
)
})
test('buildPersonOptions drops hydrated staff while searching so remote filter is visible', () => {
const out = buildPersonOptions(
[{ value: 'hit-1', label: '张三' }],
[
{ value: 'staff-1', label: '李四' },
{ value: 'staff-2', label: '王五' }
],
'staff-1',
'李四',
{ keyword: '张', mergeReferenceOptions }
)
assert.deepEqual(
out.map((item) => ({ value: item.value, label: item.label })),
[
{ value: 'hit-1', label: '张三' },
{ value: 'staff-1', label: '李四' }
]
)
})
@@ -0,0 +1,112 @@
/** 协议单客户档案回填 + 身份证出生年月 + 主档关联(对齐 Web ProtocolFormDialog) */
export function calcBirthYmFromIdCard(idCard) {
const raw = String(idCard || '').trim()
if (/^\d{17}[\dXx]$/.test(raw)) {
return raw.substring(6, 12)
}
if (/^\d{15}$/.test(raw)) {
const yy = raw.substring(6, 8)
const mm = raw.substring(8, 10)
const prefix = parseInt(yy, 10) > 30 ? '19' : '20'
return `${prefix}${yy}${mm}`
}
if (!raw) return ''
return ''
}
function fillIfEmpty(current, incoming) {
const cur = String(current || '').trim()
const next = String(incoming || '').trim()
if (cur || !next) return undefined
return next
}
/**
* 手改姓名与已关联档案名不一致时,清空对应客户 id。
* @returns {Record<string, string>|null} patch or null
*/
export function clearCustomerLinkIfNameChanged(formSlice, role, nextName) {
const slice = formSlice || {}
const name = String(nextName || '').trim()
if (role === 'father') {
const linkedId = String(slice.fatherCustomerId || '').trim()
const linkedName = String(slice._linkedFatherCustomerName || slice.fatherName || '').trim()
if (!linkedId) return null
if (name === linkedName) return null
return { fatherCustomerId: '', _linkedFatherCustomerName: '' }
}
const linkedId = String(slice.customerId || '').trim()
const linkedName = String(slice._linkedMotherCustomerName || slice.motherName || '').trim()
if (!linkedId) return null
if (name === linkedName) return null
return { customerId: '', _linkedMotherCustomerName: '' }
}
/**
* @param {'mother'|'father'} role
* @returns {Record<string, string>} patch to merge into customer form slice
*/
export function applyCustomerSelect(formSlice, customer, role) {
const name = String(customer?.customerName || '').trim()
const phone = String(customer?.phoneNo || '').trim()
const idCard = String(customer?.idCardNo || '').trim()
const customerId = String(customer?.id || '').trim()
const slice = formSlice || {}
if (role === 'father') {
return {
fatherCustomerId: customerId,
fatherName: name,
fatherPhone: phone,
fatherIdCard: idCard,
_linkedFatherCustomerName: name
}
}
const patch = {
customerId,
motherName: name,
motherPhone: phone,
motherIdCard: idCard,
_linkedMotherCustomerName: name
}
const fromId = calcBirthYmFromIdCard(idCard)
if (fromId) {
patch.motherBirthYm = fromId
} else {
const birthYm = fillIfEmpty(slice.motherBirthYm, customer?.birthYm)
if (birthYm !== undefined) patch.motherBirthYm = birthYm
}
const ethnic = fillIfEmpty(slice.motherEthnic, customer?.ethnic)
if (ethnic !== undefined) patch.motherEthnic = ethnic
const email = fillIfEmpty(slice.motherEmail, customer?.email)
if (email !== undefined) patch.motherEmail = email
const address = fillIfEmpty(slice.motherAddress, customer?.address)
if (address !== undefined) patch.motherAddress = address
return patch
}
/** 详情回填后初始化 linked 名,便于编辑改名清关联 */
export function initLinkedCustomerNames(customer) {
const slice = { ...(customer || {}) }
if (String(slice.customerId || '').trim()) {
slice._linkedMotherCustomerName = String(slice.motherName || '').trim()
}
if (String(slice.fatherCustomerId || '').trim()) {
slice._linkedFatherCustomerName = String(slice.fatherName || '').trim()
}
return slice
}
/** 提交前去掉 UI 临时字段 */
export function stripLinkedCustomerMeta(customer) {
if (!customer || typeof customer !== 'object') return customer
const next = { ...customer }
delete next._linkedMotherCustomerName
delete next._linkedFatherCustomerName
return next
}
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyCustomerSelect,
calcBirthYmFromIdCard,
clearCustomerLinkIfNameChanged,
initLinkedCustomerNames,
stripLinkedCustomerMeta
} from './protocol-customer-link.js'
test('calcBirthYmFromIdCard parses 18-digit and 15-digit ids', () => {
assert.equal(calcBirthYmFromIdCard('11010119900307421X'), '199003')
assert.equal(calcBirthYmFromIdCard('110101900307421'), '199003')
assert.equal(calcBirthYmFromIdCard('110101050307421'), '200503')
})
test('calcBirthYmFromIdCard clears on empty and ignores invalid', () => {
assert.equal(calcBirthYmFromIdCard(''), '')
assert.equal(calcBirthYmFromIdCard(' '), '')
assert.equal(calcBirthYmFromIdCard('123'), '')
assert.equal(calcBirthYmFromIdCard('abcdefghijklmnopq'), '')
})
test('applyCustomerSelect mother binds customerId and fills empty extras', () => {
const patch = applyCustomerSelect(
{
motherName: '旧名',
motherPhone: '111',
motherIdCard: 'old',
motherBirthYm: '',
motherEthnic: '',
motherEmail: 'keep@x.com',
motherAddress: ''
},
{
id: 'c-mother-1',
customerName: '张敏',
phoneNo: '13800000000',
idCardNo: '11010119900307421X',
birthYm: '199003',
ethnic: '汉族',
email: 'new@x.com',
address: '合肥'
},
'mother'
)
assert.equal(patch.customerId, 'c-mother-1')
assert.equal(patch._linkedMotherCustomerName, '张敏')
assert.equal(patch.motherName, '张敏')
assert.equal(patch.motherPhone, '13800000000')
assert.equal(patch.motherIdCard, '11010119900307421X')
assert.equal(patch.motherBirthYm, '199003')
assert.equal(patch.motherEthnic, '汉族')
assert.equal(patch.motherAddress, '合肥')
assert.equal(Object.prototype.hasOwnProperty.call(patch, 'motherEmail'), false)
})
test('applyCustomerSelect father binds fatherCustomerId', () => {
const patch = applyCustomerSelect(
{ fatherName: '', fatherPhone: '', fatherIdCard: '' },
{
id: 'c-father-1',
customerName: '李强',
phoneNo: '13900000000',
idCardNo: 'FID-FATHER-001'
},
'father'
)
assert.deepEqual(patch, {
fatherCustomerId: 'c-father-1',
fatherName: '李强',
fatherPhone: '13900000000',
fatherIdCard: 'FID-FATHER-001',
_linkedFatherCustomerName: '李强'
})
})
test('clearCustomerLinkIfNameChanged clears when name drifts', () => {
const mother = clearCustomerLinkIfNameChanged(
{ customerId: 'c1', motherName: '张敏', _linkedMotherCustomerName: '张敏' },
'mother',
'张敏改'
)
assert.deepEqual(mother, { customerId: '', _linkedMotherCustomerName: '' })
assert.equal(
clearCustomerLinkIfNameChanged(
{ customerId: 'c1', _linkedMotherCustomerName: '张敏' },
'mother',
'张敏'
),
null
)
})
test('init and strip linked meta', () => {
const init = initLinkedCustomerNames({
customerId: 'c1',
motherName: '张敏',
fatherCustomerId: 'c2',
fatherName: '李强'
})
assert.equal(init._linkedMotherCustomerName, '张敏')
assert.equal(init._linkedFatherCustomerName, '李强')
const stripped = stripLinkedCustomerMeta(init)
assert.equal(stripped.customerId, 'c1')
assert.equal(Object.prototype.hasOwnProperty.call(stripped, '_linkedMotherCustomerName'), false)
})
@@ -0,0 +1,303 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { getMarketModule } from './module-registry.js'
import {
canUseProtocolScheduleAction,
canUseProtocolTransferAction
} from './protocol-sample-transfer.js'
function source(relativePath) {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
function cardLabels(card) {
return [
card.title,
...(card.fields || []).map((field) => field.label),
...(card.cards || []).flatMap(cardLabels)
]
}
test('protocol detail exposes reception qc production storage and release information', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'cell_storage',
basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
samples: [{
id: 'sample-1',
sampleCode: 'S-1',
sampleTypeName: '脐带血',
sampleStatus: 'yrk',
remark: '细胞存储样本备注',
sampleReception: {
receptionTime: '2026-10-01 08:00:00',
handlerName: '质检员',
receiveTemperature: '4.2',
acceptConclusionLabel: '接收',
labStorageCode: 'LAB-1',
checkResults: [{ itemName: '包装完整', checked: true }],
remark: '接收正常',
receiveAttachmentFiles: [{ fileUrl: 'receive.pdf', fileName: 'receive.pdf' }]
},
productionReception: {
processTime: '2026-10-01', handlerName: '生产员', remark: '已处理'
},
qcInfo: {
workOrderNo: 'QC-1', qcBizTypeLabel: '入库质检', workOrderStatusLabel: '已完成',
tasks: [{ taskNo: 'QT-1', itemName: '活率', taskStatusLabel: '已完成', conclusionLabel: '通过', completedTime: '2026-10-02', attachmentFiles: [{ fileUrl: 'qc.pdf' }] }]
},
processQcInfoList: [{ workOrderNo: 'PQC-1', workOrderStatusLabel: '进行中' }],
productionInfo: {
workOrderNo: 'PR-1', workOrderStatusLabel: '已完成',
tasks: [{ taskNo: 'PT-1', taskName: '分离', taskStatusLabel: '已完成', conclusionLabel: '通过', startedTime: '2026-10-02', completedTime: '2026-10-03' }]
},
storageReleaseInfo: {
prodReviewConclusionLabel: '通过', qcReviewConclusionLabel: '通过', storageStatusLabel: '已入库',
prodReviewByName: '生产审核员', prodReviewTime: '2026-10-03', qcReviewByName: '质检审核员', qcReviewTime: '2026-10-03',
qcReviewAttachments: 'release-qc.pdf', prodReviewAttachments: 'release-prod.pdf'
}
}]
})
const sample = sections.find((section) => section.key === 'samples').cards[0]
assert.equal(sample.fields.find((field) => field.label === '备注').value, '细胞存储样本备注')
const labels = [...sample.fields.map((field) => field.label), ...sample.cards.flatMap(cardLabels)]
for (const expected of [
'质检接收', '接收时间', '接收附件', '生产接收', '处理时间',
'门控质检', '工单号', '过程质检 1', '生产信息', '生产任务 1',
'入库放行', '生产结论', '质检结论', '入库状态', '质检附件', '生产附件'
]) assert.ok(labels.includes(expected), `missing ${expected}`)
})
test('protocol lifecycle keeps qc targets results batches and production inputs outputs', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'research_donation',
basic: { donationNo: 'D-1', donationStatus: 'sxz' },
samples: [{
id: 'sample-rich', sampleCode: 'D-S-1',
qcInfo: {
workOrderNo: 'QC-1', cellProductCode: 'CP-1', cellProductName: '细胞产品',
productionWorkOrderNo: 'PR-1', triggerSourceLabel: '样本接收',
inspectionBatches: [{ id: 'b1', batchNo: 'B-1', batchName: '第一批', assigneeUserNames: '质检员', taskCount: 1, completedTaskCount: 1 }],
tasks: [{ taskNo: 'QT-1', results: [{ paramName: '活率', paramUnit: '%', paramValue: '98' }] }]
},
productionInfo: {
workOrderNo: 'PR-1',
tasks: [{
taskNo: 'PT-1',
inputs: [{ inputType: '原料', inputCode: 'IN-1', inputName: '脐带血' }],
outputs: [{ outputType: '产物', outputCode: 'OUT-1', outputName: '细胞制剂' }]
}]
}
}]
})
const sample = sections.find((section) => section.key === 'samples').cards[0]
const labels = [...sample.cards.flatMap(cardLabels)]
for (const expected of [
'质检对象编码', '质检对象名称', '生产工单号', '触发来源',
'检测批次 1', '批次编号', '负责人', '检测结果 1', '参数值',
'投入物 1', '投入物编码', '产出物 1', '产出物编码'
]) assert.ok(labels.includes(expected), `missing ${expected}`)
})
test('donation detail shows audit history and convert-to-storage status history', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'research_donation',
basic: {
donationNo: 'D-1', donationStatus: 'zckc',
convertTime: '2026-10-10 10:00:00', convertDesc: '已转细胞存储'
},
audits: [{ id: 'a1', auditStatus: 'shtg', auditorUserName: '审核员', auditRemark: '通过' }]
})
const audit = sections.find((section) => section.key === 'audits')
assert.equal(audit.cards.length, 1)
const status = sections.find((section) => section.key === 'statusChange')
assert.deepEqual(status.fields.map((field) => field.label), ['转细胞存储时间', '转细胞存储描述'])
})
test('v1 protocol detail renders readonly payment details with backend transfer labels', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'cell_storage',
basic: { protocolNo: 'P-V1', protocolStatus: 'sxz' },
paymentModelVersion: 1,
payment: { payMode: 'fq', contractAmount: 1000 },
paymentDetails: [
{
id: 'pd-1', installmentNo: 1, dueAmount: 1000, transferAmount: 600,
installmentDueDate: '20261031', transferTime: '2026-10-13 12:00:00',
transferType: 'wx', transferTypeLabel: '微信支付', transferAttachments: 'proof.pdf',
payStatus: 'yjf', financeRemark: '已确认', remark: '第一笔'
},
{
id: 'pd-2', installmentNo: 1, dueAmount: 1000, transferAmount: 200,
installmentDueDate: '20261031', transferTime: '2026-10-14 12:00:00',
transferType: 'zfb', transferTypeLabel: '支付宝', payStatus: 'jfqrz'
}
]
})
const payment = sections.find((section) => section.key === 'payment')
assert.equal(payment.cards.length, 1)
assert.equal(payment.cards[0].cards.length, 2)
assert.equal(payment.cards[0].fields.find((field) => field.label === '确认实缴金额').value, '600.00')
assert.equal(payment.cards[0].fields.find((field) => field.label === '欠缴金额').value, '400.00')
assert.equal(payment.cards[0].cards[0].fields.find((field) => field.label === '转账方式').value, '微信支付')
for (const expected of ['期次', '应缴金额', '确认实缴金额', '欠缴金额', '应缴日期', '期状态']) {
assert.ok(payment.cards[0].fields.some((field) => field.label === expected), `missing ${expected}`)
}
for (const expected of ['转账方式', '转账金额', '转账时间', '转账凭证', '备注', '财务备注', '状态']) {
assert.ok(payment.cards[0].cards[0].fields.some((field) => field.label === expected), `missing transfer ${expected}`)
}
})
test('sample card includes remark, full reception checks and source-specific production fields', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'research_donation',
basic: { donationNo: 'D-1', donationStatus: 'sxz' },
samples: [{
id: 's1', sampleCode: 'S-1', sampleTypeName: '脐带间充质干细胞', remark: '样本备注',
sampleReception: {
checkResults: [
{ itemName: '包装完整', checked: true },
{ itemName: '标签清晰', checked: false }
]
},
productionReception: {
inoculationAmount: '1ml', cryopreservationTime: '2026-10-13',
storageTotalTubes: 10, originalBloodWeightG: 20
}
}]
})
const sample = sections.find((section) => section.key === 'samples').cards[0]
assert.equal(sample.fields.find((field) => field.label === '备注').value, '样本备注')
const reception = sample.cards.find((card) => card.title === '质检接收')
assert.equal(reception.fields.find((field) => field.label === '检查项').value, '包装完整:是、标签清晰:否')
const production = sample.cards.find((card) => card.title === '生产接收')
assert.deepEqual(
production.fields.map((field) => field.label),
['处理时间', '处理人', '接种量', '冻存时间', '备注']
)
})
test('protocol detail exposes complete deposit and installment transfer fields', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'cell_storage',
basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
depositTransfers: [{
id: 'deposit-1', transferType: 'wx', transferAmount: 1000,
transferTime: '2026-10-01', attachments: [{ fileUrl: 'deposit.pdf' }],
remark: '意向金', financeRemark: '已核对', payStatus: 'yjf'
}],
samplePayments: [{
id: 'pay-1', sampleCode: 'S-1', payMode: 'fq', contractAmount: 10000,
schedules: [{
id: 'schedule-1', scheduleNo: 1, dueAmount: 5000, dueDate: '2026-11-01',
paidAmount: 3000, arrearsAmount: 2000, scheduleStatus: 'open',
transfers: [{
id: 'transfer-1', transferType: 'wx', transferAmount: 3000,
transferTime: '2026-10-02', transferAttachments: 'proof.pdf',
remark: '首期', financeRemark: '待复核', payStatus: 'jfqrz'
}]
}]
}]
})
const deposit = sections.find((section) => section.key === 'deposit').cards[0]
assert.deepEqual(deposit.fields.map((field) => field.label), [
'转账金额', '转账时间', '转账方式', '转账凭证', '备注', '财务备注', '状态'
])
const payment = sections.find((section) => section.key === 'payment').cards[0]
const schedule = payment.cards[0]
assert.deepEqual(schedule.fields.map((field) => field.label), [
'期次', '应缴金额', '实缴金额', '欠缴金额', '应缴日期', '状态'
])
assert.deepEqual(schedule.cards[0].fields.map((field) => field.label), [
'转账方式', '转账金额', '转账时间', '转账凭证', '备注', '财务备注', '状态'
])
})
test('deposit and installment transfers prefer backend dictionary labels and fall back to code', () => {
const sections = getMarketModule('protocol').detailSections({
source: 'cell_storage',
basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
depositTransfers: [{ transferType: 'wx', transferTypeLabel: '微信支付' }],
samplePayments: [{ sampleCode: 'S-1', schedules: [{
scheduleNo: 1,
transfers: [
{ transferType: 'zfb', transferTypeLabel: '支付宝' },
{ transferType: 'unknown-code' }
]
}] }]
})
const deposit = sections.find((section) => section.key === 'deposit').cards[0]
assert.equal(deposit.fields.find((field) => field.label === '转账方式').value, '微信支付')
const transfers = sections.find((section) => section.key === 'payment').cards[0].cards[0].cards
assert.equal(transfers[0].fields.find((field) => field.label === '转账方式').value, '支付宝')
assert.equal(transfers[1].fields.find((field) => field.label === '转账方式').value, 'unknown-code')
})
test('detail renderer recursively renders transfer and task subcards', () => {
const section = source('./components/MarketDetailSection.vue')
const nested = source('./components/MarketDetailNestedCard.vue')
assert.match(section, /<MarketDetailNestedCard/)
assert.match(nested, /<MarketDetailNestedCard/)
assert.match(nested, /card\.cards/)
})
test('protocol transfer controls require backend nested capability and frontend whitelist', () => {
assert.equal(canUseProtocolScheduleAction({ allowedActions: ['register-transfer'] }, 'register-transfer'), true)
assert.equal(canUseProtocolScheduleAction({ allowedActions: [] }, 'register-transfer'), false)
assert.equal(canUseProtocolTransferAction({ allowedActions: ['edit-transfer'] }, 'edit-transfer'), true)
assert.equal(canUseProtocolTransferAction({ allowedActions: ['confirm'] }, 'confirm'), false)
assert.equal(canUseProtocolTransferAction({ allowedActions: ['refund'] }, 'refund'), false)
})
test('protocol transfer UI calls DELETE for persisted rows without reloading form drafts', () => {
const panel = source('./components/ProtocolSamplePaymentPanel.vue')
const form = source('../views/market/MarketModuleForm.vue')
assert.doesNotMatch(form, /:can-manage-transfers="true"/)
assert.match(panel, /performMarketAction\(paymentContext\(\), 'delete-payment-transfer'/)
assert.match(panel, /title:\s*'提交财务确认'/)
assert.doesNotMatch(panel, /emit\('refresh'\)/)
assert.doesNotMatch(form, /@refresh="handleTransferRefresh"/)
assert.match(form, /@transfer-dirty-change="handleTransferDirtyChange"/)
assert.match(form, /protocolTransferSaveBlockMessage\(transferDirty\.value\)/)
assert.match(form, /showToast\(transferMessage/)
assert.match(panel, /emit\('transfer-dirty-change'/)
})
test('protocol uploads use the Web 20MB rich attachment policy', () => {
const protocol = source('./modules/protocol.js')
const formField = source('./components/MarketFormField.vue')
assert.match(protocol, /attachmentPolicy:\s*'protocol'/)
assert.match(formField, /PROTOCOL_ATTACHMENT_MAX_BYTES/)
assert.match(formField, /PROTOCOL_ATTACHMENT_ACCEPT/)
})
test('protocol list and nested payment actions keep 44px mobile touch targets', () => {
const styles = source('../styles/market.css')
const panel = source('./components/ProtocolSamplePaymentPanel.vue')
assert.match(styles, /\.market-list-card__actions \.van-button\s*\{[^}]*min-height:\s*44px;/)
assert.match(panel, /\.protocol-sample-pay__transfer-actions[\s\S]*?\.van-button\s*\{[^}]*min-height:\s*44px;/)
})
test('protocol form edits v1 with a dedicated mobile panel and never upgrades it while rendering', () => {
const form = source('../views/market/MarketModuleForm.vue')
const panel = source('./components/ProtocolV1PaymentPanel.vue')
assert.match(form, /collectAllowedActions/)
assert.match(form, /import ProtocolV1PaymentPanel/)
assert.match(form, /protocolPaymentModelVersion\(\)\s*<\s*2/)
assert.match(form, /protocolPaymentModelVersion\(\)\s*>=\s*2/)
assert.match(form, /serializeLegacyPaymentState/)
assert.match(form, /paymentDetails:\s*legacyPayment\?\.details\s*\|\|\s*detail\.paymentDetails\s*\|\|\s*\[\]/)
assert.doesNotMatch(form, /paymentModelVersion\s*=\s*2/)
assert.doesNotMatch(form, /paymentModelVersion:\s*2/)
assert.match(panel, /<OaMoneyField/)
assert.match(panel, /<MarketAttachmentCards/)
assert.match(panel, /PROTOCOL_ATTACHMENT_MAX_BYTES/)
assert.match(panel, /evaluateLegacyPaymentPlanChange/)
assert.match(panel, /showConfirmDialog/)
assert.match(panel, /showToast/)
assert.match(panel, /#0a9f6d/)
assert.match(panel, /min-height:\s*44px/)
assert.doesNotMatch(panel, /<table|<el-table/)
assert.doesNotMatch(panel, /finance|refund/i)
})
@@ -1,5 +1,7 @@
/** 分期日期/均摊金额(与 web-admin protocol-payment-installment 对齐) */
import { minorUnitsToMoney, moneyToMinorUnits } from './money.js'
const DAYS_PER_MONTH = 30
export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
@@ -9,7 +11,11 @@ export function addMonthsYmd(dateStr, months) {
const y = parseInt(compact.slice(0, 4), 10)
const m = parseInt(compact.slice(4, 6), 10) - 1
const d = parseInt(compact.slice(6, 8), 10)
const date = new Date(y, m + months, d)
const monthIndex = m + Number(months)
const targetYear = y + Math.floor(monthIndex / 12)
const targetMonth = ((monthIndex % 12) + 12) % 12
const lastDay = new Date(targetYear, targetMonth + 1, 0).getDate()
const date = new Date(targetYear, targetMonth, Math.min(d, lastDay))
const yy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
@@ -59,14 +65,16 @@ export function calcFqInstallmentDueDate(
export function defaultInstallmentAmounts(contractAmount, count) {
if (contractAmount == null || !count || count < 1) return []
const totalCents = Math.round(Number(contractAmount) * 100)
const baseCents = Math.floor(totalCents / count)
const totalCents = moneyToMinorUnits(contractAmount)
if (totalCents == null || totalCents < 0n) return []
const divisor = BigInt(count)
const baseCents = totalCents / divisor
const amounts = []
let sumCents = 0
let sumCents = 0n
for (let i = 0; i < count; i++) {
if (i === count - 1) amounts.push((totalCents - sumCents) / 100)
if (i === count - 1) amounts.push(minorUnitsToMoney(totalCents - sumCents))
else {
amounts.push(baseCents / 100)
amounts.push(minorUnitsToMoney(baseCents))
sumCents += baseCents
}
}
@@ -0,0 +1,55 @@
/** 与 Web sample-production-reception-fields 保持同一字段 profile。 */
const FIELD_DEFS = Object.freeze({
originalBloodWeightG: { key: 'originalBloodWeightG', label: '原血重量/g' },
hesVolumeMl: { key: 'hesVolumeMl', label: 'HES用量/ml' },
bloodType: { key: 'bloodType', label: '血型' },
infectiousFourTestResult: { key: 'infectiousFourTestResult', label: '传染四项检测(检测卡)' },
enrichedBloodWeightG: { key: 'enrichedBloodWeightG', label: '富集后血量/g' },
cryopreservationVolumeMl: { key: 'cryopreservationVolumeMl', label: '冻存液用量/ml' },
inoculationAmount: { key: 'inoculationAmount', label: '接种量' },
cryopreservationTime: { key: 'cryopreservationTime', label: '冻存时间' },
storageTotalTubes: { key: 'storageTotalTubes', label: '储存总量(管)' },
availableCellCount1e7: { key: 'availableCellCount1e7', label: '可提供细胞数量/*10E7' },
originalBloodVolumeMl: { key: 'originalBloodVolumeMl', label: '原血体积/ml' },
nucleatedCellCount1e8: { key: 'nucleatedCellCount1e8', label: '有核细胞数/10E8' }
})
const PROFILE_KEYS = Object.freeze({
'cell_storage|围产期造血干细胞': [
'originalBloodWeightG', 'hesVolumeMl', 'bloodType',
'infectiousFourTestResult', 'enrichedBloodWeightG', 'cryopreservationVolumeMl'
],
'cell_storage|脐带间充质干细胞': [
'inoculationAmount', 'cryopreservationTime', 'storageTotalTubes', 'availableCellCount1e7'
],
'research_donation|脐带间充质干细胞': ['inoculationAmount', 'cryopreservationTime'],
'research_donation|围产期造血干细胞': ['originalBloodVolumeMl', 'nucleatedCellCount1e8']
})
export function resolveProductionReceptionFields(sourceType, sampleTypeName) {
const source = sourceType === 'research_donation' ? 'research_donation' : 'cell_storage'
const profile = PROFILE_KEYS[`${source}|${String(sampleTypeName || '').trim()}`] || []
return profile.map((key) => FIELD_DEFS[key])
}
export function formatProductionReceptionDate(value) {
const text = String(value || '').trim()
if (!text) return '—'
if (/^\d{8}$/.test(text)) {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`
}
return text.length >= 10 ? text.slice(0, 10) : text
}
export function productionReceptionFieldValue(field, record) {
const raw = record?.[field?.key]
if (raw == null || raw === '') return '—'
if (field?.key === 'infectiousFourTestResult') {
return String(record?.infectiousFourTestResultLabel || ({ zc: '正常', bzc: '不正常' }[raw]) || raw)
}
if (field?.key === 'cryopreservationTime') {
return formatProductionReceptionDate(raw)
}
return String(raw)
}
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
formatProductionReceptionDate,
productionReceptionFieldValue,
resolveProductionReceptionFields
} from './protocol-production-reception.js'
test('matches Web production reception profiles by source and sample type', () => {
assert.deepEqual(
resolveProductionReceptionFields('cell_storage', '围产期造血干细胞').map((field) => field.key),
[
'originalBloodWeightG', 'hesVolumeMl', 'bloodType',
'infectiousFourTestResult', 'enrichedBloodWeightG', 'cryopreservationVolumeMl'
]
)
assert.deepEqual(
resolveProductionReceptionFields('research_donation', '脐带间充质干细胞').map((field) => field.key),
['inoculationAmount', 'cryopreservationTime']
)
assert.deepEqual(
resolveProductionReceptionFields('research_donation', '围产期造血干细胞').map((field) => field.key),
['originalBloodVolumeMl', 'nucleatedCellCount1e8']
)
assert.deepEqual(
resolveProductionReceptionFields('cell_storage', '脐带间充质干细胞').map((field) => field.key),
['inoculationAmount', 'cryopreservationTime', 'storageTotalTubes', 'availableCellCount1e7']
)
})
test('unknown sample type exposes no profile-specific production fields', () => {
assert.deepEqual(resolveProductionReceptionFields('cell_storage', '未知样本'), [])
})
test('production reception dates match Web YYYY-MM-DD display', () => {
assert.equal(formatProductionReceptionDate('2026-10-14 09:30:00'), '2026-10-14')
const field = resolveProductionReceptionFields(
'research_donation',
'脐带间充质干细胞'
).find((item) => item.key === 'cryopreservationTime')
assert.equal(
productionReceptionFieldValue(field, { cryopreservationTime: '2026-10-13T12:00:00' }),
'2026-10-13'
)
})
@@ -5,26 +5,36 @@ import {
defaultInstallmentAmounts,
DEFAULT_INSTALLMENT_ADVANCE_DAYS
} from './protocol-payment-installment.js'
import {
addMoney,
compareMoney,
minorUnitsToMoney,
moneyToMinorUnits,
normalizeMoney,
subtractMoney
} from './money.js'
export { calcFqInstallmentDueDate, DEFAULT_INSTALLMENT_ADVANCE_DAYS }
export function sumSampleContractAmounts(payments) {
return (payments || []).reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
return addMoney((payments || []).map((payment) => payment?.contractAmount))
}
export function parsePreferentialPrice(sample) {
if (sample == null) return null
const raw = sample.price
if (raw == null || raw === '') return null
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) return null
return Math.round(n * 100) / 100
const cents = moneyToMinorUnits(raw)
if (cents == null || cents < 0n) return null
return minorUnitsToMoney(cents)
}
export function protocolTotalMismatchHint(total, sampleSum) {
if (total == null || Number.isNaN(Number(total))) return null
if (Math.abs(Number(total) - sampleSum) > 0.015) {
return `协议总金额 ${Number(total).toFixed(2)} 与样本合同合计 ${sampleSum.toFixed(2)} 不一致,请核对`
const totalText = normalizeMoney(total)
const sampleText = normalizeMoney(sampleSum)
if (totalText == null || sampleText == null) return null
if (compareMoney(totalText, sampleText) !== 0) {
return `协议总金额 ${totalText} 与样本合同合计 ${sampleText} 不一致,请核对`
}
return null
}
@@ -42,8 +52,8 @@ export function sampleScheduleRequiredFieldsHint(pay) {
const prefix = code ? `样本 ${code} ` : ''
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i]
const amt = Number(s.dueAmount)
if (!Number.isFinite(amt) || amt <= 0) {
const amt = moneyToMinorUnits(s.dueAmount)
if (amt == null || amt <= 0n) {
return `${prefix}第 ${i + 1} 期应缴金额必须填写`
}
if (!String(s.dueDate || '').trim()) {
@@ -54,34 +64,33 @@ export function sampleScheduleRequiredFieldsHint(pay) {
}
export function sampleScheduleDuesMismatchHint(pay) {
const contract = Number(pay.contractAmount)
if (!Number.isFinite(contract)) return null
const contract = normalizeMoney(pay.contractAmount)
if (contract == null) return null
const schedules = activeSchedules(pay)
if (!schedules.length) return null
const sum = schedules.reduce((acc, x) => acc + (Number(x.dueAmount) || 0), 0)
if (Math.abs(contract - sum) > 0.015) {
const sum = addMoney(schedules.map((schedule) => schedule?.dueAmount))
if (compareMoney(contract, sum) !== 0) {
const code = String(pay.sampleCode || '').trim()
const prefix = code ? `样本 ${code} ` : ''
return `${prefix}合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存`
return `${prefix}合同金额 ${contract} 与各期应缴合计 ${sum} 不一致,请调整后再保存`
}
return null
}
function sumTransferAmountByStatuses(transfers, statuses) {
return (transfers || [])
return addMoney((transfers || [])
.filter((t) => statuses.has(String(t.payStatus || '')))
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
.map((transfer) => transfer?.transferAmount))
}
export function scheduleFinanceConfirmedPaidArrears(dueAmount, transfers) {
const paid =
Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
const paid = sumTransferAmountByStatuses(transfers, new Set(['yjf']))
return { paid, arrears: subtractMoney(dueAmount, paid) }
}
export function schedulePaidArrears(dueAmount, transfers) {
const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(dueAmount, transfers)
return { paid, arrears: arrears > 0 ? arrears : 0 }
return { paid, arrears: compareMoney(arrears, 0) > 0 ? arrears : '0.00' }
}
export function resolveSampleContractAmount(sample) {
@@ -98,16 +107,48 @@ export function resolvePerSamplePaymentDefaults(sample, payMode) {
export function shouldReplaceContractWithPreferentialPrice(contractAmount, preferentialPrice) {
if (preferentialPrice == null) return false
if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
if (normalizeMoney(contractAmount) == null) return true
return false
}
/**
* 优惠价同步合同:有 yjf 锁定不改;force 或合同为空时写合同;
* dj 重建应缴;fq/fgd 只改合同。
*/
export function applyPreferentialPriceToPayment(pay, sample, force = false) {
if (samplePaymentHasConfirmedTransfer(pay)) return pay
const price = parsePreferentialPrice(sample)
if (price == null) return pay
if (!force && !shouldReplaceContractWithPreferentialPrice(pay.contractAmount, price)) {
return pay
}
if (compareMoney(pay.contractAmount, price) === 0) return pay
const mode = String(pay.payMode || 'dj')
const next = { ...pay, contractAmount: price }
if (mode === 'fq' || mode === 'fgd') return next
return rebuildSampleSchedules(next)
}
/** 协议总金额是否仍跟随样本合同合计(差 ≤0.015 视为一致) */
export function shouldFollowProtocolTotal(total, sampleSum) {
if (normalizeMoney(total) == null) return true
if (compareMoney(sampleSum, 0) <= 0) return true
return compareMoney(total, sampleSum) === 0
}
/** follow 时返回合计(>0)或 null;不 follow 时返回原 total */
export function nextProtocolTotalFromSamples(follow, sampleSum, currentTotal) {
if (!follow) return currentTotal
const sum = normalizeMoney(sampleSum)
return sum != null && compareMoney(sum, 0) > 0 ? sum : null
}
export function parseInstallmentRatios(raw) {
if (Array.isArray(raw)) return raw.map((v) => Number(v) || 0)
if (Array.isArray(raw)) return raw.map((value) => normalizeMoney(value) || '0.00')
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.map((v) => Number(v) || 0)
if (Array.isArray(parsed)) return parsed.map((value) => normalizeMoney(value) || '0.00')
} catch {
/* ignore */
}
@@ -115,10 +156,6 @@ export function parseInstallmentRatios(raw) {
return []
}
function roundMoney(n) {
return Math.round(n * 100) / 100
}
export function planFqToFgdMerge(schedules) {
const schList = (schedules || [])
.filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
@@ -127,15 +164,15 @@ export function planFqToFgdMerge(schedules) {
const arrearsOf = (s) => {
const { arrears } = scheduleFinanceConfirmedPaidArrears(
Number(s.dueAmount) || 0,
s.dueAmount,
s.transfers || []
)
return arrears > 0 ? arrears : 0
return compareMoney(arrears, 0) > 0 ? arrears : '0.00'
}
let target
for (const s of schList) {
if (arrearsOf(s) > 0) {
if (compareMoney(arrearsOf(s), 0) > 0) {
target = s
break
}
@@ -153,12 +190,12 @@ export function planFqToFgdMerge(schedules) {
}
}
const remain = roundMoney(schList.reduce((sum, s) => sum + arrearsOf(s), 0))
const remain = addMoney(schList.map(arrearsOf))
const { paid: targetConfirmed } = scheduleFinanceConfirmedPaidArrears(
Number(target.dueAmount) || 0,
target.dueAmount,
target.transfers || []
)
const newDueAmount = roundMoney(targetConfirmed + remain)
const newDueAmount = addMoney([targetConfirmed, remain])
const softDeleteScheduleIds = later.map((s) => String(s.id || '').trim()).filter(Boolean)
const softDeleteScheduleNos = later.map((s) => Number(s.scheduleNo || 0))
const softNoSet = new Set(softDeleteScheduleNos)
@@ -209,8 +246,8 @@ export function applyConsolidateRemainingToPayment(pay, targetMode) {
installmentCount: plan.newInstallmentCount,
schedules,
installmentRatios: schedules.map((s) => {
const n = Number(s.dueAmount)
return Number.isFinite(n) && n > 0 ? n : 0
const amount = normalizeMoney(s.dueAmount)
return amount != null && compareMoney(amount, 0) > 0 ? amount : '0.00'
})
}
}
@@ -231,28 +268,28 @@ export function applyExpandRemainingToInstallment(pay, opts) {
}
}
let totalPaid = 0
let totalPaid = '0.00'
const kept = []
for (const s of schList) {
const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(
Number(s.dueAmount) || 0,
s.dueAmount,
s.transfers || []
)
totalPaid = roundMoney(totalPaid + paid)
if (paid > 0) {
totalPaid = addMoney([totalPaid, paid])
if (compareMoney(paid, 0) > 0) {
kept.push({
...s,
dueAmount: arrears > 0 ? paid : s.dueAmount,
scheduleStatus: arrears > 0 ? 'cleared' : s.scheduleStatus || 'cleared',
dueAmount: compareMoney(arrears, 0) > 0 ? paid : s.dueAmount,
scheduleStatus: compareMoney(arrears, 0) > 0 ? 'cleared' : s.scheduleStatus || 'cleared',
transfers: [...(s.transfers || [])]
})
}
}
const contract = Number(pay.contractAmount)
if (!Number.isFinite(contract)) throw new Error('请先填写样本合同金额')
const remaining = roundMoney(contract - totalPaid)
if (remaining <= 0) throw new Error('已缴清,无法改为分期')
const contract = normalizeMoney(pay.contractAmount)
if (contract == null) throw new Error('请先填写样本合同金额')
const remaining = subtractMoney(contract, totalPaid)
if (compareMoney(remaining, 0) <= 0) throw new Error('已缴清,无法改为分期')
const count = Math.max(1, Number(opts.installmentCount) || 6)
const newCount = Math.max(1, count - kept.length)
@@ -280,29 +317,29 @@ export function applyExpandRemainingToInstallment(pay, opts) {
installmentAdvanceDays: advanceDays,
schedules,
installmentRatios: schedules.map((s) => {
const n = Number(s.dueAmount)
return Number.isFinite(n) && n > 0 ? n : 0
const amount = normalizeMoney(s.dueAmount)
return amount != null && compareMoney(amount, 0) > 0 ? amount : '0.00'
})
}
}
export function calcLastPeriodDueAmount(contractAmount, priorAmounts) {
if (contractAmount == null) return null
const contract = Number(contractAmount)
if (!Number.isFinite(contract)) return null
const contract = moneyToMinorUnits(contractAmount)
if (contract == null) return null
if (!priorAmounts.length) return null
let anyFilled = false
let sumCents = 0
let sumCents = 0n
for (const raw of priorAmounts) {
if (raw === null || raw === undefined || raw === '') continue
const n = Number(raw)
if (!Number.isFinite(n)) continue
const cents = moneyToMinorUnits(raw)
if (cents == null) continue
anyFilled = true
sumCents += Math.round(n * 100)
sumCents += cents
}
if (!anyFilled) return null
const last = Math.round(contract * 100 - sumCents) / 100
return last < 0 ? 0 : last
const last = contract - sumCents
return minorUnitsToMoney(last < 0n ? 0n : last)
}
export function shouldAutoFillLastPeriodDue(opts) {
@@ -359,7 +396,9 @@ export function rebuildSampleSchedules(pay) {
return {
id: prev?.id,
scheduleNo,
dueAmount: prevAmt != null && Number(prevAmt) > 0 ? Number(prevAmt) : null,
dueAmount: prevAmt != null && compareMoney(prevAmt, 0) > 0
? normalizeMoney(prevAmt)
: null,
dueDate: prev?.dueDate || calcFqInstallmentDueDate(startDate, i, interval, advanceDays) || '',
scheduleStatus: prev?.scheduleStatus || 'open',
transfers: [...(prev?.transfers || [])]
@@ -7,13 +7,35 @@ import {
availableSamplePayModes,
applyConsolidateRemainingToPayment,
sampleScheduleRequiredFieldsHint,
sampleScheduleDuesMismatchHint
sampleScheduleDuesMismatchHint,
applyPreferentialPriceToPayment,
scheduleFinanceConfirmedPaidArrears,
shouldFollowProtocolTotal,
nextProtocolTotalFromSamples,
sumSampleContractAmounts
} from './protocol-sample-payment.js'
describe('decimal-safe totals', () => {
it('sums contracts and confirmed transfers in integer minor units', () => {
assert.equal(sumSampleContractAmounts([
{ contractAmount: '0.10' },
{ contractAmount: '0.20' }
]), '0.30')
assert.deepEqual(
scheduleFinanceConfirmedPaidArrears('0.30', [
{ transferAmount: '0.10', payStatus: 'yjf' },
{ transferAmount: '0.20', payStatus: 'yjf' },
{ transferAmount: '9.99', payStatus: 'jfqrz' }
]),
{ paid: '0.30', arrears: '0.00' }
)
})
})
describe('calcLastPeriodDueAmount', () => {
it('last = contract − priors', () => {
assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000)
assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), 33.34)
assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), '5000.00')
assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), '33.34')
})
it('returns null when contract invalid or priors empty', () => {
assert.equal(calcLastPeriodDueAmount(null, [1000]), null)
@@ -74,7 +96,7 @@ describe('applyConsolidateRemainingToPayment', () => {
)
assert.equal(next.payMode, 'fgd')
assert.equal(next.schedules.length, 1)
assert.equal(next.schedules[0].dueAmount, 30000)
assert.equal(next.schedules[0].dueAmount, '30000.00')
})
})
@@ -99,3 +121,57 @@ describe('hints', () => {
assert.match(hint, /不一致/)
})
})
describe('applyPreferentialPriceToPayment', () => {
it('fills empty contract on first align', () => {
const next = applyPreferentialPriceToPayment(
{ payMode: 'dj', contractAmount: null, schedules: [] },
{ price: 20000 },
false
)
assert.equal(next.contractAmount, '20000.00')
assert.equal(next.schedules[0].dueAmount, '20000.00')
})
it('does not overwrite hand-filled contract without force', () => {
const pay = { payMode: 'dj', contractAmount: 18000, schedules: [{ scheduleNo: 1, dueAmount: 18000 }] }
const next = applyPreferentialPriceToPayment(pay, { price: 20000 }, false)
assert.equal(next, pay)
})
it('force overwrites contract; fq keeps schedule amounts', () => {
const next = applyPreferentialPriceToPayment(
{
payMode: 'fq',
contractAmount: 18000,
schedules: [{ scheduleNo: 1, dueAmount: 9000 }]
},
{ price: 20000 },
true
)
assert.equal(next.contractAmount, '20000.00')
assert.equal(next.schedules[0].dueAmount, 9000)
})
it('skips when yjf locked', () => {
const pay = {
payMode: 'dj',
contractAmount: 18000,
schedules: [{ scheduleNo: 1, dueAmount: 18000, transfers: [{ payStatus: 'yjf' }] }]
}
assert.equal(applyPreferentialPriceToPayment(pay, { price: 20000 }, true), pay)
})
})
describe('protocol total follow', () => {
it('shouldFollowProtocolTotal', () => {
assert.equal(shouldFollowProtocolTotal(null, 100), true)
assert.equal(shouldFollowProtocolTotal(100, 100), true)
assert.equal(shouldFollowProtocolTotal(99, 100), false)
})
it('nextProtocolTotalFromSamples', () => {
assert.equal(nextProtocolTotalFromSamples(true, '20000.005', 1), '20000.01')
assert.equal(nextProtocolTotalFromSamples(true, 0, 1), null)
assert.equal(nextProtocolTotalFromSamples(false, 20000, 1), 1)
})
})
@@ -0,0 +1,161 @@
/** 协议缴费期内转账:与 Web SamplePaymentPanel 规则对齐的纯函数 */
import { normalizeMoney } from './money.js'
export const PAY_STATUS_LABELS = Object.freeze({
dfj: '已付款',
jfqrz: '付款确认中',
yjf: '付款完成'
})
const SCHEDULE_ACTIONS = new Set(['register-transfer'])
const TRANSFER_ACTION_ALIASES = Object.freeze({
'submit-finance': 'submit',
'withdraw-transfer': 'withdraw'
})
const TRANSFER_ACTIONS = new Set([
'edit-transfer',
'delete-payment-transfer',
'submit',
'withdraw'
])
function actionCode(action) {
const raw = typeof action === 'string'
? action
: String(action?.code || action?.action || action?.key || '').trim()
return TRANSFER_ACTION_ALIASES[raw] || raw
}
export function canUseProtocolScheduleAction(schedule, requested) {
const code = String(requested || '').trim()
return SCHEDULE_ACTIONS.has(code) && (schedule?.allowedActions || []).some(
(action) => actionCode(action) === code
)
}
export function canUseProtocolTransferAction(transfer, requested) {
const code = String(requested || '').trim()
return TRANSFER_ACTIONS.has(code) && (transfer?.allowedActions || []).some(
(action) => actionCode(action) === code
)
}
export function emptyTransferRow() {
return {
transferType: '',
transferAmount: null,
transferTime: '',
transferAttachments: '',
attachments: [],
remark: '',
financeRemark: '',
payStatus: 'dfj'
}
}
export function isTransferLocked(transfer) {
return String(transfer?.payStatus || '') === 'yjf'
}
export function ensureTransferAttachments(transfer) {
const row = transfer || {}
if (Array.isArray(row.attachments)) return row.attachments
const urls = String(row.transferAttachments || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
row.attachments = urls.map((fileUrl, index) => ({
id: `url-${index}`,
fileName: fileUrl.split('/').pop() || '附件',
fileUrl
}))
return row.attachments
}
export function attachmentsToUrls(transfer) {
ensureTransferAttachments(transfer)
return (transfer.attachments || [])
.filter((f) => f?.fileUrl && !f._uploading)
.map((f) => String(f.fileUrl))
.join(',')
}
export function buildTransferSavePayload(transfer) {
return {
transferType: transfer.transferType,
transferAmount: normalizeMoney(transfer.transferAmount),
transferTime: transfer.transferTime || undefined,
transferAttachments: attachmentsToUrls(transfer) || undefined,
remark: transfer.remark
}
}
export function assertCanSaveTransfer({ protocolId, scheduleId } = {}) {
if (!String(protocolId || '').trim()) {
throw new Error('请先保存协议后再保存转账记录')
}
if (!String(scheduleId || '').trim()) {
throw new Error('请先保存协议后再保存转账记录')
}
}
export function validateTransferRow(transfer) {
if (!String(transfer?.transferType || '').trim()) {
return '请选择转账方式'
}
const amt = Number(transfer?.transferAmount)
if (!Number.isFinite(amt) || amt <= 0) {
return '请填写转账金额'
}
return ''
}
export function canSubmitFinance(transfer, protocolId) {
return Boolean(
String(protocolId || '').trim() &&
transfer?.id &&
String(transfer.payStatus || 'dfj') === 'dfj'
)
}
export function canWithdrawFinance(transfer, protocolId) {
return Boolean(
String(protocolId || '').trim() &&
transfer?.id &&
String(transfer.payStatus || '') === 'jfqrz'
)
}
export function applySavedTransfer(local, remote = {}) {
const next = { ...(local || {}) }
if (remote.id != null) next.id = remote.id
if (remote.payStatus != null) next.payStatus = remote.payStatus
if (Array.isArray(remote.allowedActions)) {
next.allowedActions = [...remote.allowedActions]
}
if (remote.financeRemark != null) next.financeRemark = remote.financeRemark
if (remote.transferAttachments != null) {
next.transferAttachments = remote.transferAttachments
next.attachments = undefined
ensureTransferAttachments(next)
}
if (Array.isArray(remote.attachments)) {
next.attachments = remote.attachments.map((a) => ({ ...a }))
}
for (const key of ['transferType', 'transferAmount', 'transferTime', 'remark']) {
if (remote[key] != null) next[key] = remote[key]
}
return next
}
export function patchTransfersAt(payments, pIdx, sIdx, transfers) {
const list = [...(payments || [])]
const pay = { ...(list[pIdx] || {}) }
const schedules = [...(pay.schedules || [])]
const sch = { ...(schedules[sIdx] || {}) }
sch.transfers = transfers
schedules[sIdx] = sch
pay.schedules = schedules
list[pIdx] = pay
return list
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applySavedTransfer,
assertCanSaveTransfer,
buildTransferSavePayload,
canSubmitFinance,
canWithdrawFinance,
emptyTransferRow,
isTransferLocked,
patchTransfersAt,
validateTransferRow
} from './protocol-sample-transfer.js'
test('empty transfer starts as pending payment', () => {
const row = emptyTransferRow()
assert.equal(row.payStatus, 'dfj')
assert.equal(isTransferLocked(row), false)
assert.equal(isTransferLocked({ payStatus: 'yjf' }), true)
})
test('save requires persisted protocol and schedule ids', () => {
assert.throws(() => assertCanSaveTransfer({}), /请先保存协议/)
assert.throws(() => assertCanSaveTransfer({ protocolId: 'p1' }), /请先保存协议/)
assert.doesNotThrow(() => assertCanSaveTransfer({ protocolId: 'p1', scheduleId: 's1' }))
})
test('validate transfer row requires type and positive amount', () => {
assert.match(validateTransferRow({}), /转账方式/)
assert.match(validateTransferRow({ transferType: 'wx', transferAmount: 0 }), /转账金额/)
assert.equal(validateTransferRow({ transferType: 'wx', transferAmount: 100 }), '')
})
test('submit and withdraw gates mirror web SamplePaymentPanel', () => {
assert.equal(canSubmitFinance({ id: 't1', payStatus: 'dfj' }, 'p1'), true)
assert.equal(canSubmitFinance({ id: 't1', payStatus: 'dfj' }, ''), false)
assert.equal(canSubmitFinance({ payStatus: 'dfj' }, 'p1'), false)
assert.equal(canWithdrawFinance({ id: 't1', payStatus: 'jfqrz' }, 'p1'), true)
assert.equal(canWithdrawFinance({ id: 't1', payStatus: 'dfj' }, 'p1'), false)
})
test('applySavedTransfer merges remote id and status', () => {
const next = applySavedTransfer(emptyTransferRow(), {
id: 't-new',
payStatus: 'dfj',
transferAmount: 200,
allowedActions: ['edit-transfer', 'delete-payment-transfer', 'submit-finance']
})
assert.equal(next.id, 't-new')
assert.equal(next.transferAmount, 200)
assert.deepEqual(next.allowedActions, [
'edit-transfer',
'delete-payment-transfer',
'submit-finance'
])
})
test('buildTransferSavePayload serializes attachment urls', () => {
const payload = buildTransferSavePayload({
transferType: 'zfb',
transferAmount: 50,
transferTime: '2026-10-13 12:00:00',
remark: 'ok',
attachments: [{ fileUrl: 'https://a/b.jpg' }, { fileUrl: '', _uploading: true }]
})
assert.equal(payload.transferAttachments, 'https://a/b.jpg')
assert.equal(payload.transferType, 'zfb')
assert.equal(payload.transferAmount, '50.00')
})
test('buildTransferSavePayload preserves the 18,2 boundary as a fixed decimal string', () => {
const payload = buildTransferSavePayload({
transferType: 'bank',
transferAmount: '9999999999999999.99'
})
assert.equal(payload.transferAmount, '9999999999999999.99')
})
test('patchTransfersAt replaces one schedule transfer list immutably', () => {
const payments = [{ schedules: [{ id: 's1', transfers: [] }] }]
const next = patchTransfersAt(payments, 0, 0, [{ id: 't1' }])
assert.equal(payments[0].schedules[0].transfers.length, 0)
assert.equal(next[0].schedules[0].transfers[0].id, 't1')
})
@@ -0,0 +1,251 @@
/** 协议样本及运输:对齐 Web sample-transport-form-section 的纯函数 */
import { moneyInputText } from '../oa/money-field-model.js'
import { compareMoney, normalizeMoney } from './money.js'
export const SAMPLE_STATUS_OPTIONS = Object.freeze([
{ label: '已采集', value: 'ycj' },
{ label: '检测中', value: 'jcz' },
{ label: '已入库', value: 'yrk' },
{ label: '已作废', value: 'yzf' }
])
function normalizeConstrainedMoney(value) {
if (value == null || value === '') return null
const raw = String(value).trim()
if (!raw || moneyInputText(raw) !== raw) return null
const normalized = normalizeMoney(raw)
return normalized != null && compareMoney(normalized, '0.00') >= 0 ? normalized : null
}
export function emptySampleRow({ source = 'cell_storage' } = {}) {
const isDonation = source === 'research_donation'
return {
sampleTypeId: '',
sampleCodeId: '',
sampleCode: '',
price: isDonation ? null : null,
originalPrice: isDonation ? null : null,
storageYears: isDonation ? null : null,
sampleStatus: 'ycj',
etaTime: '',
isSpecial: 0,
specialRemark: '',
freightId: '',
freight: null,
freightDraft: undefined
}
}
export function applySampleTypeDefaults(sample, sampleType, { showPrice = true } = {}) {
const next = { ...(sample || {}) }
const type = sampleType || {}
if (showPrice) {
const storagePrice = normalizeConstrainedMoney(type.storagePrice)
next.originalPrice = storagePrice
next.price = storagePrice
} else {
next.originalPrice = null
next.price = null
}
if (type.storageYears != null && type.storageYears !== '') {
const years = Number(type.storageYears)
next.storageYears = Number.isFinite(years) ? years : null
}
next.sampleCodeId = ''
next.sampleCode = showPrice ? '' : next.sampleCode
return next
}
export function validateSampleRow(sample, { source = 'cell_storage', autoGenerateCode = false } = {}) {
if (!String(sample?.sampleTypeId || '').trim()) return '请选择样本类型'
if (!autoGenerateCode && !String(sample?.sampleCodeId || sample?.sampleCode || '').trim()) {
return '请选择样本编码'
}
if (autoGenerateCode && source === 'research_donation') {
// 编码可空,保存时后端生成
}
const special = Number(sample?.isSpecial) === 1 || sample?.isSpecial === true
if (special && !String(sample?.specialRemark || '').trim()) {
return '特殊样本须填写特殊说明'
}
return ''
}
export function sampleHasFreight(sample) {
return Boolean(
String(sample?.freightId || sample?.freight?.id || '').trim() ||
(sample?.freightDraft && typeof sample.freightDraft === 'object')
)
}
export function partitionFreightConfigSamples(samples) {
const list = Array.isArray(samples) ? samples : []
const configurable = list.filter((row) => !sampleHasFreight(row))
const skipped = list.filter((row) => sampleHasFreight(row))
return { configurable, skipped }
}
export function buildFreightDraft({ transportMode, freightAmount, clientKey } = {}) {
return {
clientKey: clientKey || `fd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
transportMode: String(transportMode || '').trim(),
freightAmount: normalizeConstrainedMoney(freightAmount)
}
}
export function buildFreightCreatePayload({ transportMode, freightAmount, items } = {}) {
const draft = buildFreightDraft({ transportMode, freightAmount })
return {
transportMode: draft.transportMode,
freightAmount: draft.freightAmount,
items: Array.isArray(items) ? items : []
}
}
function freightTargetKey(row) {
return String(row?.id || row?._key || '').trim()
}
export function applyFreightAssignments(samples, assignments) {
const patches = new Map()
for (const assignment of assignments || []) {
for (const target of assignment?.targets || []) {
const key = freightTargetKey(target)
if (!key) continue
patches.set(key, { ...(patches.get(key) || {}), ...(assignment.patch || {}) })
}
}
return (samples || []).map((row) => {
const patch = patches.get(freightTargetKey(row))
return patch ? { ...row, ...patch, _checked: false } : row
})
}
export function validateFreightForm({ transportMode, freightAmount } = {}) {
if (!String(transportMode || '').trim()) return '请选择运输方式'
const rawAmount = freightAmount == null ? '' : String(freightAmount).trim()
const amount = normalizeConstrainedMoney(rawAmount)
if (amount == null) {
return '运费须为不小于 0 且最多 16 位整数、2 位小数的数字'
}
return ''
}
export function freightDisplay(sample) {
const freight = sample?.freight || {}
const draft = sample?.freightDraft
const modeLabel =
freight.transportModeLabel ||
freight.transportMode ||
draft?.transportMode ||
''
const amount =
freight.freightAmount != null
? freight.freightAmount
: draft?.freightAmount != null
? draft.freightAmount
: null
const linked = Number(freight.linkedSampleCount || 0)
const shared = linked > 1
return { modeLabel, amount, shared, linkedSampleCount: linked }
}
export function freightUnlinkRowKey(item) {
return `${String(item?.sampleSource || '')}:${String(item?.sampleId || '')}`
}
/** 聚合各运费详情 items,并标记默认勾选种子 */
export function buildFreightUnlinkCandidates(freightDetails, seedRows, { protocolId, source } = {}) {
const seedKeys = new Set(
(seedRows || [])
.filter((row) => row?.id)
.map((row) =>
freightUnlinkRowKey({
sampleSource: source || row.sampleSource,
sampleId: row.id
})
)
)
const byKey = new Map()
for (const detail of freightDetails || []) {
const freightId = String(detail?.id || '').trim()
if (!freightId) continue
for (const item of detail.items || []) {
const row = {
...item,
freightId,
sampleSource: item.sampleSource || source,
sampleId: item.sampleId || item.id,
protocolId: item.protocolId || protocolId || '',
protocolNo: item.protocolNo || '',
sampleCode: item.sampleCode || '',
sampleTypeName: item.sampleTypeName || '',
rowKey: freightUnlinkRowKey({
sampleSource: item.sampleSource || source,
sampleId: item.sampleId || item.id
})
}
if (!row.sampleId) continue
if (!byKey.has(row.rowKey)) byKey.set(row.rowKey, row)
}
}
// 种子若不在详情中则兜底补行
for (const seed of seedRows || []) {
if (!seed?.id) continue
const freightId = String(seed.freightId || seed.freight?.id || '').trim()
if (!freightId) continue
const rowKey = freightUnlinkRowKey({ sampleSource: source, sampleId: seed.id })
if (byKey.has(rowKey)) continue
byKey.set(rowKey, {
freightId,
sampleSource: source,
sampleId: seed.id,
protocolId: protocolId || '',
protocolNo: '',
sampleCode: seed.sampleCode || '',
sampleTypeName: '',
rowKey
})
}
const items = [...byKey.values()]
const defaultSelectedKeys = items.filter((row) => seedKeys.has(row.rowKey)).map((row) => row.rowKey)
return { items, defaultSelectedKeys }
}
/** 提交前剥离 UI 临时字段,保留后端白名单 */
export function serializeSamplesForSave(samples) {
return (samples || []).map((row) => {
const next = {
sampleTypeId: row.sampleTypeId || '',
sampleCodeId: row.sampleCodeId || '',
sampleCode: row.sampleCode || '',
price: normalizeConstrainedMoney(row.price),
originalPrice: normalizeConstrainedMoney(row.originalPrice),
storageYears: row.storageYears,
sampleStatus: row.sampleStatus || '',
isSpecial: row.isSpecial ? 1 : 0,
specialRemark: row.specialRemark || '',
etaTime: row.etaTime || ''
}
const id = String(row.id || row.sampleId || '').trim()
if (id) next.id = id
const freightId = String(row.freightId || row.freight?.id || '').trim()
if (freightId) next.freightId = freightId
if (row.freightDraft && typeof row.freightDraft === 'object' && !freightId) {
next.freightDraft = {
clientKey: row.freightDraft.clientKey,
transportMode: row.freightDraft.transportMode,
freightAmount: normalizeConstrainedMoney(row.freightDraft.freightAmount)
}
}
return next
})
}
export function buildProtocolSamplesPayload(payload, samples) {
return {
...(payload || {}),
samples: serializeSamplesForSave(samples)
}
}
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { saveMarketRecord } from '../api/market.js'
import { formatMoney } from './money.js'
import * as sampleTransport from './protocol-sample-transport.js'
import {
applySampleTypeDefaults,
buildFreightDraft,
buildFreightUnlinkCandidates,
emptySampleRow,
freightDisplay,
partitionFreightConfigSamples,
sampleHasFreight,
serializeSamplesForSave,
validateFreightForm,
validateSampleRow
} from './protocol-sample-transport.js'
test('empty cell sample defaults to collected status', () => {
const row = emptySampleRow({ source: 'cell_storage' })
assert.equal(row.sampleStatus, 'ycj')
assert.equal(row.isSpecial, 0)
})
test('sample type change fills price for cell storage and clears code', () => {
const next = applySampleTypeDefaults(
{ sampleCodeId: 'c1', sampleCode: 'S-1', price: 1 },
{ storagePrice: 25880, storageYears: 20 },
{ showPrice: true }
)
assert.equal(next.originalPrice, '25880.00')
assert.equal(next.price, '25880.00')
assert.equal(next.storageYears, 20)
assert.equal(next.sampleCodeId, '')
})
test('sample type defaults preserve the maximum DECIMAL(18,2) value exactly', () => {
const next = applySampleTypeDefaults(
{ sampleCodeId: 'c1', sampleCode: 'S-1' },
{ storagePrice: '9999999999999999.99', storageYears: '20' },
{ showPrice: true }
)
assert.equal(next.originalPrice, '9999999999999999.99')
assert.equal(next.price, '9999999999999999.99')
assert.equal(next.storageYears, 20)
})
test('donation sample defaults do not copy storage prices', () => {
const next = applySampleTypeDefaults(
{ sampleCode: 'AUTO', price: null, originalPrice: null },
{ storagePrice: '9999999999999999.99', storageYears: '20' },
{ showPrice: false }
)
assert.equal(next.price, null)
assert.equal(next.originalPrice, null)
assert.equal(next.sampleCode, 'AUTO')
})
test('validate sample requires type, code, and special remark', () => {
assert.match(validateSampleRow({}), /样本类型/)
assert.match(
validateSampleRow({ sampleTypeId: 't1' }, { source: 'cell_storage' }),
/样本编码/
)
assert.match(
validateSampleRow({ sampleTypeId: 't1', sampleCodeId: 'c1', isSpecial: 1 }),
/特殊说明/
)
assert.equal(
validateSampleRow({ sampleTypeId: 't1', sampleCodeId: 'c1', isSpecial: 0 }),
''
)
})
test('donation may omit code when auto-generating', () => {
assert.equal(
validateSampleRow(
{ sampleTypeId: 't1' },
{ source: 'research_donation', autoGenerateCode: true }
),
''
)
})
test('freight partition skips already configured samples', () => {
const { configurable, skipped } = partitionFreightConfigSamples([
{ sampleCode: 'a' },
{ sampleCode: 'b', freightId: 'fr1' },
{ sampleCode: 'c', freightDraft: { transportMode: 'zxc' } }
])
assert.equal(configurable.length, 1)
assert.equal(skipped.length, 2)
assert.equal(sampleHasFreight({ freightId: 'x' }), true)
})
test('freight form validates the money input precision and range without float coercion', () => {
assert.match(validateFreightForm({}), /运输方式/)
for (const freightAmount of ['', '-1', 'abc', '0.001', '10000000000000000.00']) {
assert.match(validateFreightForm({ transportMode: 'zxc', freightAmount }), /运费/)
}
assert.equal(validateFreightForm({ transportMode: 'zxc', freightAmount: '0' }), '')
assert.equal(
validateFreightForm({ transportMode: 'zxc', freightAmount: '9999999999999999.99' }),
''
)
assert.equal(
validateFreightForm({ transportMode: 'zxc', freightAmount: '00000000000000001.00' }),
''
)
})
test('freight draft normalizes valid amounts to exact strings or null', () => {
const draft = buildFreightDraft({ transportMode: 'zxc', freightAmount: 10, clientKey: 'k1' })
assert.equal(draft.clientKey, 'k1')
assert.equal(draft.freightAmount, '10.00')
assert.equal(
buildFreightDraft({ transportMode: 'zxc', freightAmount: '9999999999999999.99' })
.freightAmount,
'9999999999999999.99'
)
assert.equal(
buildFreightDraft({ transportMode: 'zxc', freightAmount: '00000000000000001.00' })
.freightAmount,
'1.00'
)
assert.equal(buildFreightDraft({ transportMode: 'zxc', freightAmount: '' }).freightAmount, null)
assert.equal(buildFreightDraft({ transportMode: 'zxc', freightAmount: '-1' }).freightAmount, null)
assert.equal(
buildFreightDraft({ transportMode: 'zxc', freightAmount: '10000000000000000.00' })
.freightAmount,
null
)
})
test('serializeSamplesForSave keeps persisted sample id and strips ui-only keys', () => {
const rows = serializeSamplesForSave([
{
id: 'local',
sampleTypeId: 't1',
sampleCode: 'S-1',
sampleStatus: 'ycj',
isSpecial: true,
specialRemark: 'HBV',
etaTime: '2026-10-13 10:00:00',
freightDraft: { clientKey: 'k', transportMode: 'zxc', freightAmount: 9 },
_checked: true
}
])
assert.equal(rows[0].isSpecial, 1)
assert.equal(rows[0].freightDraft.transportMode, 'zxc')
assert.equal(rows[0].id, 'local')
assert.equal(rows[0]._checked, undefined)
})
test('serializeSamplesForSave normalizes every sample and freight amount as a string', () => {
const rows = serializeSamplesForSave([
{
sampleTypeId: 't1',
sampleCode: 'S-1',
price: '9999999999999999.99',
originalPrice: '25880',
freightDraft: {
clientKey: 'k',
transportMode: 'zxc',
freightAmount: '0'
}
}
])
assert.equal(rows[0].price, '9999999999999999.99')
assert.equal(rows[0].originalPrice, '25880.00')
assert.equal(rows[0].freightDraft.freightAmount, '0.00')
})
test('freightDisplay prefers persisted freight over draft and marks shared', () => {
assert.deepEqual(
freightDisplay({
freight: { transportModeLabel: '专线车', freightAmount: 20, linkedSampleCount: 3 },
freightDraft: { transportMode: 'zxc', freightAmount: 1 }
}),
{ modeLabel: '专线车', amount: 20, shared: true, linkedSampleCount: 3 }
)
})
test('money display keeps maximum values exact and includes one currency symbol', () => {
const display = freightDisplay({
freightDraft: { transportMode: 'zxc', freightAmount: '9999999999999999.99' }
})
assert.equal(display.amount, '9999999999999999.99')
assert.equal(formatMoney(display.amount), '¥9,999,999,999,999,999.99')
})
test('persisted and draft freight save payloads keep maximum values exact', async () => {
assert.equal(typeof sampleTransport.buildFreightCreatePayload, 'function')
assert.equal(typeof sampleTransport.buildProtocolSamplesPayload, 'function')
const originalFetch = globalThis.fetch
const originalSessionStorage = globalThis.sessionStorage
const requests = []
globalThis.sessionStorage = { getItem: () => 'test-token' }
globalThis.fetch = async (url, init) => {
requests.push({ url, init, body: JSON.parse(init.body) })
return {
status: 200,
json: async () => ({ code: 200, data: { id: 'saved' } })
}
}
try {
const freightPayload = sampleTransport.buildFreightCreatePayload({
transportMode: 'zxc',
freightAmount: '9999999999999999.99',
items: [{ sampleSource: 'cell_storage', sampleId: 'sample-1' }]
})
await saveMarketRecord(
{ module: 'freight', marketRole: 'specialist' },
{ data: freightPayload }
)
const freightDraft = buildFreightDraft({ ...freightPayload, clientKey: 'k-max' })
const defaults = applySampleTypeDefaults(
{ sampleTypeId: 'type-1', sampleCode: 'S-NEW' },
{ storagePrice: '9999999999999999.99', storageYears: 20 },
{ showPrice: true }
)
const protocolPayload = sampleTransport.buildProtocolSamplesPayload(
{ source: 'cell_storage' },
[{ ...defaults, freightDraft, sampleStatus: 'ycj', isSpecial: 0 }]
)
await saveMarketRecord(
{ module: 'protocol', marketRole: 'specialist' },
{ data: protocolPayload }
)
assert.equal(requests[0].body.freightAmount, '9999999999999999.99')
assert.equal(requests[1].body.samples[0].price, '9999999999999999.99')
assert.equal(requests[1].body.samples[0].originalPrice, '9999999999999999.99')
assert.equal(
requests[1].body.samples[0].freightDraft.freightAmount,
'9999999999999999.99'
)
} finally {
globalThis.fetch = originalFetch
if (originalSessionStorage === undefined) delete globalThis.sessionStorage
else globalThis.sessionStorage = originalSessionStorage
}
})
test('freight assignment merges persisted and draft targets atomically', () => {
assert.equal(typeof sampleTransport.applyFreightAssignments, 'function')
const persisted = { id: 'sample-1', sampleCode: 'S-1', _checked: true }
const draft = { _key: 'draft-1', sampleCode: 'S-2', _checked: true }
const untouched = { id: 'sample-3', sampleCode: 'S-3', _checked: true }
const created = {
id: 'freight-1',
transportMode: 'zxc',
freightAmount: '9999999999999999.99'
}
const result = sampleTransport.applyFreightAssignments(
[persisted, draft, untouched],
[
{
targets: [persisted],
patch: { freightId: created.id, freight: created, freightDraft: undefined }
},
{
targets: [draft],
patch: { freightId: created.id, freight: created, freightDraft: undefined }
}
]
)
assert.deepEqual(result[0], { ...persisted, freightId: 'freight-1', freight: created, freightDraft: undefined, _checked: false })
assert.deepEqual(result[1], { ...draft, freightId: 'freight-1', freight: created, freightDraft: undefined, _checked: false })
assert.equal(result[2], untouched)
assert.equal(persisted.freightId, undefined)
assert.equal(draft.freightId, undefined)
})
test('sample transport component uses shared money input and display contracts', () => {
const panel = readFileSync(
new URL('./components/ProtocolSampleTransportPanel.vue', import.meta.url),
'utf8'
)
assert.match(panel, /import OaMoneyField/)
assert.equal((panel.match(/<OaMoneyField/g) || []).length, 2)
assert.match(panel, /import \{ formatMoney \} from '\.\.\/money\.js'/)
assert.doesNotMatch(panel, /¥\s*\{\{\s*formatMoney/)
assert.doesNotMatch(panel, /¥\$\{formatMoney/)
assert.doesNotMatch(panel, /function formatMoney\(/)
assert.doesNotMatch(panel, /function parseAmount\(/)
assert.doesNotMatch(panel, /Number\(freightForm\.freightAmount\)/)
assert.doesNotMatch(panel, /\.toFixed\(/)
assert.match(panel, /freightAmount:\s*freightDraft\.freightAmount/)
assert.match(panel, /buildFreightCreatePayload/)
assert.match(panel, /applyFreightAssignments/)
assert.equal((panel.match(/applyFreightLocalAssignments\(assignments\)/g) || []).length, 3)
assert.match(panel, /\.protocol-sample-transport__actions\s+:deep\(\.van-button\)/)
assert.match(panel, /\.protocol-sample-transport__row-actions\s+:deep\(\.van-button\)/)
assert.match(panel, /min-height:\s*44px/)
})
test('protocol form delegates sample serialization to the shared payload builder', () => {
const form = readFileSync(
new URL('../views/market/MarketModuleForm.vue', import.meta.url),
'utf8'
)
assert.match(form, /buildProtocolSamplesPayload/)
assert.match(
form,
/buildProtocolSamplesPayload\(\s*withLinks,\s*formData\.value\.samples\s*\|\|\s*\[\]\s*\)/
)
})
test('buildFreightUnlinkCandidates merges batch items and defaults to seeds', () => {
const { items, defaultSelectedKeys } = buildFreightUnlinkCandidates(
[
{
id: 'fr-1',
items: [
{
sampleSource: 'cell_storage',
sampleId: 's1',
protocolNo: 'P1',
sampleCode: 'A',
sampleTypeName: '脐带血'
},
{
sampleSource: 'cell_storage',
sampleId: 's2',
protocolNo: 'P2',
sampleCode: 'B',
sampleTypeName: '脐带血'
}
]
}
],
[{ id: 's1', freightId: 'fr-1', sampleCode: 'A' }],
{ protocolId: 'p1', source: 'cell_storage' }
)
assert.equal(items.length, 2)
assert.deepEqual(defaultSelectedKeys, ['cell_storage:s1'])
})
@@ -0,0 +1,44 @@
const DIRTY_KEY = '_transferDirty'
export const PROTOCOL_TRANSFER_SAVE_BLOCK_MESSAGE = '请先保存转账记录'
export function markTransferDirty(transfer) {
return { ...(transfer || {}), [DIRTY_KEY]: true }
}
export function clearTransferDirty(transfer) {
const next = { ...(transfer || {}) }
delete next[DIRTY_KEY]
return next
}
export function isTransferDirty(transfer) {
return transfer?.[DIRTY_KEY] === true
}
export function hasDirtyTransfers(payments) {
return (payments || []).some((payment) =>
(payment?.schedules || []).some((schedule) =>
(schedule?.transfers || []).some(isTransferDirty)
)
)
}
export function protocolTransferSaveBlockMessage(hasDirty) {
return hasDirty ? PROTOCOL_TRANSFER_SAVE_BLOCK_MESSAGE : ''
}
export function applyTransferDeleteResult(form, paymentIndex, scheduleIndex, transferIndex, succeeded) {
if (!succeeded) return form
const payments = [...(form?.samplePayments || [])]
const payment = { ...(payments[paymentIndex] || {}) }
const schedules = [...(payment.schedules || [])]
const schedule = { ...(schedules[scheduleIndex] || {}) }
const transfers = [...(schedule.transfers || [])]
transfers.splice(transferIndex, 1)
schedule.transfers = transfers
schedules[scheduleIndex] = schedule
payment.schedules = schedules
payments[paymentIndex] = payment
return { ...(form || {}), samplePayments: payments }
}
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyTransferDeleteResult,
clearTransferDirty,
hasDirtyTransfers,
markTransferDirty,
protocolTransferSaveBlockMessage
} from './protocol-transfer-dirty.js'
function paymentsWith(transfer) {
return [{ schedules: [{ transfers: transfer ? [transfer] : [] }] }]
}
test('new and edited transfers remain dirty until their single-record save succeeds', () => {
const created = markTransferDirty({ id: '', transferAmount: '0.10' })
const edited = markTransferDirty({ id: 'transfer-1', transferAmount: '0.20' })
assert.equal(hasDirtyTransfers(paymentsWith(created)), true)
assert.equal(hasDirtyTransfers(paymentsWith(edited)), true)
assert.equal(hasDirtyTransfers(paymentsWith(clearTransferDirty({ ...edited, id: 'transfer-1' }))), false)
})
test('deleting a dirty row or replacing state from a reload clears transfer dirty state', () => {
assert.equal(hasDirtyTransfers(paymentsWith(null)), false)
assert.equal(hasDirtyTransfers(paymentsWith({ id: 'transfer-1', transferAmount: '1.00' })), false)
})
test('protocol global save returns the explicit transfer-first message only when dirty', () => {
assert.equal(protocolTransferSaveBlockMessage(true), '请先保存转账记录')
assert.equal(protocolTransferSaveBlockMessage(false), '')
})
test('successful persisted delete removes only the target and preserves other form drafts', () => {
const form = {
customer: { motherName: '未保存客户' },
samples: [{ sampleCode: '未保存样本' }],
samplePayments: [{ schedules: [{ transfers: [
markTransferDirty({ id: 't1', transferAmount: '1.00' }),
markTransferDirty({ id: 't2', transferAmount: '2.00' })
] }] }]
}
const next = applyTransferDeleteResult(form, 0, 0, 0, true)
assert.equal(next.customer, form.customer)
assert.equal(next.samples, form.samples)
assert.deepEqual(next.samplePayments[0].schedules[0].transfers.map((row) => row.id), ['t2'])
assert.equal(hasDirtyTransfers(next.samplePayments), true)
})
test('failed persisted delete leaves the complete form state unchanged', () => {
const form = { customer: { motherName: '草稿' }, samplePayments: paymentsWith(markTransferDirty({ id: 't1' })) }
assert.equal(applyTransferDeleteResult(form, 0, 0, 0, false), form)
})
@@ -0,0 +1,474 @@
/** v1 协议缴费明细按 Web ProtocolViewDialog.v1PayPeriods 语义归组。 */
import {
addMoney,
compareMoney,
minorUnitsToMoney,
moneyToMinorUnits,
normalizeMoney,
subtractMoney
} from './money.js'
import { serializeMarketAttachments } from './attachment-presenter.js'
import {
calcFqInstallmentDueDate,
defaultInstallmentAmounts
} from './protocol-payment-installment.js'
const INSTALLMENT_MODES = new Set(['fq', 'djfq', 'fgd'])
const DATE_RE = /^\d{8}$/
const PLAN_FIELDS = [
'payMode',
'contractAmount',
'lumpSumDeadline',
'installmentCount',
'installmentIntervalMonths',
'installmentStartDate',
'installmentAdvanceDays',
'installmentRatios'
]
const PROCESSING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf'])
function optionalMoney(value) {
if (value == null || String(value).trim() === '') return null
return normalizeMoney(value)
}
function positiveInteger(value) {
if (value == null || String(value).trim() === '') return null
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
function nonNegativeInteger(value) {
if (value == null || String(value).trim() === '') return null
const parsed = Number(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null
}
function text(value) {
return value == null ? '' : String(value)
}
function normalizeDetail(row = {}) {
return {
...row,
installmentNo: positiveInteger(row.installmentNo),
installmentDueDate: text(row.installmentDueDate),
dueAmount: optionalMoney(row.dueAmount),
transferType: text(row.transferType),
transferAmount: optionalMoney(row.transferAmount),
transferTime: text(row.transferTime),
transferAttachments: row.transferAttachments || row.attachments || '',
remark: text(row.remark),
payStatus: row.payStatus == null ? row.payStatus : String(row.payStatus)
}
}
export function isLegacyInstallmentMode(payMode) {
return INSTALLMENT_MODES.has(String(payMode || ''))
}
/** Only the exact database draft state is market-editable. */
export function isLegacyPaymentDetailEditable(detail) {
return String(detail?.payStatus ?? '') === 'dfj'
}
export function createLegacyPaymentDetail(overrides = {}) {
return normalizeDetail({
installmentNo: null,
installmentDueDate: '',
dueAmount: null,
transferType: '',
transferAmount: null,
transferTime: '',
transferAttachments: '',
remark: '',
payStatus: 'dfj',
...overrides
})
}
export function createLegacyPaymentState({ payment = {}, paymentDetails = [] } = {}) {
const payMode = text(payment?.payMode || 'dj')
return {
payment: {
payMode,
contractAmount: optionalMoney(payment?.contractAmount),
lumpSumDeadline: text(payment?.lumpSumDeadline),
installmentCount: positiveInteger(payment?.installmentCount),
installmentIntervalMonths: payment?.installmentIntervalMonths == null ||
text(payment.installmentIntervalMonths).trim() === ''
? null
: text(payment.installmentIntervalMonths),
installmentStartDate: text(payment?.installmentStartDate),
installmentAdvanceDays: nonNegativeInteger(payment?.installmentAdvanceDays),
installmentRatios: Array.isArray(payment?.installmentRatios)
? payment.installmentRatios.map(optionalMoney)
: [],
remark: text(payment?.remark)
},
details: (Array.isArray(paymentDetails) ? paymentDetails : []).map(normalizeDetail)
}
}
export function reconcileLegacyInstallments(details, installmentCount) {
const count = positiveInteger(installmentCount)
const rows = (Array.isArray(details) ? details : []).map(normalizeDetail)
if (!count) return rows
const byNo = new Map()
for (const row of rows) {
const no = positiveInteger(row.installmentNo)
if (no && no <= count && !byNo.has(no)) byNo.set(no, row)
}
const result = []
for (let no = 1; no <= count; no += 1) {
result.push(byNo.get(no) || createLegacyPaymentDetail({ installmentNo: no }))
}
for (const row of rows) {
const no = positiveInteger(row.installmentNo)
if (!isLegacyPaymentDetailEditable(row) && (!no || no > count)) result.push(row)
}
return result
}
function legacyInstallmentAmounts(payment, count) {
const ratios = Array.isArray(payment?.installmentRatios)
? payment.installmentRatios.map(optionalMoney)
: []
if (ratios.length === count && ratios.every((value) => value != null)) {
const contract = optionalMoney(payment?.contractAmount)
if (contract != null && compareMoney(addMoney(ratios), contract) === 0) return ratios
const ratioCents = ratios.map(moneyToMinorUnits)
const totalRatio = ratioCents.reduce((sum, value) => sum + value, 0n)
const contractCents = moneyToMinorUnits(contract)
if (
contractCents != null && totalRatio === 10000n &&
ratioCents.every((value) => value >= 0n && value <= 10000n)
) {
let assigned = 0n
return ratioCents.map((ratio, index) => {
const cents = index === count - 1
? contractCents - assigned
: (contractCents * ratio) / 10000n
assigned += cents
return minorUnitsToMoney(cents)
})
}
}
return defaultInstallmentAmounts(payment?.contractAmount, count)
}
function allocateRemainingAmounts(payment, rows, unlockedNos) {
const contractCents = moneyToMinorUnits(payment?.contractAmount)
if (contractCents == null) return new Map()
const lockedCents = rows
.filter((row) => !isLegacyPaymentDetailEditable(row))
.reduce((sum, row) => sum + (moneyToMinorUnits(row.dueAmount) || 0n), 0n)
const remainingCents = contractCents - lockedCents
if (remainingCents < 0n || !unlockedNos.length) return new Map()
const ratios = Array.isArray(payment?.installmentRatios)
? payment.installmentRatios
: []
const weights = unlockedNos.map((no) => moneyToMinorUnits(ratios[no - 1]))
const useWeights = weights.every((value) => value != null && value > 0n)
const normalizedWeights = useWeights ? weights : unlockedNos.map(() => 1n)
const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0n)
let assigned = 0n
const result = new Map()
unlockedNos.forEach((no, index) => {
const cents = index === unlockedNos.length - 1
? remainingCents - assigned
: (remainingCents * normalizedWeights[index]) / weightTotal
assigned += cents
result.set(no, minorUnitsToMoney(cents))
})
return result
}
/** Preserve every locked row while rebuilding editable rows from the new plan. */
export function rebuildLegacyInstallmentDetails(payment = {}, details = []) {
const count = positiveInteger(payment.installmentCount)
const payMode = text(payment.payMode)
if (!count || (payMode !== 'fq' && payMode !== 'djfq')) {
return (Array.isArray(details) ? details : []).map(normalizeDetail)
}
const rows = (Array.isArray(details) ? details : []).map(normalizeDetail)
const lockedByNo = new Map()
const draftByNo = new Map()
for (const row of rows) {
const no = positiveInteger(row.installmentNo)
if (!no || no > count) continue
const target = isLegacyPaymentDetailEditable(row) ? draftByNo : lockedByNo
if (!target.has(no)) target.set(no, row)
}
const amounts = legacyInstallmentAmounts(payment, count)
const unlockedNos = Array.from(
{ length: count }, (_, index) => index + 1
).filter((no) => !lockedByNo.has(no))
const remainingAmounts = lockedByNo.size
? allocateRemainingAmounts(payment, rows, unlockedNos)
: new Map()
const interval = Number(payment.installmentIntervalMonths || 0)
const advanceDays = nonNegativeInteger(payment.installmentAdvanceDays) ?? 30
const result = []
for (let no = 1; no <= count; no += 1) {
const locked = lockedByNo.get(no)
if (locked) {
result.push(locked)
continue
}
const draft = draftByNo.get(no) || createLegacyPaymentDetail({ installmentNo: no })
result.push(normalizeDetail({
...draft,
installmentNo: no,
dueAmount: remainingAmounts.get(no) ?? amounts[no - 1] ?? null,
installmentDueDate: calcFqInstallmentDueDate(
payment.installmentStartDate,
no - 1,
interval,
advanceDays
)
}))
}
for (const row of rows) {
const no = positiveInteger(row.installmentNo)
if (!isLegacyPaymentDetailEditable(row) && (!no || no > count)) result.push(row)
}
return result
}
/** Apply Web-equivalent detail rebuilding when the v1 payment mode changes. */
export function rebuildLegacyDetailsForPayMode(previous = {}, next = {}, details = []) {
const rows = (Array.isArray(details) ? details : []).map(normalizeDetail)
const fromMode = text(previous.payMode)
const toMode = text(next.payMode)
if ((fromMode === 'fq' || fromMode === 'djfq') && toMode === 'dj') {
const locked = rows.filter((row) => !isLegacyPaymentDetailEditable(row))
const lockedDue = addMoney(locked.map((row) => row.dueAmount))
const remaining = subtractMoney(next.contractAmount || '0', lockedDue)
return [
...locked,
createLegacyPaymentDetail({
installmentNo: null,
installmentDueDate: text(next.lumpSumDeadline),
dueAmount: remaining
})
]
}
if (toMode === 'fq' || toMode === 'djfq') {
return rebuildLegacyInstallmentDetails(next, rows)
}
return rows
}
function samePlanField(field, left, right) {
if (field === 'installmentRatios') {
const a = Array.isArray(left) ? left.map(optionalMoney) : []
const b = Array.isArray(right) ? right.map(optionalMoney) : []
return JSON.stringify(a) === JSON.stringify(b)
}
if (field === 'contractAmount') return optionalMoney(left) === optionalMoney(right)
return text(left).trim() === text(right).trim()
}
/** Web-equivalent safety gate for changes that rebuild a legacy payment plan. */
export function evaluateLegacyPaymentPlanChange(previous = {}, next = {}, details = []) {
const changedFields = PLAN_FIELDS.filter((field) => !samePlanField(
field, previous?.[field], next?.[field]
))
if (!changedFields.length) return { action: 'noop' }
const rows = Array.isArray(details) ? details : []
const locked = rows.filter((row) => !isLegacyPaymentDetailEditable(row))
if (!locked.length) {
const rebuildsPlan = changedFields.some((field) => [
'payMode',
'installmentCount',
'installmentIntervalMonths',
'installmentStartDate',
'installmentAdvanceDays',
'installmentRatios'
].includes(field))
return rebuildsPlan && rows.length
? { action: 'confirm', summary: '将按新方案重新生成待缴明细,是否继续?' }
: { action: 'noop' }
}
if (locked.some((row) => PROCESSING_STATUSES.has(String(row?.payStatus ?? '')))) {
return {
action: 'blocked',
reason: '存在付款确认中或退费处理中的锁定明细,请先完成相关流程。'
}
}
if (locked.some((row) => String(row?.payStatus ?? '') !== 'yjf')) {
return {
action: 'blocked',
reason: '存在异常锁定明细,无法变更缴费计划。'
}
}
const lockedDue = addMoney(locked.map((row) => row?.dueAmount))
const contract = optionalMoney(next?.contractAmount)
if (contract == null || compareMoney(contract, lockedDue) < 0) {
return { action: 'blocked', reason: '合同金额不能小于已缴或锁定明细金额。' }
}
const maxLockedInstallment = locked.reduce(
(maximum, row) => Math.max(maximum, positiveInteger(row?.installmentNo) || 0),
0
)
if (
isLegacyInstallmentMode(next?.payMode) &&
(positiveInteger(next?.installmentCount) || 0) < maxLockedInstallment
) {
return {
action: 'blocked',
reason: `已有第 ${maxLockedInstallment} 期锁定记录,分期期数不能小于该期次。`
}
}
const fromMode = text(previous?.payMode)
const toMode = text(next?.payMode)
if (
fromMode !== toMode &&
isLegacyInstallmentMode(fromMode) &&
isLegacyInstallmentMode(toMode)
) {
return { action: 'blocked', reason: '分期已有缴费记录,无法切换分期类型。' }
}
if (fromMode === 'dj' && isLegacyInstallmentMode(toMode)) {
return { action: 'blocked', reason: '趸交已有缴费记录,无法改为分期。' }
}
return {
action: 'confirm',
summary: '将保留已缴或锁定明细,并按新参数重新生成待缴计划。是否继续?'
}
}
export function serializeLegacyPaymentState(state = {}) {
const payment = state.payment || {}
const details = Array.isArray(state.details) ? state.details : []
let installmentRatios = (payment.installmentRatios || []).map(optionalMoney)
if (isLegacyInstallmentMode(payment.payMode)) {
const count = positiveInteger(payment.installmentCount)
if (count) {
const dueByNo = new Map(details.map((detail) => [
positiveInteger(detail.installmentNo),
optionalMoney(detail.dueAmount)
]))
const detailAmounts = Array.from(
{ length: count },
(_, index) => dueByNo.get(index + 1) ?? null
)
if (detailAmounts.every((value) => value != null)) {
installmentRatios = detailAmounts
} else if (
installmentRatios.length !== count ||
installmentRatios.some((value) => value == null)
) {
installmentRatios = defaultInstallmentAmounts(payment.contractAmount, count)
}
}
}
return {
payment: {
payMode: text(payment.payMode || 'dj'),
contractAmount: optionalMoney(payment.contractAmount),
lumpSumDeadline: text(payment.lumpSumDeadline),
installmentCount: positiveInteger(payment.installmentCount),
installmentIntervalMonths: payment.installmentIntervalMonths == null ||
text(payment.installmentIntervalMonths).trim() === ''
? null
: text(payment.installmentIntervalMonths),
installmentStartDate: text(payment.installmentStartDate),
installmentAdvanceDays: nonNegativeInteger(payment.installmentAdvanceDays),
installmentRatios
},
paymentDetails: details.map((detail) => ({
...(detail.id ? { id: String(detail.id) } : {}),
installmentNo: positiveInteger(detail.installmentNo),
installmentDueDate: text(detail.installmentDueDate),
dueAmount: optionalMoney(detail.dueAmount),
transferType: text(detail.transferType),
transferAmount: optionalMoney(detail.transferAmount),
transferTime: text(detail.transferTime),
transferAttachments: serializeMarketAttachments(detail.transferAttachments),
remark: text(detail.remark)
}))
}
}
export function validateLegacyPaymentState(state = {}) {
const payment = state.payment || {}
const details = Array.isArray(state.details) ? state.details : []
if (payment.lumpSumDeadline && !DATE_RE.test(text(payment.lumpSumDeadline))) {
return '趸交截止时间格式无效'
}
if (payment.installmentStartDate && !DATE_RE.test(text(payment.installmentStartDate))) {
return '分期开始时间格式无效'
}
if (isLegacyInstallmentMode(payment.payMode)) {
const count = positiveInteger(payment.installmentCount)
if (!count) return '请填写有效的分期期数'
if (details.length !== count) return '分期明细条数应与分期期数一致'
}
for (const detail of details) {
if (detail.installmentDueDate && !DATE_RE.test(text(detail.installmentDueDate))) {
return '应缴费时间格式无效'
}
if (detail.dueAmount != null && optionalMoney(detail.dueAmount) == null) {
return '应缴费金额格式无效'
}
if (detail.transferAmount != null && optionalMoney(detail.transferAmount) == null) {
return '转账金额格式无效'
}
}
return ''
}
function confirmedPaid(rows) {
return addMoney(rows
.filter((row) => String(row?.payStatus || '') === 'yjf')
.map((row) => row?.transferAmount))
}
function period(scheduleNo, rows, payment, lumpSum = false) {
const dueAmount = normalizeMoney(rows[0]?.dueAmount) ??
(lumpSum ? normalizeMoney(payment?.contractAmount) : null)
const paidAmount = confirmedPaid(rows)
const rawArrears = dueAmount == null ? null : subtractMoney(dueAmount, paidAmount)
const arrearsAmount = rawArrears == null
? null
: (compareMoney(rawArrears, 0) > 0 ? rawArrears : '0.00')
return {
scheduleNo,
dueAmount,
dueDate: String(
(lumpSum ? payment?.lumpSumDeadline : '') || rows[0]?.installmentDueDate || ''
),
paidAmount,
arrearsAmount,
scheduleStatus: dueAmount != null && compareMoney(paidAmount, dueAmount) >= 0 ? 'cleared' : 'open',
transfers: rows
}
}
export function groupLegacyPaymentPeriods({ payment = {}, details = [] } = {}) {
const rows = Array.isArray(details) ? details : []
if (!rows.length) return []
const payMode = String(payment?.payMode || '')
const installment = payMode === 'fq' || payMode === 'djfq'
if (!installment) return [period(1, rows, payment, true)]
const groups = new Map()
for (const row of rows) {
const scheduleNo = Number(row?.installmentNo) || 0
if (!groups.has(scheduleNo)) groups.set(scheduleNo, [])
groups.get(scheduleNo).push(row)
}
return [...groups.entries()]
.sort((left, right) => left[0] - right[0])
.map(([scheduleNo, transfers]) =>
period(scheduleNo || 1, transfers, payment, false)
)
}
@@ -0,0 +1,361 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { addMoney } from './money.js'
import {
addMonthsYmd,
calcFqInstallmentDueDate
} from './protocol-payment-installment.js'
import {
createLegacyPaymentState,
evaluateLegacyPaymentPlanChange,
groupLegacyPaymentPeriods,
isLegacyPaymentDetailEditable,
rebuildLegacyDetailsForPayMode,
rebuildLegacyInstallmentDetails,
reconcileLegacyInstallments,
serializeLegacyPaymentState,
validateLegacyPaymentState
} from './protocol-v1-payment.js'
test('groups multiple v1 transfers into one installment card and totals confirmed payment only', () => {
const periods = groupLegacyPaymentPeriods({
payment: { payMode: 'fq', contractAmount: 2000 },
details: [
{ id: 't1', installmentNo: 1, dueAmount: 1000, installmentDueDate: '20261031', transferAmount: 300, payStatus: 'yjf' },
{ id: 't2', installmentNo: 1, dueAmount: 1000, installmentDueDate: '20261031', transferAmount: 200, payStatus: 'jfqrz' },
{ id: 't3', installmentNo: 2, dueAmount: 1000, installmentDueDate: '20261130', transferAmount: 1000, payStatus: 'yjf' }
]
})
assert.equal(periods.length, 2)
assert.deepEqual(periods[0], {
scheduleNo: 1,
dueAmount: '1000.00',
dueDate: '20261031',
paidAmount: '300.00',
arrearsAmount: '700.00',
scheduleStatus: 'open',
transfers: periods[0].transfers
})
assert.equal(periods[0].transfers.length, 2)
assert.equal(periods[1].paidAmount, '1000.00')
assert.equal(periods[1].arrearsAmount, '0.00')
assert.equal(periods[1].scheduleStatus, 'cleared')
})
test('groups lump-sum v1 details into period one using contract deadline fallback', () => {
const periods = groupLegacyPaymentPeriods({
payment: { payMode: 'dj', contractAmount: 500, lumpSumDeadline: '20261231' },
details: [
{ id: 't1', transferAmount: 500, payStatus: 'yjf' },
{ id: 't2', transferAmount: 50, payStatus: 'ytf' }
]
})
assert.equal(periods.length, 1)
assert.equal(periods[0].scheduleNo, 1)
assert.equal(periods[0].dueAmount, '500.00')
assert.equal(periods[0].dueDate, '20261231')
assert.equal(periods[0].paidAmount, '500.00')
assert.equal(periods[0].transfers.length, 2)
})
test('v1 confirmed paid and arrears use decimal-safe arithmetic', () => {
const periods = groupLegacyPaymentPeriods({
payment: { payMode: 'fq' },
details: [
{ installmentNo: 1, dueAmount: '0.30', transferAmount: '0.10', payStatus: 'yjf' },
{ installmentNo: 1, dueAmount: '0.30', transferAmount: '0.20', payStatus: 'yjf' }
]
})
assert.equal(periods[0].paidAmount, '0.30')
assert.equal(periods[0].arrearsAmount, '0.00')
assert.equal(periods[0].scheduleStatus, 'cleared')
})
test('initializes v1 editor without converting decimal strings through Number', () => {
const state = createLegacyPaymentState({
payment: {
payMode: 'fq',
contractAmount: '9999999999999999.99',
installmentCount: 2,
installmentIntervalMonths: '12.50',
installmentStartDate: '20261014',
installmentAdvanceDays: 30,
installmentRatios: ['0.01', '9999999999999999.98']
},
paymentDetails: [{
id: 'detail-1', installmentNo: 1,
dueAmount: '9999999999999999.99', transferAmount: '0.01',
payStatus: 'dfj', transferAttachments: 'proof.pdf'
}]
})
assert.equal(state.payment.contractAmount, '9999999999999999.99')
assert.deepEqual(state.payment.installmentRatios, ['0.01', '9999999999999999.98'])
assert.equal(state.details[0].dueAmount, '9999999999999999.99')
assert.equal(state.details[0].transferAmount, '0.01')
})
test('only exact dfj rows are editable and deletable', () => {
assert.equal(isLegacyPaymentDetailEditable({ payStatus: 'dfj' }), true)
for (const payStatus of ['yjf', 'jfqrz', '', ' ', null, undefined, 'DFJ']) {
assert.equal(isLegacyPaymentDetailEditable({ payStatus }), false)
}
})
test('reconciles installment rows without deleting locked rows', () => {
const state = createLegacyPaymentState({
payment: { payMode: 'fq', installmentCount: 3 },
paymentDetails: [
{ id: 'locked-1', installmentNo: 1, payStatus: 'yjf', dueAmount: '100.00' },
{ id: 'draft-4', installmentNo: 4, payStatus: 'dfj', dueAmount: '400.00' }
]
})
const rows = reconcileLegacyInstallments(state.details, 3)
assert.equal(rows.length, 3)
assert.equal(rows[0].id, 'locked-1')
assert.deepEqual(rows.map((row) => row.installmentNo), [1, 2, 3])
assert.deepEqual(rows.map((row) => row.payStatus), ['yjf', 'dfj', 'dfj'])
})
test('serializes only market-writable v1 fields and keeps exact money strings', () => {
const payload = serializeLegacyPaymentState(createLegacyPaymentState({
payment: {
payMode: 'dj', contractAmount: '9999999999999999.99',
lumpSumDeadline: '20261231', financeRemark: 'forbidden'
},
paymentDetails: [{
id: 'detail-1', installmentNo: '1', installmentDueDate: '20261231',
dueAmount: '9999999999999999.99', transferType: 'wx',
transferAmount: '0.01', transferTime: '2026-10-14 10:00:00',
transferAttachments: 'proof-a.pdf,proof-b.pdf', remark: '市场备注',
payStatus: 'yjf', financeRemark: 'forbidden', refundAmount: '2.00'
}]
}))
assert.deepEqual(payload, {
payment: {
payMode: 'dj',
contractAmount: '9999999999999999.99',
lumpSumDeadline: '20261231',
installmentCount: null,
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: null,
installmentRatios: []
},
paymentDetails: [{
id: 'detail-1',
installmentNo: 1,
installmentDueDate: '20261231',
dueAmount: '9999999999999999.99',
transferType: 'wx',
transferAmount: '0.01',
transferTime: '2026-10-14 10:00:00',
transferAttachments: 'proof-a.pdf,proof-b.pdf',
remark: '市场备注'
}]
})
assert.equal(JSON.stringify(payload).includes('payStatus'), false)
assert.equal(JSON.stringify(payload).includes('finance'), false)
assert.equal(JSON.stringify(payload).includes('refund'), false)
})
test('validates installment count, row count, date and money without coercing amounts', () => {
assert.equal(validateLegacyPaymentState(createLegacyPaymentState({
payment: { payMode: 'fq', installmentCount: 2 },
paymentDetails: [{ installmentNo: 1, payStatus: 'yjf' }]
})), '分期明细条数应与分期期数一致')
assert.equal(validateLegacyPaymentState(createLegacyPaymentState({
payment: { payMode: 'dj', contractAmount: '100.00', lumpSumDeadline: '2026/10/14' },
paymentDetails: [{ dueAmount: 'bad', payStatus: 'dfj' }]
})), '趸交截止时间格式无效')
})
test('blocks a locked paid installment from becoming inconsistent with a smaller lump-sum plan', () => {
const previous = {
payMode: 'fq',
contractAmount: '2000.00',
installmentCount: 2,
installmentIntervalMonths: '12',
installmentStartDate: '20261001',
installmentAdvanceDays: 30,
installmentRatios: ['1000.00', '1000.00']
}
const next = { ...previous, payMode: 'dj', contractAmount: '1.00' }
const result = evaluateLegacyPaymentPlanChange(previous, next, [
{ installmentNo: 1, dueAmount: '1000.00', payStatus: 'yjf' },
{ installmentNo: 2, dueAmount: '1000.00', payStatus: 'dfj' }
])
assert.equal(result.action, 'blocked')
assert.match(result.reason, /已缴|锁定|合同金额/)
})
test('requires confirmation for a Web-compatible locked installment plan rebuild', () => {
const previous = {
payMode: 'fq',
contractAmount: '2000.00',
installmentCount: 2,
installmentIntervalMonths: '12',
installmentStartDate: '20261001',
installmentAdvanceDays: 30,
installmentRatios: ['1000.00', '1000.00']
}
const result = evaluateLegacyPaymentPlanChange(
previous,
{ ...previous, installmentCount: 3 },
[
{ installmentNo: 1, dueAmount: '1000.00', payStatus: 'yjf' },
{ installmentNo: 2, dueAmount: '1000.00', payStatus: 'dfj' }
]
)
assert.equal(result.action, 'confirm')
assert.match(result.summary, /保留.*已缴|重新生成/)
})
test('rebuilds editable installments after a paid plan interval change', () => {
const payment = {
payMode: 'fq', contractAmount: '2000.00', installmentCount: 2,
installmentIntervalMonths: '24', installmentStartDate: '20261001',
installmentAdvanceDays: 30, installmentRatios: ['1000.00', '1000.00']
}
const rows = rebuildLegacyInstallmentDetails(payment, [
{
id: 'paid-1', installmentNo: 1, installmentDueDate: '20261001',
dueAmount: '1000.00', payStatus: 'yjf'
},
{
id: 'draft-2', installmentNo: 2, installmentDueDate: '20270901',
dueAmount: '1000.00', payStatus: 'dfj'
}
])
assert.equal(rows[0].id, 'paid-1')
assert.equal(rows[0].installmentDueDate, '20261001')
assert.equal(rows[1].id, 'draft-2')
assert.equal(rows[1].installmentDueDate, '20280901')
assert.equal(rows[1].dueAmount, '1000.00')
})
test('switching paid installments to lump sum keeps history and creates one remaining draft', () => {
const previous = { payMode: 'fq', contractAmount: '2000.00' }
const next = {
payMode: 'dj', contractAmount: '2000.00', lumpSumDeadline: '20261231'
}
const rows = rebuildLegacyDetailsForPayMode(previous, next, [
{
id: 'paid-1', installmentNo: 1, installmentDueDate: '20261001',
dueAmount: '1000.00', payStatus: 'yjf'
},
{
id: 'draft-2', installmentNo: 2, installmentDueDate: '20270901',
dueAmount: '1000.00', payStatus: 'dfj'
}
])
assert.equal(rows.length, 2)
assert.equal(rows[0].id, 'paid-1')
assert.equal(rows[1].id, undefined)
assert.equal(rows[1].payStatus, 'dfj')
assert.equal(rows[1].installmentNo, null)
assert.equal(rows[1].installmentDueDate, '20261231')
assert.equal(rows[1].dueAmount, '1000.00')
})
test('rebuild distributes only the remaining contract across unpaid installments', () => {
const rows = rebuildLegacyInstallmentDetails({
payMode: 'fq', contractAmount: '3000.00', installmentCount: 3,
installmentIntervalMonths: '12', installmentStartDate: '20261001',
installmentAdvanceDays: 30, installmentRatios: []
}, [
{ id: 'paid-1', installmentNo: 1, dueAmount: '500.00', payStatus: 'yjf' },
{ id: 'draft-2', installmentNo: 2, dueAmount: '1000.00', payStatus: 'dfj' },
{ id: 'draft-3', installmentNo: 3, dueAmount: '1000.00', payStatus: 'dfj' }
])
assert.deepEqual(rows.map((row) => row.dueAmount), ['500.00', '1250.00', '1250.00'])
})
test('remaining installment rebuild preserves large-value cent tail without Number', () => {
const rows = rebuildLegacyInstallmentDetails({
payMode: 'fq', contractAmount: '9999999999999999.99', installmentCount: 3,
installmentIntervalMonths: '12', installmentStartDate: '20261001',
installmentAdvanceDays: 30, installmentRatios: []
}, [
{ id: 'paid-1', installmentNo: 1, dueAmount: '0.02', payStatus: 'yjf' },
{ installmentNo: 2, payStatus: 'dfj' },
{ installmentNo: 3, payStatus: 'dfj' }
])
assert.deepEqual(rows.map((row) => row.dueAmount), [
'0.02', '4999999999999999.98', '4999999999999999.99'
])
assert.equal(addMoney(rows.map((row) => row.dueAmount)), '9999999999999999.99')
})
test('calendar month addition clamps month-end dates like the backend', () => {
assert.equal(addMonthsYmd('20260131', 1), '20260228')
assert.equal(addMonthsYmd('20240131', 1), '20240229')
assert.equal(calcFqInstallmentDueDate('20260131', 1, 1, 0), '20260228')
})
test('paid-plan rebuild uses the shared clamped calendar helper for drafts', () => {
const rows = rebuildLegacyInstallmentDetails({
payMode: 'fq', contractAmount: '100.00', installmentCount: 2,
installmentIntervalMonths: '1', installmentStartDate: '20260131',
installmentAdvanceDays: 0, installmentRatios: []
}, [
{
id: 'paid-1', installmentNo: 1, installmentDueDate: '20260131',
dueAmount: '50.00', payStatus: 'yjf'
},
{
id: 'draft-2', installmentNo: 2, installmentDueDate: '20260303',
dueAmount: '50.00', payStatus: 'dfj'
}
])
assert.equal(rows[0].installmentDueDate, '20260131')
assert.equal(rows[1].installmentDueDate, '20260228')
assert.equal(rows[1].dueAmount, '50.00')
})
test('serialization fills complete installment amounts after lump-sum switches to installments', () => {
const payment = {
payMode: 'fq', contractAmount: '100.01', installmentCount: 2,
installmentIntervalMonths: '1', installmentStartDate: '20260131',
installmentAdvanceDays: 0, installmentRatios: [null, null]
}
const rows = rebuildLegacyDetailsForPayMode(
{ payMode: 'dj', contractAmount: '100.01' },
payment,
[{ installmentDueDate: '20261231', dueAmount: '100.01', payStatus: 'dfj' }]
)
const payload = serializeLegacyPaymentState({ payment, details: rows })
assert.deepEqual(rows.map((row) => row.dueAmount), ['50.00', '50.01'])
assert.deepEqual(payload.payment.installmentRatios, ['50.00', '50.01'])
})
test('serialization fills increased installment amounts from rebuilt rows with exact large tail', () => {
const payment = {
payMode: 'fq', contractAmount: '9999999999999999.99', installmentCount: 3,
installmentIntervalMonths: '1', installmentStartDate: '20260131',
installmentAdvanceDays: 0, installmentRatios: ['0.02', null, null]
}
const rows = rebuildLegacyInstallmentDetails(payment, [
{ id: 'paid-1', installmentNo: 1, dueAmount: '0.02', payStatus: 'yjf' }
])
const payload = serializeLegacyPaymentState({ payment, details: rows })
assert.deepEqual(payload.payment.installmentRatios, [
'0.02', '4999999999999999.98', '4999999999999999.99'
])
assert.equal(addMoney(payload.payment.installmentRatios), '9999999999999999.99')
})
+24 -5
View File
@@ -33,9 +33,21 @@ export function mergeReferenceOptions(scopedOptions, currentReferences, options
if (position != null) {
const existing = result[position]
const existingLabel = normalizedText(existing?.label)
if ((!existingLabel || existingLabel === key) && option.label !== key) {
result[position] = { ...existing, ...option }
const supplemented = { ...existing }
for (const [field, value] of Object.entries(option)) {
if (field === 'value' || field === 'label') continue
if (
(!Object.prototype.hasOwnProperty.call(supplemented, field) ||
supplemented[field] === undefined) &&
value != null && value !== ''
) {
supplemented[field] = value
}
}
if ((!existingLabel || existingLabel === key) && option.label !== key) {
supplemented.label = option.label
}
result[position] = supplemented
return
}
positions.set(key, result.length)
@@ -98,6 +110,15 @@ export function hydrateProtocolReferenceOptions(options, detail) {
const collection = detail?.collection || {}
const samples = Array.isArray(detail?.samples) ? detail.samples : []
const currentSampleCodeIds = samples.map((item) => item?.sampleCodeId).filter(Boolean)
const sampleTypeReference = (item) => {
const extra = {}
const storagePrice = item?.originalPrice ?? item?.price
if (storagePrice != null && storagePrice !== '') extra.storagePrice = storagePrice
if (item?.storageYears != null && item.storageYears !== '') {
extra.storageYears = item.storageYears
}
return reference(item?.sampleTypeId, item?.sampleTypeName, extra)
}
return {
...source,
collectorStaff: personOptions(
@@ -114,9 +135,7 @@ export function hydrateProtocolReferenceOptions(options, detail) {
),
regions: referenceOptions(source.regions, reference(collection.regionId, collection.regionName)),
hospitals: referenceOptions(source.hospitals, reference(collection.hospitalId, collection.hospitalName)),
sampleTypes: referenceOptions(source.sampleTypes, samples.map((item) =>
reference(item?.sampleTypeId, item?.sampleTypeName)
)),
sampleTypes: referenceOptions(source.sampleTypes, samples.map(sampleTypeReference)),
sampleCodes: referenceOptions(
filterAvailableSampleCodeOptions(source.sampleCodes, currentSampleCodeIds),
samples.map((item) =>
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { fetchMarketFormOptions } from '../api/market.js'
import {
filterAvailableSampleCodeOptions,
hydratePresignReferenceOptions,
@@ -8,6 +9,7 @@ import {
mergeReferenceOptions,
sampleCodeOptionsForRow
} from './reference-options.js'
import { applySampleTypeDefaults } from './protocol-sample-transport.js'
test('reference options keep scoped choices and add readable current snapshots once', () => {
const result = mergeReferenceOptions(
@@ -62,6 +64,155 @@ test('protocol options hydrate all selected reference values from detail labels'
assert.deepEqual(options.units, [{ value: 'unit-1', label: '毫升' }])
})
test('fetched protocol sample type defaults survive hydration and reach the panel helper', async () => {
const originalFetch = globalThis.fetch
const originalSessionStorage = globalThis.sessionStorage
globalThis.sessionStorage = { getItem: () => 'test-token' }
globalThis.fetch = async () => ({
status: 200,
json: async () => ({
code: 200,
data: {
sampleTypes: [{
value: 'type-max',
label: '最大金额样本',
storagePrice: '9999999999999999.99',
storageYears: 30
}]
}
})
})
let fetched
try {
fetched = await fetchMarketFormOptions({
module: 'protocol',
marketRole: 'specialist'
})
} finally {
globalThis.fetch = originalFetch
if (originalSessionStorage === undefined) delete globalThis.sessionStorage
else globalThis.sessionStorage = originalSessionStorage
}
const options = hydrateProtocolReferenceOptions(
fetched,
{
samples: [{ sampleTypeId: 'type-max', sampleTypeName: '最大金额样本' }]
}
)
assert.deepEqual(options.sampleTypes, [{
value: 'type-max',
label: '最大金额样本',
storagePrice: '9999999999999999.99',
storageYears: 30
}])
assert.deepEqual(
applySampleTypeDefaults({}, options.sampleTypes[0], { showPrice: true }),
{
sampleCodeId: '',
sampleCode: '',
storageYears: 30,
originalPrice: '9999999999999999.99',
price: '9999999999999999.99'
}
)
})
test('historical sample type defaults hydrate without overriding active option defaults', () => {
const historical = hydrateProtocolReferenceOptions(
{ sampleTypes: [] },
{
samples: [{
sampleTypeId: 'type-disabled',
sampleTypeName: '停用样本',
originalPrice: '9999999999999999.99',
price: '1.00',
storageYears: 30
}]
}
)
assert.deepEqual(historical.sampleTypes, [{
value: 'type-disabled',
label: '停用样本',
storagePrice: '9999999999999999.99',
storageYears: 30
}])
const restored = applySampleTypeDefaults({}, historical.sampleTypes[0], { showPrice: true })
assert.equal(restored.originalPrice, '9999999999999999.99')
assert.equal(restored.price, '9999999999999999.99')
assert.equal(restored.storageYears, 30)
const active = hydrateProtocolReferenceOptions(
{
sampleTypes: [{
value: 'type-active',
label: '有效样本',
storagePrice: '25880.00',
storageYears: 20
}]
},
{
samples: [{
sampleTypeId: 'type-active',
sampleTypeName: '历史名称',
originalPrice: '1.00',
storageYears: 99
}]
}
)
assert.deepEqual(active.sampleTypes, [{
value: 'type-active',
label: '有效样本',
storagePrice: '25880.00',
storageYears: 20
}])
const activeWithoutDefaults = hydrateProtocolReferenceOptions(
{
sampleTypes: [{
value: 'type-no-default',
label: '有效无默认值样本',
storagePrice: null,
storageYears: null
}]
},
{
samples: [{
sampleTypeId: 'type-no-default',
sampleTypeName: '历史名称',
originalPrice: '100.00',
storageYears: 5
}]
}
)
assert.deepEqual(activeWithoutDefaults.sampleTypes, [{
value: 'type-no-default',
label: '有效无默认值样本',
storagePrice: null,
storageYears: null
}])
const supplemented = hydrateProtocolReferenceOptions(
{ sampleTypes: [{ value: 'type-partial', label: '当前名称' }] },
{
samples: [{
sampleTypeId: 'type-partial',
sampleTypeName: '历史名称',
price: '1280.00',
storageYears: 10
}]
}
)
assert.deepEqual(supplemented.sampleTypes, [{
value: 'type-partial',
label: '当前名称',
storagePrice: '1280.00',
storageYears: 10
}])
})
test('sample code options hide occupied codes but keep current edit selection', () => {
const scoped = [
{ value: 'free-1', label: 'FREE-1', sampleCode: 'FREE-1' },
+75
View File
@@ -21,6 +21,12 @@ export const MARKET_ROLE_TITLES = Object.freeze({
manager: '地区经理'
})
/** 与小程序 MARKET_ENTRIES 导航栏文案一致的短角色名 */
export const MARKET_ROLE_SHORT_LABELS = Object.freeze({
specialist: '专员',
manager: '经理'
})
function isMarketRole(value) {
return MARKET_ROLES.includes(value)
}
@@ -29,6 +35,68 @@ function isMarketModule(value) {
return MARKET_MODULES.includes(value)
}
/**
* 微信 web-view 导航栏跟 H5 document.title;市场页须覆盖 index.html 默认「OA 表单」。
* 列表标题与 mobile-h5 MARKET_ENTRIES.title 对齐。
*/
export function resolveMarketDocumentTitle(route) {
const marketRole = route?.meta?.marketRole
const module = route?.meta?.module
if (!isMarketRole(marketRole) || !isMarketModule(module)) return ''
const moduleTitle = MARKET_MODULE_TITLES[module]
if (route.meta?.formMode === 'create') return `新增${moduleTitle}`
if (route.meta?.formMode === 'edit') {
const action = String(route.query?.action || 'edit').trim()
return (
{
edit: '编辑',
'register-transfer': '登记转账',
'edit-transfer': '编辑转账',
link: '关联样本',
unlink: '取消运费'
}[action] || `办理${moduleTitle}`
)
}
if (route.meta?.detail) return moduleTitle
return `${moduleTitle}(${MARKET_ROLE_SHORT_LABELS[marketRole]})`
}
/**
* 从真实 URL path 解析标题(含 Vite base `/oa-h5/`)。
* 供首屏 inline / history guard 在 router 就绪前使用。
*/
export function resolveMarketDocumentTitleFromPath(pathname, search = '') {
const matched = String(pathname || '').match(
/(?:^|\/)market\/(specialist|manager)\/(presign|protocol|payment-chase|payment|freight)(?:\/(.+))?\/?$/
)
if (!matched) return ''
const marketRole = matched[1]
const module = matched[2]
const segments = String(matched[3] || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
const meta = { marketRole, module }
let query = {}
if (segments[0] === 'new') {
meta.formMode = 'create'
} else if (segments.length >= 2 && segments[segments.length - 1] === 'edit') {
meta.formMode = 'edit'
try {
query = Object.fromEntries(new URLSearchParams(String(search || '').replace(/^\?/, '')))
} catch {
query = {}
}
} else if (segments.length >= 1) {
meta.detail = true
}
return resolveMarketDocumentTitle({ meta, query })
}
export function getMarketRouteContext(route) {
const marketRole = route?.meta?.marketRole
const module = route?.meta?.module
@@ -61,6 +129,13 @@ export function marketRecordNavigationQuery(module, item, query) {
return result
}
export function resolvePaymentChaseView(action) {
return {
'view-payment': 'payment',
'view-protocol': 'protocol'
}[String(action || '').trim()] || ''
}
export function resolveMarketSavedDetailId(module, currentId, result, action) {
const current = String(currentId || '').trim()
if (module === 'freight' && action === 'unlink' && result?.disappeared === true) return ''
+71
View File
@@ -9,6 +9,9 @@ import {
marketRecordNavigationQuery,
MARKET_MODULES,
pickMarketNavigationQuery,
resolveMarketDocumentTitle,
resolveMarketDocumentTitleFromPath,
resolvePaymentChaseView,
resolveMarketSavedDetailId,
shouldLoadMarketListAfterDetail
} from './routes.js'
@@ -22,6 +25,68 @@ test('selects dedicated presign list and form components', () => {
assert.equal(marketRouteModule.marketFormComponentKey?.('payment'), 'generic-form')
})
test('resolves web-view document titles aligned with market menu entries', () => {
assert.equal(
resolveMarketDocumentTitle({
meta: { marketRole: 'specialist', module: 'presign', title: '预签约订单' }
}),
'预签约订单(专员)'
)
assert.equal(
resolveMarketDocumentTitle({
meta: { marketRole: 'manager', module: 'freight', title: '运费信息' }
}),
'运费信息(经理)'
)
assert.equal(
resolveMarketDocumentTitle({
meta: { marketRole: 'specialist', module: 'protocol', formMode: 'create' }
}),
'新增协议单'
)
assert.equal(
resolveMarketDocumentTitle({
meta: { marketRole: 'manager', module: 'payment', formMode: 'edit' },
query: { action: 'register-transfer' }
}),
'登记转账'
)
assert.equal(
resolveMarketDocumentTitle({
meta: { marketRole: 'specialist', module: 'payment-chase', detail: true }
}),
'催缴信息'
)
assert.equal(resolveMarketDocumentTitle({ meta: {} }), '')
})
test('resolves market titles from real web-view pathnames including /oa-h5 base', () => {
assert.equal(
resolveMarketDocumentTitleFromPath('/oa-h5/market/specialist/presign'),
'预签约订单(专员)'
)
assert.equal(
resolveMarketDocumentTitleFromPath('/oa-h5/market/manager/freight/'),
'运费信息(经理)'
)
assert.equal(
resolveMarketDocumentTitleFromPath('/oa-h5/market/specialist/protocol/new'),
'新增协议单'
)
assert.equal(
resolveMarketDocumentTitleFromPath(
'/oa-h5/market/manager/payment/pay-1/edit',
'?action=register-transfer'
),
'登记转账'
)
assert.equal(
resolveMarketDocumentTitleFromPath('/oa-h5/market/specialist/payment-chase/c1'),
'催缴信息'
)
assert.equal(resolveMarketDocumentTitleFromPath('/oa-h5/oa-start'), '')
})
test('registers ten static market entries with isolated role metadata', () => {
assert.equal(marketRoutes.length, 10)
@@ -91,6 +156,12 @@ test('uses the authoritative singular payment chase API resource', () => {
)
})
test('maps both chase card buttons to explicit read-only detail views', () => {
assert.equal(resolvePaymentChaseView('view-payment'), 'payment')
assert.equal(resolvePaymentChaseView('view-protocol'), 'protocol')
assert.equal(resolvePaymentChaseView('edit'), '')
})
test('rejects unknown route metadata instead of accepting a client scope', () => {
assert.throws(
() => getMarketRouteContext({ meta: {}, query: { scope: 'manager' } }),
+32 -17
View File
@@ -2,12 +2,13 @@
<div class="oa-money">
<van-field
v-model="text"
type="number"
type="text"
inputmode="decimal"
input-align="left"
:disabled="disabled"
:placeholder="placeholder"
@update:model-value="onInput"
@blur="onBlur"
/>
<div v-if="showUpper && upperText" class="oa-money__upper">{{ upperText }}</div>
</div>
@@ -16,6 +17,11 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { toRmbUpper } from '../../utils/rmb-upper.js'
import {
moneyInputText,
nextMoneyInputText,
normalizeMoneyInput
} from '../money-field-model.js'
defineOptions({ name: 'money' })
@@ -28,32 +34,41 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'change'])
function toNum(v) {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, ''))
if (!Number.isFinite(n) || n < 0) return null
return Math.round(n * 100) / 100
}
const text = ref(props.modelValue == null || props.modelValue === '' ? '' : String(props.modelValue))
const numeric = computed(() => toNum(props.modelValue))
const initialText = moneyInputText(props.modelValue)
const text = ref(initialText)
const acceptedText = ref(initialText)
const upperText = computed(() => {
const n = numeric.value
return n == null ? '' : toRmbUpper(n)
const normalized = normalizeMoneyInput(text.value)
return normalized == null || normalized === '' ? '' : toRmbUpper(normalized)
})
watch(
() => props.modelValue,
(v) => {
const next = v == null || v === '' ? '' : String(v)
if (next !== text.value) text.value = next
const next = moneyInputText(v)
if (next === acceptedText.value) return
acceptedText.value = next
text.value = next
}
)
function onInput(v) {
const n = toNum(v)
emit('update:modelValue', n)
emit('change', n)
const candidate = v == null ? '' : String(v)
const next = nextMoneyInputText(acceptedText.value, candidate)
text.value = next
if (next !== candidate) return
acceptedText.value = next
emit('update:modelValue', next)
emit('change', next)
}
function onBlur() {
const normalized = normalizeMoneyInput(acceptedText.value)
if (normalized == null || normalized === acceptedText.value) return
acceptedText.value = normalized
text.value = normalized
emit('update:modelValue', normalized)
emit('change', normalized)
}
</script>
+40
View File
@@ -0,0 +1,40 @@
const MAX_INTEGER_DIGITS = 16
const MAX_MINOR_UNITS = 999999999999999999n
const EDITABLE_MONEY_PATTERN = /^\d+(?:\.\d{0,2})?$/
function isEditableMoneyText(text) {
if (!EDITABLE_MONEY_PATTERN.test(text)) return false
const integer = text.split('.', 1)[0].replace(/^0+(?=\d)/, '')
return integer.length <= MAX_INTEGER_DIGITS
}
export function moneyInputText(value) {
if (value == null || value === '') return ''
const text = String(value).trim()
return isEditableMoneyText(text) ? text : ''
}
export function nextMoneyInputText(previous, candidate) {
const next = candidate == null ? '' : String(candidate)
if (next === '' || isEditableMoneyText(next)) return next
return moneyInputText(previous)
}
export function normalizeMoneyInput(value) {
if (value == null || String(value).trim() === '') return ''
const match = String(value).trim().match(/^(\d+)(?:\.(\d*))?$/)
if (!match) return null
const integer = match[1].replace(/^0+(?=\d)/, '')
if (integer.length > MAX_INTEGER_DIGITS) return null
const fraction = match[2] || ''
const centText = fraction.padEnd(2, '0').slice(0, 2)
let minorUnits = BigInt(integer) * 100n + BigInt(centText)
if ((fraction[2] || '0') >= '5') minorUnits += 1n
if (minorUnits > MAX_MINOR_UNITS) return null
const whole = minorUnits / 100n
const cents = String(minorUnits % 100n).padStart(2, '0')
return `${whole}.${cents}`
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { toRmbUpper } from '../utils/rmb-upper.js'
const moneyModel = await import('./money-field-model.js').catch(() => ({}))
const {
moneyInputText,
nextMoneyInputText,
normalizeMoneyInput
} = moneyModel
test('money input preserves the full DECIMAL(18,2) boundary as text', () => {
assert.equal(typeof moneyInputText, 'function')
assert.equal(
moneyInputText('9999999999999999.99'),
'9999999999999999.99'
)
assert.equal(
normalizeMoneyInput('9999999999999999.99'),
'9999999999999999.99'
)
})
test('money input preserves each editable state while typing 1 then 1. then 1.2', () => {
assert.equal(typeof nextMoneyInputText, 'function')
let current = ''
current = nextMoneyInputText(current, '1')
assert.equal(current, '1')
current = nextMoneyInputText(current, '1.')
assert.equal(current, '1.')
current = nextMoneyInputText(current, '1.2')
assert.equal(current, '1.2')
current = nextMoneyInputText(current, '')
assert.equal(current, '')
})
test('money input accepts leading zeros after validating effective integer digits', () => {
const padded = '00000000000000001.00'
assert.equal(moneyInputText(padded), padded)
assert.equal(nextMoneyInputText('1.00', padded), padded)
assert.equal(normalizeMoneyInput(padded), '1.00')
assert.equal(moneyInputText('10000000000000000.00'), '')
assert.equal(moneyInputText('-0001.00'), '')
assert.equal(moneyInputText('0001e0'), '')
assert.equal(moneyInputText('0001.000'), '')
})
test('money input rejects illegal text and values beyond DECIMAL(18,2)', () => {
assert.equal(nextMoneyInputText('1.2', '1.2a'), '1.2')
assert.equal(nextMoneyInputText('1.2', '-1.2'), '1.2')
assert.equal(nextMoneyInputText('1.2', '1.234'), '1.2')
assert.equal(nextMoneyInputText('1.2', '10000000000000000'), '1.2')
assert.equal(nextMoneyInputText('1.2', '9999999999999999.999'), '1.2')
})
test('money blur normalization pads cents and rounds half up without Number', () => {
assert.equal(normalizeMoneyInput(''), '')
assert.equal(normalizeMoneyInput('1.'), '1.00')
assert.equal(normalizeMoneyInput('1.2'), '1.20')
assert.equal(normalizeMoneyInput('1.005'), '1.01')
assert.equal(normalizeMoneyInput('1.004'), '1.00')
assert.equal(normalizeMoneyInput('invalid'), null)
assert.equal(normalizeMoneyInput('10000000000000000.00'), null)
assert.equal(normalizeMoneyInput('9999999999999999.999'), null)
})
test('money upper-case display accepts the normalized string contract', () => {
assert.equal(toRmbUpper('1.20'), '壹元贰角')
assert.equal(toRmbUpper('999999999999.99').endsWith('玖分'), true)
assert.equal(toRmbUpper('1000000000000.00'), '金额过大')
})
test('OaMoneyField delegates input and blur to the string money model', async () => {
const source = await readFile(new URL('./components/OaMoneyField.vue', import.meta.url), 'utf8')
assert.match(source, /type="text"/)
assert.match(source, /@blur="onBlur"/)
assert.match(source, /nextMoneyInputText/)
assert.match(source, /normalizeMoneyInput/)
assert.doesNotMatch(source, /\bNumber\s*\(/)
assert.doesNotMatch(source, /Math\.round/)
})
+15 -2
View File
@@ -4,10 +4,11 @@ import OaStart from './views/OaStart.vue'
import OaDetail from './views/OaDetail.vue'
import OaHistory from './views/OaHistory.vue'
import OaMpBind from './views/OaMpBind.vue'
import { marketRoutes } from './market/routes.js'
import { applyMarketDocumentTitle } from './market/document-title.js'
import { marketRoutes, resolveMarketDocumentTitle } from './market/routes.js'
export function createAppRouter() {
return createRouter({
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
@@ -22,4 +23,16 @@ export function createAppRouter() {
...marketRoutes
]
})
router.afterEach((to) => {
const title = resolveMarketDocumentTitle(to)
if (title) {
if (typeof document !== 'undefined') document.title = title
return
}
// 非市场路由不强制改写(OA 页自行设 title);市场子路径兜底再刷一次
applyMarketDocumentTitle()
})
return router
}
+32 -6
View File
@@ -403,12 +403,23 @@ body.van-toast--unclickable .presign-order-page * {
.market-tag {
width: fit-content;
max-width: 112px;
max-width: 128px;
align-self: flex-start;
flex: 0 0 auto;
padding: 5px 12px;
overflow: hidden;
border-radius: 999px;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
/* 市场部各页(含预签约)统一胶囊 tag,覆盖 Vant 默认直角/紧凑内边距 */
.market-page .van-tag,
.presign-order-page .van-tag {
padding: 5px 12px;
border-radius: 999px;
line-height: 1.3;
}
.market-list-card__head > .market-tag,
@@ -909,16 +920,24 @@ body.van-toast--unclickable .presign-order-page * {
}
.presign-order-page__filter-row .van-dropdown-menu__title {
padding: 0;
padding: 0 14px 0 0;
font-size: 13px;
font-weight: 500;
color: var(--presign-text);
}
.presign-order-page__filter-row .van-dropdown-menu__title::after {
margin-top: -2px;
top: 50%;
right: 0;
margin-top: 0;
border-color: transparent transparent currentColor currentColor;
opacity: 0.7;
transform: translateY(-50%) rotate(-45deg);
}
.presign-order-page__filter-row .van-dropdown-menu__title--down::after {
margin-top: 0;
transform: translateY(-50%) rotate(135deg);
}
.presign-order-page__filter-row .van-dropdown-menu__title--active,
@@ -1140,14 +1159,21 @@ body.van-toast--unclickable .presign-order-page * {
font-size: 12px;
}
.presign-form-section__heading .van-button {
min-width: 44px;
min-height: 44px;
.presign-form-section__heading .van-button,
.presign-form-section__heading .presign-add-transfer-btn {
flex: 0 0 auto;
border-color: var(--presign-primary);
color: var(--presign-primary-dark);
}
.presign-form-section__heading .presign-add-transfer-btn {
min-width: 0;
min-height: 32px;
height: 32px;
padding: 0 10px;
font-size: 13px;
}
.presign-form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
+5 -3
View File
@@ -1,9 +1,11 @@
/** 与 web-admin toRmbUpper 对齐的人民币大写 */
export function toRmbUpper(value) {
if (value == null || !Number.isFinite(value) || value < 0) return ''
if (value > 999999999999.99) return '金额过大'
if (value == null || String(value).trim() === '') return ''
const numericValue = typeof value === 'number' ? value : Number(String(value).trim())
if (!Number.isFinite(numericValue) || numericValue < 0) return ''
if (numericValue > 999999999999.99) return '金额过大'
const fen = Math.round(value * 100)
const fen = Math.round(numericValue * 100)
if (fen === 0) return '零元整'
const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']
@@ -39,13 +39,6 @@
@action="handleAction"
/>
</template>
<van-action-sheet
v-model:show="auditSheetVisible"
title="审核协议单"
cancel-text="取消"
:actions="auditOptions"
@select="submitProtocolAudit"
/>
</MarketPageShell>
</template>
@@ -112,11 +105,6 @@ const statusTone = computed(() => resolveStatusTone(
))
const actionPending = ref(false)
const pendingAction = ref('')
const auditSheetVisible = ref(false)
const auditOptions = [
{ name: '审核通过', value: 'shtg', color: '#0a9f6d' },
{ name: '审核退回', value: 'shth', color: '#d14343' }
]
const allowedActions = computed(() => collectAllowedActions(
context.value.module,
context.value.marketRole,
@@ -185,10 +173,6 @@ async function handleAction(action) {
openActionForm(code, action)
return
}
if (context.value.module === 'protocol' && code === 'audit') {
auditSheetVisible.value = true
return
}
if (context.value.module === 'protocol' && code === 'delete') {
await deleteProtocol()
return
@@ -202,6 +186,7 @@ async function handleAction(action) {
'convert',
'submit',
'withdraw',
'delete-payment-transfer',
'delete-transfer',
'submit-transfer',
'withdraw-transfer'
@@ -259,29 +244,6 @@ async function deletePresign() {
}
}
async function submitProtocolAudit(selected) {
auditSheetVisible.value = false
try {
await showConfirmDialog({ title: '确认审核', message: `确认${selected.name}吗?` })
} catch {
return
}
try {
await performMarketAction(context.value, 'audit', {
id: id.value,
data: {
source: route.query.source || detail.value?.source,
auditStatus: selected.value,
auditRemark: ''
}
})
showSuccessToast('审核已提交')
await loadDetail()
} catch (error) {
if (error) showToast(marketErrorMessage(error))
}
}
async function deleteProtocol() {
try {
await showConfirmDialog({ title: '删除协议单', message: '删除后该协议单将进入回收状态,确认继续吗?' })
@@ -319,7 +281,11 @@ async function loadDetail() {
try {
const result = await fetchMarketDetail(requestContext, requestIdValue, {
signal: abortController.signal,
params: context.value.module === 'protocol' ? { source: route.query.source } : undefined
params: context.value.module === 'protocol'
? { source: route.query.source }
: context.value.module === 'payment-chase'
? { view: route.query.view }
: undefined
})
detailState.value = finishMarketDetailRequest(detailState.value, requestId, result)
} catch (error) {
@@ -6,9 +6,11 @@
:has-bottom-bar="true"
>
<div v-if="loading" class="market-state"><van-loading color="#0a9f6d">加载表单…</van-loading></div>
<van-empty v-else-if="errorMessage" image="error" :description="errorMessage">
<van-button type="primary" size="small" round @click="load">重新加载</van-button>
</van-empty>
<div v-else-if="errorMessage" class="market-state">
<van-empty image="error" :description="errorMessage">
<van-button type="primary" round @click="load">重新加载</van-button>
</van-empty>
</div>
<template v-else>
<MarketFormSection
v-for="section in visibleSections"
@@ -18,19 +20,78 @@
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="uploadPending > 0"
:disabled="formBusy"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
/>
<section
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && depositHintText"
class="market-form-section"
>
<h2>意向金(关联预签约)</h2>
<p class="market-protocol-total-sync">{{ depositHintText }}</p>
<div
v-for="(tr, index) in formData.depositTransfers || []"
:key="tr.id || index"
class="market-deposit-transfer"
>
<strong>转账 {{ index + 1 }}</strong>
<span>{{ formatMoney(tr.transferAmount || 0) }}</span>
<span>{{ tr.transferTime || '—' }}</span>
</div>
</section>
<section
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && isProtocolV2"
class="market-form-section"
>
<p class="market-protocol-total-sync">
样本合同合计 ¥{{ sampleContractSumDisplay }}
</p>
<p v-if="protocolTotalHintText" class="market-protocol-total-sync is-warn">
{{ protocolTotalHintText }}
</p>
</section>
<ProtocolSampleTransportPanel
v-if="context.module === 'protocol'"
v-model="formData.samples"
:source="formData.source || 'cell_storage'"
:protocol-id="id"
:market-role="context.marketRole"
:sample-types="formOptions.sampleTypes || []"
:sample-codes="formOptions.sampleCodes || []"
:transport-modes="formOptions.transportModes || []"
:disabled="fieldsLocked"
@update:model-value="onSamplesChange"
/>
<ProtocolSamplePaymentPanel
v-if="context.module === 'protocol' && formData.source === 'cell_storage'"
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && isProtocolV2"
v-model="formData.samplePayments"
:samples="formData.samples || []"
:disabled="formBusy"
:disabled="fieldsLocked"
:protocol-id="id"
:market-role="context.marketRole"
:transfer-types="formOptions.transferTypes || []"
@update:model-value="onSamplePaymentsChange"
@transfer-dirty-change="handleTransferDirtyChange"
/>
<ProtocolV1PaymentPanel
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && isProtocolV1"
v-model:payment="formData.payment"
v-model:payment-details="formData.paymentDetails"
:disabled="fieldsLocked"
:uploading="false"
:transfer-types="formOptions.transferTypes || []"
@upload="uploadV1PaymentAttachment"
@change="dirty = true"
/>
<section v-if="context.module === 'freight' && action !== 'edit'" class="market-form-section">
@@ -38,20 +99,48 @@
<van-search
v-if="contract.usesPagedSampleOptions?.(action)"
v-model="sampleKeyword"
:disabled="formBusy"
:disabled="fieldsLocked"
placeholder="搜索样本或协议编号"
@search="loadSampleOptions"
@clear="loadSampleOptions"
/>
<van-checkbox-group
v-model="selectedSampleKeys"
:disabled="formBusy"
class="market-sample-options"
@change="handleSampleSelection"
<van-dropdown-menu v-if="contract.usesPagedSampleOptions?.(action)" active-color="#0a9f6d">
<van-dropdown-item
v-model="sampleSource"
:options="sampleSourceOptions"
:disabled="fieldsLocked"
@change="loadSampleOptions"
/>
</van-dropdown-menu>
<van-list
v-model:loading="sampleOptionsLoading"
:finished="sampleOptionsFinished"
finished-text="没有更多样本了"
@load="loadMoreSampleOptions"
>
<van-checkbox v-for="item in sampleOptions" :key="sampleKey(item)" :name="sampleKey(item)">
{{ item.sampleCode || '-' }} · {{ item.customerName || item.protocolNo || '-' }}
</van-checkbox>
</van-checkbox-group>
<van-checkbox-group
v-model="selectedSampleKeys"
:disabled="fieldsLocked"
class="market-sample-options"
@change="handleSampleSelection"
>
<van-checkbox
v-for="item in sampleOptions"
:key="sampleKey(item)"
:name="sampleKey(item)"
:disabled="sampleOptionDisabled(item)"
class="market-sample-option"
>
<strong>{{ item.sampleCode || '—' }}</strong>
<span>{{ sampleSourceLabel(item.sampleSource) }} · 协议号 {{ item.protocolNo || '—' }}</span>
<span>母亲 {{ item.customerName || '—' }} · 新生儿 {{ item.newbornName || '—' }}</span>
<span>样本类型 {{ item.sampleTypeName || '—' }}</span>
<span v-if="item.freightId" class="market-sample-option__linked">
当前运费 {{ item.freightNo || item.freightId }}
</span>
</van-checkbox>
</van-checkbox-group>
</van-list>
</section>
</template>
@@ -72,17 +161,24 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
fetchFreightSampleOptions,
fetchMarketDetail,
fetchMarketFormOptions,
fetchMarketProtocolCustomers,
performMarketAction,
saveMarketRecord,
uploadMarketAttachment
} from '../../api/market.js'
import {
applyCustomerSelect,
calcBirthYmFromIdCard,
clearCustomerLinkIfNameChanged,
initLinkedCustomerNames
} from '../../market/protocol-customer-link.js'
import {
collectAllowedActions,
resolveAllowedActionTarget,
@@ -90,20 +186,46 @@ import {
} from '../../market/action-policy.js'
import {
buildMarketActionPayload,
mergeProtocolCustomerLinks,
mergeProtocolSamplePayments,
pickConfiguredPayload,
sanitizeMarketPayload,
validateRequiredFields
} from '../../market/form-payload.js'
import {
freightSampleKey,
freightSamplePageFinished,
mergeFreightSamplePage,
reconcileFreightSampleSelection,
rememberFreightSampleRows,
selectedFreightSamples
} from '../../market/freight-sample-options.js'
import {
nextProtocolTotalFromSamples,
protocolTotalMismatchHint,
sampleScheduleDuesMismatchHint,
sampleScheduleRequiredFieldsHint,
shouldFollowProtocolTotal,
sumSampleContractAmounts
} from '../../market/protocol-sample-payment.js'
import ProtocolSamplePaymentPanel from '../../market/components/ProtocolSamplePaymentPanel.vue'
import { appendMarketAttachment } from '../../market/attachment-presenter.js'
import ProtocolV1PaymentPanel from '../../market/components/ProtocolV1PaymentPanel.vue'
import ProtocolSampleTransportPanel from '../../market/components/ProtocolSampleTransportPanel.vue'
import { buildProtocolSamplesPayload } from '../../market/protocol-sample-transport.js'
import {
createPendingMarketAttachment,
dropPendingMarketAttachment,
resolvePendingMarketAttachment,
withPendingMarketAttachment
} from '../../market/attachment-upload-state.js'
import { marketErrorMessage } from '../../market/market-error.js'
import { protocolTransferSaveBlockMessage } from '../../market/protocol-transfer-dirty.js'
import {
createLegacyPaymentState,
serializeLegacyPaymentState,
validateLegacyPaymentState
} from '../../market/protocol-v1-payment.js'
import { compareMoney, formatMoney, normalizeMoney } from '../../market/money.js'
import { canLeaveMarketForm, createAsyncGenerationGuard } from '../../market/form-state.js'
import { getMarketModule } from '../../market/module-registry.js'
import { hydrateProtocolReferenceOptions } from '../../market/reference-options.js'
@@ -145,6 +267,7 @@ const loading = ref(true)
const submitting = ref(false)
const errorMessage = ref('')
const dirty = ref(false)
const transferDirty = ref(false)
const formData = ref({})
const formOptions = ref({})
const uploadPending = ref(0)
@@ -152,8 +275,27 @@ const sampleOptions = ref([])
const selectedSampleKeys = ref([])
const selectedSampleMap = new Map()
const sampleKeyword = ref('')
const sampleSource = ref('')
const sampleSourceOptions = [
{ text: '全部来源', value: '' },
{ text: '细胞存储', value: 'cell_storage' },
{ text: '科研捐赠', value: 'research_donation' }
]
const SAMPLE_OPTION_PAGE_SIZE = 20
const sampleOptionsPage = ref(0)
const sampleOptionsTotal = ref(0)
const sampleOptionsLoading = ref(false)
const sampleOptionsRequestPending = ref(false)
const sampleOptionsFinished = computed(() =>
action.value === 'unlink' || (
sampleOptionsPage.value > 0 &&
freightSamplePageFinished(sampleOptions.value, sampleOptionsTotal.value)
)
)
const authorizedAction = ref(null)
const formBusy = computed(() => submitting.value || uploadPending.value > 0)
/** 附件上传中仍可编辑;仅保存中锁定字段 */
const fieldsLocked = computed(() => submitting.value)
const mountGuard = createAsyncGenerationGuard()
const mountToken = mountGuard.begin()
const loadGuard = createAsyncGenerationGuard()
@@ -165,6 +307,16 @@ let submitController = null
const uploadControllers = new Set()
let allowLeave = false
function protocolPaymentModelVersion() {
const value = formData.value.paymentModelVersion
if (value == null || value === '') return mode.value === 'create' ? 2 : 1
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 1
}
const isProtocolV1 = computed(() => protocolPaymentModelVersion() < 2)
const isProtocolV2 = computed(() => protocolPaymentModelVersion() >= 2)
const visibleSections = computed(() => {
let sections = contract.value.formSections || []
if (context.value.module === 'payment') sections = [{ key: 'transfer', title: '转账登记', nested: true, fields: contract.value.transferFields }]
@@ -188,13 +340,116 @@ function setSectionValue(section, value) {
dirty.value = true
}
const protocolTotalFollowSamples = ref(true)
const sampleContractSum = computed(() =>
sumSampleContractAmounts(formData.value.samplePayments || [])
)
const sampleContractSumDisplay = computed(() => sampleContractSum.value)
const protocolTotalHintText = computed(() =>
context.value.module === 'protocol' && formData.value.source === 'cell_storage'
? protocolTotalMismatchHint(formData.value.protocolTotalAmount, sampleContractSum.value)
: null
)
const depositHintText = computed(() => {
const h = formData.value.presignDepositHint
if (!h) return ''
const cnt = Number(h.confirmedTransferCount || 0)
return `来源预签约已确认意向金合计 ${formatMoney(h.confirmedDepositAmount || 0)}(${cnt} 笔),与预签同一数据,不自动写入样本缴费`
})
function syncProtocolTotalFromSamples() {
if (context.value.module !== 'protocol' || formData.value.source !== 'cell_storage') return
if (!isProtocolV2.value) return
if (!protocolTotalFollowSamples.value) return
const next = nextProtocolTotalFromSamples(
true,
sampleContractSum.value,
formData.value.protocolTotalAmount
)
if (next === formData.value.protocolTotalAmount) return
if (
next != null &&
formData.value.protocolTotalAmount != null &&
compareMoney(next, formData.value.protocolTotalAmount) === 0
) {
return
}
formData.value = { ...formData.value, protocolTotalAmount: next }
}
function onSamplePaymentsChange(value) {
formData.value = { ...formData.value, samplePayments: value }
dirty.value = true
syncProtocolTotalFromSamples()
}
function handleTransferDirtyChange(value) {
transferDirty.value = Boolean(value)
}
function onSamplesChange(value) {
formData.value = { ...formData.value, samples: value }
dirty.value = true
}
watch(sampleContractSum, () => {
syncProtocolTotalFromSamples()
})
async function searchProtocolCustomers(keyword) {
if (context.value.module !== 'protocol') return []
const data = await fetchMarketProtocolCustomers(context.value, {
keyword,
page: 1,
pageSize: 20
})
return data?.list || []
}
function handleCustomerSelect({ section, field, customer }) {
if (context.value.module !== 'protocol' || section?.key !== 'customer') return
const role = field?.role === 'father' ? 'father' : 'mother'
const current = formData.value.customer || {}
const patch = applyCustomerSelect(current, customer, role)
formData.value = {
...formData.value,
customer: { ...current, ...patch }
}
dirty.value = true
}
async function handleFieldChange({ section, field, value, index }) {
if (context.value.module !== 'protocol') return
if (section.key === 'protocol' && field.key === 'protocolTotalAmount') {
if (!isProtocolV2.value) return
const total = normalizeMoney(value)
protocolTotalFollowSamples.value = shouldFollowProtocolTotal(total, sampleContractSum.value)
return
}
if (section.key === 'customer' && (field.key === 'motherName' || field.key === 'fatherName')) {
const role = field.key === 'fatherName' ? 'father' : 'mother'
const current = formData.value.customer || {}
const clearPatch = clearCustomerLinkIfNameChanged(current, role, value)
const next = { ...current, [field.key]: value, ...(clearPatch || {}) }
if (field.key === 'motherName' || clearPatch) {
formData.value = { ...formData.value, customer: next }
dirty.value = true
}
return
}
if (section.key === 'customer' && field.key === 'motherIdCard') {
const current = formData.value.customer || {}
formData.value = {
...formData.value,
customer: {
...current,
motherIdCard: value,
motherBirthYm: calcBirthYmFromIdCard(value)
}
}
dirty.value = true
return
}
if (section.key === 'samples' && field.key === 'sampleCodeId') {
const option = (formOptions.value.sampleCodes || []).find(
(item) => String(item.value) === String(value)
@@ -243,48 +498,87 @@ async function handleFieldChange({ section, field, value, index }) {
}
}
function sampleKey(item) {
return `${item.sampleSource}:${item.sampleId}`
return freightSampleKey(item)
}
function selectedItems() {
return selectedSampleKeys.value
.map((key) => selectedSampleMap.get(key))
.filter(Boolean)
.map((item) => ({ sampleSource: item.sampleSource, sampleId: item.sampleId }))
return selectedFreightSamples(selectedSampleMap, selectedSampleKeys.value)
}
function handleSampleSelection(keys) {
for (const item of sampleOptions.value) selectedSampleMap.set(sampleKey(item), item)
const selected = new Set(keys)
for (const key of [...selectedSampleMap.keys()]) {
if (!selected.has(key)) selectedSampleMap.delete(key)
}
selectedSampleKeys.value = reconcileFreightSampleSelection(
selectedSampleMap,
keys,
sampleOptions.value
)
dirty.value = true
}
async function loadSampleOptions() {
sampleOptionsController?.abort()
sampleOptionsGuard.begin()
sampleOptions.value = []
sampleOptionsPage.value = 0
sampleOptionsTotal.value = 0
sampleOptionsLoading.value = false
sampleOptionsRequestPending.value = false
return loadMoreSampleOptions()
}
async function loadMoreSampleOptions() {
if (context.value.module !== 'freight' || !contract.value.usesPagedSampleOptions?.(action.value)) return
if (sampleOptionsRequestPending.value) return
if (sampleOptionsFinished.value && sampleOptionsPage.value > 0) {
sampleOptionsLoading.value = false
return
}
const requestToken = sampleOptionsGuard.begin()
sampleOptionsController?.abort()
sampleOptionsController = new AbortController()
const nextPage = sampleOptionsPage.value + 1
sampleOptionsRequestPending.value = true
sampleOptionsLoading.value = true
try {
const result = await fetchFreightSampleOptions(
context.value,
{ keyword: sampleKeyword.value, page: 1, pageSize: 100 },
{
keyword: sampleKeyword.value.trim(),
sampleSource: sampleSource.value,
page: nextPage,
pageSize: SAMPLE_OPTION_PAGE_SIZE
},
{ signal: sampleOptionsController.signal }
)
if (!mountGuard.isCurrent(mountToken) || !sampleOptionsGuard.isCurrent(requestToken)) return
const rows = result?.list || []
sampleOptions.value = contract.value.sampleOptions?.(action.value, [], rows) || []
for (const item of sampleOptions.value) selectedSampleMap.set(sampleKey(item), item)
const normalized = contract.value.sampleOptions?.(action.value, [], rows) || []
sampleOptions.value = mergeFreightSamplePage(sampleOptions.value, normalized)
sampleOptionsPage.value = nextPage
sampleOptionsTotal.value = Math.max(0, Number(result?.total) || 0)
rememberFreightSampleRows(selectedSampleMap, sampleOptions.value)
} catch (error) {
if (error?.name === 'AbortError' || !mountGuard.isCurrent(mountToken) || !sampleOptionsGuard.isCurrent(requestToken)) return
showToast(marketErrorMessage(error, '样本选项加载失败'))
} finally {
if (mountGuard.isCurrent(mountToken) && sampleOptionsGuard.isCurrent(requestToken)) {
sampleOptionsRequestPending.value = false
sampleOptionsLoading.value = false
}
}
}
function loadUnlinkOptions(detail) {
sampleOptions.value = contract.value.sampleOptions?.(action.value, detail?.items, []) || []
for (const item of sampleOptions.value) selectedSampleMap.set(sampleKey(item), item)
sampleOptionsPage.value = 1
sampleOptionsTotal.value = sampleOptions.value.length
rememberFreightSampleRows(selectedSampleMap, sampleOptions.value)
}
function sampleSourceLabel(source) {
return source === 'research_donation' ? '科研捐赠' : '细胞存储'
}
function sampleOptionDisabled(item) {
return action.value !== 'unlink' && Boolean(item?.freightId)
}
function resolveCreateProtocolSource() {
@@ -302,6 +596,7 @@ function initialForm(detail) {
samples: [],
samplePayments: [],
payment: {},
paymentDetails: [],
attachments: {},
confirm: { confirmed: false }
}
@@ -309,14 +604,25 @@ function initialForm(detail) {
return {}
}
if (context.value.module === 'protocol') {
const paymentModelVersion = detail.paymentModelVersion ?? detail.basic?.paymentModelVersion
const legacyPayment = detail.source === 'cell_storage' && Number(paymentModelVersion || 1) < 2
? createLegacyPaymentState({
payment: detail.payment,
paymentDetails: detail.paymentDetails
})
: null
return {
...(detail.protocol || detail.basic || detail),
source: detail.source,
customer: detail.customer || {},
paymentModelVersion,
customer: initLinkedCustomerNames(detail.customer || {}),
collection: detail.collection || {},
samples: detail.samples || [],
samplePayments: detail.samplePayments || [],
payment: detail.payment || {},
depositTransfers: detail.depositTransfers || [],
presignDepositHint: detail.presignDepositHint || null,
payment: legacyPayment?.payment || detail.payment || {},
paymentDetails: legacyPayment?.details || detail.paymentDetails || [],
attachments: detail.attachments || {},
confirm: { confirmed: false }
}
@@ -377,6 +683,20 @@ async function load() {
}
}
formData.value = initialForm(detail)
transferDirty.value = false
if (
context.value.module === 'protocol' &&
formData.value.source === 'cell_storage' &&
isProtocolV2.value
) {
protocolTotalFollowSamples.value = shouldFollowProtocolTotal(
formData.value.protocolTotalAmount,
sumSampleContractAmounts(formData.value.samplePayments || [])
)
syncProtocolTotalFromSamples()
} else {
protocolTotalFollowSamples.value = true
}
formOptions.value = hydrateCurrentOptions(options, detail)
if (context.value.module === 'freight') {
if (action.value === 'unlink') loadUnlinkOptions(detail)
@@ -391,8 +711,36 @@ async function load() {
}
}
function patchUploadFieldValue(section, field, index, nextValue) {
const current = sectionValue(section)
if (section.repeat) {
const rows = current.map((row, rowIndex) =>
rowIndex === index ? { ...row, [field.key]: nextValue } : row
)
setSectionValue(section, rows)
return
}
setSectionValue(section, {
...current,
[field.key]: nextValue
})
}
function readUploadFieldValue(section, field, index) {
const current = sectionValue(section)
if (section.repeat) return current?.[index]?.[field.key]
return current?.[field.key]
}
async function uploadField({ section, field, index, file }) {
if (submitting.value) return
if (submitting.value || !file) return
const pending = createPendingMarketAttachment(file)
patchUploadFieldValue(
section,
field,
index,
withPendingMarketAttachment(readUploadFieldValue(section, field, index), pending)
)
const controller = new AbortController()
uploadControllers.add(controller)
uploadPending.value += 1
@@ -401,25 +749,91 @@ async function uploadField({ section, field, index, file }) {
if (!mountGuard.isCurrent(mountToken)) return
const url = uploaded?.fileUrl || uploaded?.url
if (!url) throw new Error('上传结果缺少文件地址')
const attachment = {
...uploaded,
fileUrl: url,
fileName: uploaded?.fileName || file.name
}
const current = sectionValue(section)
if (section.repeat) {
const rows = current.map((row, rowIndex) => rowIndex === index
? { ...row, [field.key]: appendMarketAttachment(row[field.key], attachment) }
: row)
setSectionValue(section, rows)
} else {
setSectionValue(section, {
...current,
[field.key]: appendMarketAttachment(current[field.key], attachment)
})
}
patchUploadFieldValue(
section,
field,
index,
resolvePendingMarketAttachment(
readUploadFieldValue(section, field, index),
pending._pendingId,
{
...uploaded,
fileUrl: url,
fileName: uploaded?.fileName || file.name
}
)
)
showSuccessToast('上传成功')
} catch (error) {
if (mountGuard.isCurrent(mountToken)) {
patchUploadFieldValue(
section,
field,
index,
dropPendingMarketAttachment(
readUploadFieldValue(section, field, index),
pending._pendingId
)
)
}
if (error?.name === 'AbortError' || !mountGuard.isCurrent(mountToken)) return
showToast(marketErrorMessage(error, '附件上传失败'))
} finally {
uploadControllers.delete(controller)
if (mountGuard.isCurrent(mountToken)) uploadPending.value -= 1
}
}
async function uploadV1PaymentAttachment({ index, file }) {
if (submitting.value || !file) return
const pending = createPendingMarketAttachment(file)
const begin = [...(formData.value.paymentDetails || [])]
if (!begin[index]) return
begin[index] = {
...begin[index],
transferAttachments: withPendingMarketAttachment(begin[index].transferAttachments, pending)
}
formData.value = { ...formData.value, paymentDetails: begin }
dirty.value = true
const controller = new AbortController()
uploadControllers.add(controller)
uploadPending.value += 1
try {
const uploaded = await uploadMarketAttachment(context.value, file, { signal: controller.signal })
if (!mountGuard.isCurrent(mountToken)) return
const url = uploaded?.fileUrl || uploaded?.url
if (!url) throw new Error('上传结果缺少文件地址')
const details = [...(formData.value.paymentDetails || [])]
if (!details[index]) return
details[index] = {
...details[index],
transferAttachments: resolvePendingMarketAttachment(
details[index].transferAttachments,
pending._pendingId,
{
...uploaded,
fileUrl: url,
fileName: uploaded?.fileName || file.name
}
)
}
formData.value = { ...formData.value, paymentDetails: details }
dirty.value = true
showSuccessToast('上传成功')
} catch (error) {
if (mountGuard.isCurrent(mountToken)) {
const details = [...(formData.value.paymentDetails || [])]
if (details[index]) {
details[index] = {
...details[index],
transferAttachments: dropPendingMarketAttachment(
details[index].transferAttachments,
pending._pendingId
)
}
formData.value = { ...formData.value, paymentDetails: details }
}
}
if (error?.name === 'AbortError' || !mountGuard.isCurrent(mountToken)) return
showToast(marketErrorMessage(error, '附件上传失败'))
} finally {
@@ -443,19 +857,63 @@ function buildPayload() {
}
if (context.value.module === 'protocol') {
const fields = pickConfiguredPayload(visibleSections.value, formData.value)
return mergeProtocolSamplePayments(fields, formData.value.samplePayments)
const withLinks = mergeProtocolCustomerLinks(fields, formData.value.customer)
const withSamples = buildProtocolSamplesPayload(
withLinks,
formData.value.samples || []
)
if (formData.value.source !== 'cell_storage') return withSamples
if (isProtocolV1.value) {
const legacy = serializeLegacyPaymentState(createLegacyPaymentState({
payment: formData.value.payment,
paymentDetails: formData.value.paymentDetails
}))
const payload = { ...withSamples, ...legacy }
delete payload.samplePayments
return payload
}
const payload = mergeProtocolSamplePayments(withSamples, formData.value.samplePayments)
delete payload.payment
delete payload.paymentDetails
return payload
}
return pickConfiguredPayload(visibleSections.value, formData.value)
}
async function submit() {
if (submitting.value || uploadPending.value > 0) return
const transferMessage = context.value.module === 'protocol'
&& isProtocolV2.value
? protocolTransferSaveBlockMessage(transferDirty.value)
: ''
if (transferMessage) {
showToast(transferMessage)
return
}
const validationMessage = validateRequiredFields(visibleSections.value, formData.value)
if (validationMessage) {
showToast(validationMessage)
return
}
if (context.value.module === 'protocol' && formData.value.source === 'cell_storage') {
if (
context.value.module === 'protocol' &&
formData.value.source === 'cell_storage' &&
isProtocolV1.value
) {
const legacyHint = validateLegacyPaymentState(createLegacyPaymentState({
payment: formData.value.payment,
paymentDetails: formData.value.paymentDetails
}))
if (legacyHint) {
showToast(legacyHint)
return
}
}
if (
context.value.module === 'protocol' &&
formData.value.source === 'cell_storage' &&
isProtocolV2.value
) {
for (const pay of formData.value.samplePayments || []) {
const requiredHint = sampleScheduleRequiredFieldsHint(pay)
if (requiredHint) {
@@ -493,6 +951,18 @@ async function submit() {
showToast('缺少应缴期标识')
return
}
if (context.value.module === 'freight' && ['link', 'unlink'].includes(action.value)) {
try {
await showConfirmDialog({
title: action.value === 'link' ? '确认关联样本' : '确认取消运费',
message: action.value === 'link'
? `确认将已选的 ${payload.items.length} 个样本关联到当前运费批次吗?`
: `确认取消已选 ${payload.items.length} 个样本的运费关联吗?`
})
} catch {
return
}
}
submitting.value = true
submitController = new AbortController()
try {
@@ -25,15 +25,28 @@
{{ tab.text }}
</button>
</div>
<van-search
v-model="keyword"
shape="round"
clearable
:placeholder="searchPlaceholder"
@update:model-value="queueSearch"
@search="search"
@clear="search"
/>
<div class="market-list__search-row">
<van-search
v-model="keyword"
shape="round"
clearable
:placeholder="searchPlaceholder"
@update:model-value="queueSearch"
@search="search"
@clear="search"
/>
<button
v-if="listFilterFields.length"
type="button"
class="market-list__filter-button"
aria-label="打开筛选"
@click="filterSheetVisible = true"
>
<van-icon name="filter-o" />
<span>筛选</span>
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
</button>
</div>
</div>
<div v-if="loading && !items.length" class="market-state">
@@ -41,12 +54,30 @@
</div>
<div v-else-if="errorMessage && !items.length" class="market-state">
<van-empty image="error" :description="errorMessage">
<van-button type="primary" size="small" round @click="loadPage(true)">重新加载</van-button>
<van-button type="primary" round @click="loadPage(true)">重新加载</van-button>
</van-empty>
</div>
<van-empty v-else-if="!items.length" description="暂无相关数据" />
<div v-if="batchDeleteAvailable" class="market-list__batch-toolbar">
<template v-if="batchSelectionMode">
<span>已选 {{ selectedRecordIds.length }} 项</span>
<button type="button" @click="toggleSelectAll">
{{ allDeletableSelected ? '取消全选' : '全选' }}
</button>
<button type="button" @click="cancelBatchSelection">取消</button>
<button
type="button"
class="is-danger"
:disabled="!selectedRecordIds.length || batchDeleting"
@click="deleteSelectedProtocols"
>批量删除</button>
</template>
<button v-else type="button" class="market-list__batch-start" @click="batchSelectionMode = true">
<van-icon name="delete-o" />批量删除
</button>
</div>
<van-list
v-else
v-if="items.length"
class="market-list"
:loading="loadingMore"
:finished="!hasMore || Boolean(errorMessage)"
@@ -65,19 +96,24 @@
:index="index"
:module="context.module"
:role="context.marketRole"
:selectable="batchSelectionMode && canDeleteProtocol(item)"
:selected="selectedRecordIds.includes(String(item.id || ''))"
:selection-mode="batchSelectionMode"
@action="handleCardAction"
@toggle-select="toggleRecordSelection"
/>
</van-list>
<button v-if="canCreate" type="button" class="market-list__fab" :aria-label="`新增${moduleTitle}`" @click="openCreate">
<van-icon name="plus" />
</button>
<van-action-sheet
v-model:show="auditSheetVisible"
title="审核协议单"
cancel-text="取消"
:actions="auditOptions"
@select="submitAudit"
<MarketFilterSheet
v-model:show="filterSheetVisible"
v-model="listFilters"
:fields="listFilterFields"
:options="filterOptions"
@apply="applyListFilters"
@reset="resetListFilters"
/>
</MarketPageShell>
</template>
@@ -86,8 +122,14 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { fetchMarketList, performMarketAction } from '../../api/market.js'
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
import { intersectAllowedActions } from '../../market/action-policy.js'
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
import {
activeFilterCount,
buildListParams,
createListFilters
} from '../../market/list-filters.js'
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
import { marketErrorMessage } from '../../market/market-error.js'
import { getMarketModule } from '../../market/module-registry.js'
@@ -97,6 +139,7 @@ import {
MARKET_ROLE_TITLES,
marketRecordNavigationQuery,
pickMarketNavigationQuery,
resolvePaymentChaseView,
shouldLoadMarketListAfterDetail
} from '../../market/routes.js'
import {
@@ -107,6 +150,7 @@ import {
startMarketListRequest
} from '../../market/request-state.js'
import MarketListCard from '../../market/components/MarketListCard.vue'
import MarketFilterSheet from '../../market/components/MarketFilterSheet.vue'
import MarketPageShell from '../../market/components/MarketPageShell.vue'
const PAGE_SIZE = 10
@@ -151,13 +195,21 @@ const searchPlaceholder = computed(() =>
marketSearchPlaceholder(context.value.module, listStatus.value)
)
const keyword = ref('')
const filterSheetVisible = ref(false)
const listFilters = ref(createListFilters(context.value.module, listStatus.value))
const filterOptions = ref({})
const listFilterFields = computed(() =>
(moduleContract.value.filterFields || []).filter((field) =>
!field.source || field.source === listFilters.value.source
)
)
const activeFilters = computed(() =>
activeFilterCount(context.value.module, listFilters.value)
)
const collectionActions = ref([])
const auditSheetVisible = ref(false)
const pendingAuditItem = ref(null)
const auditOptions = [
{ name: '审核通过', value: 'shtg', color: '#0a9f6d' },
{ name: '审核退回', value: 'shth', color: '#d14343' }
]
const batchSelectionMode = ref(false)
const batchDeleting = ref(false)
const selectedRecordIds = ref([])
const listState = ref(createMarketListState())
const items = computed(() => listState.value.items)
const total = computed(() => listState.value.total)
@@ -170,6 +222,15 @@ const canCreate = computed(() => intersectAllowedActions(
context.value.marketRole,
collectionActions.value
).includes('create'))
const deletableItems = computed(() => items.value.filter(canDeleteProtocol))
const batchDeleteAvailable = computed(() =>
context.value.module === 'protocol' && deletableItems.value.length > 0
)
const allDeletableSelected = computed(() =>
deletableItems.value.length > 0 && deletableItems.value.every((item) =>
selectedRecordIds.value.includes(String(item.id || ''))
)
)
let requestSequence = 0
let abortController = null
let searchTimer = null
@@ -185,14 +246,18 @@ function normalizeList(result) {
async function loadPage(reset) {
if (!reset && (!hasMore.value || listState.value.loading || listState.value.loadingMore)) return
if (reset) collectionActions.value = []
if (reset) {
collectionActions.value = []
batchSelectionMode.value = false
selectedRecordIds.value = []
}
const requestId = ++requestSequence
abortController?.abort()
abortController = new AbortController()
const started = startMarketListRequest(listState.value, { requestId, reset })
listState.value = started.state
const requestContext = { ...context.value }
const listParams = moduleContract.value.listParams?.({ status: listStatus.value }) || {}
const listParams = buildListParams(context.value.module, listFilters.value)
try {
const result = normalizeList(await fetchMarketList(
requestContext,
@@ -229,6 +294,34 @@ function queueSearch() {
searchTimer = window.setTimeout(search, 350)
}
async function loadListFilterOptions() {
const optionKeys = listFilterFields.value
.map((field) => field.optionKey)
.filter(Boolean)
if (!optionKeys.length) {
filterOptions.value = {}
return
}
try {
const result = await fetchMarketFormOptions(context.value, {})
filterOptions.value = Object.fromEntries(
optionKeys.map((key) => [key, Array.isArray(result?.[key]) ? result[key] : []])
)
} catch {
filterOptions.value = {}
}
}
function applyListFilters(value) {
listFilters.value = value
loadPage(true)
}
function resetListFilters(value) {
listFilters.value = value
loadPage(true)
}
function openDetail(item, extraQuery = {}) {
const target = moduleContract.value.detailTarget?.(item)
const targetModule = target?.module || context.value.module
@@ -276,35 +369,6 @@ function openForm(item, action) {
})
}
function auditProtocol(item) {
pendingAuditItem.value = item
auditSheetVisible.value = true
}
async function submitAudit(selected) {
const item = pendingAuditItem.value
auditSheetVisible.value = false
if (!item) return
try {
await showConfirmDialog({ title: '确认审核', message: `确认${selected.name}吗?` })
} catch {
pendingAuditItem.value = null
return
}
try {
await performMarketAction(context.value, 'audit', {
id: item.id,
data: { source: item.source, auditStatus: selected.value, auditRemark: '' }
})
showSuccessToast('审核已提交')
await loadPage(true)
} catch (error) {
if (error) showToast(marketErrorMessage(error))
} finally {
pendingAuditItem.value = null
}
}
async function deleteProtocol(item) {
try {
await showConfirmDialog({ title: '删除协议单', message: `确认删除 ${item.protocolNo || item.code || ''} 吗?` })
@@ -323,11 +387,73 @@ async function deleteProtocol(item) {
}
}
function canDeleteProtocol(item) {
return context.value.module === 'protocol' && intersectAllowedActions(
'protocol',
context.value.marketRole,
item?.allowedActions
).includes('delete')
}
function toggleRecordSelection(item) {
if (!canDeleteProtocol(item)) return
const id = String(item?.id || '')
if (!id) return
selectedRecordIds.value = selectedRecordIds.value.includes(id)
? selectedRecordIds.value.filter((value) => value !== id)
: [...selectedRecordIds.value, id]
}
function toggleSelectAll() {
selectedRecordIds.value = allDeletableSelected.value
? []
: deletableItems.value.map((item) => String(item.id))
}
function cancelBatchSelection() {
batchSelectionMode.value = false
selectedRecordIds.value = []
}
async function deleteSelectedProtocols() {
if (!selectedRecordIds.value.length || batchDeleting.value) return
const selected = selectedRecordIds.value
.map((id) => items.value.find((item) => String(item.id || '') === id))
.filter(Boolean)
try {
await showConfirmDialog({
title: '批量删除协议单',
message: `确认删除已选的 ${selected.length} 条协议单吗?`
})
} catch {
return
}
batchDeleting.value = true
const result = await deleteMarketItemsSequentially(selected, (item) =>
performMarketAction(context.value, 'delete', {
id: item.id,
data: { source: item.source }
})
)
batchDeleting.value = false
if (result.failed) {
showToast(
`已删除 ${result.succeeded} 条,失败 ${result.failed} 条,未处理 ${result.unprocessed} 条:${marketErrorMessage(result.error)}`
)
} else {
showSuccessToast(`已删除 ${result.succeeded} 条`)
}
await loadPage(true)
}
function handleCardAction(action, item) {
const chaseView = context.value.module === 'payment-chase'
? resolvePaymentChaseView(action)
: ''
if (chaseView) return openDetail(item, { view: chaseView })
if (action === 'view' || action === 'view-payment' || action === 'view-payment-detail') return openDetail(item)
if (action === 'view-storage-qc') return openDetail(item, { focus: 'storage-qc' })
if (action === 'view-protocol') return openProtocol(item)
if (action === 'audit') return auditProtocol(item)
if (action === 'delete') return deleteProtocol(item)
if (['edit', 'register-transfer', 'link', 'unlink'].includes(action)) {
return openForm(item, action)
@@ -348,6 +474,7 @@ function switchListStatus(value) {
const next = String(value || '').trim()
if (!SOURCE_TAB_VALUES.includes(next) || next === listStatus.value) return
listStatus.value = next
listFilters.value = createListFilters('protocol', next)
keyword.value = ''
collectionActions.value = []
const nextQuery = {
@@ -364,11 +491,17 @@ watch(
() => {
keyword.value = ''
collectionActions.value = []
cancelBatchSelection()
listStatus.value = usesSourceTabs(context.value.module)
? resolveSourceListStatus(route.query)
: ''
listFilters.value = createListFilters(context.value.module, listStatus.value)
filterSheetVisible.value = false
clearStalePointerLocks()
if (!childActive.value) loadPage(true)
if (!childActive.value) {
loadListFilterOptions()
loadPage(true)
}
}
)
@@ -381,7 +514,10 @@ watch(detailId, (currentDetailId, previousDetailId) => {
onMounted(() => {
clearStalePointerLocks()
if (!childActive.value) loadPage(true)
if (!childActive.value) {
loadListFilterOptions()
loadPage(true)
}
})
onBeforeUnmount(() => {