提交
This commit is contained in:
1 parent
a2125374a8
commit
3950b5c334
157 files changed
+57538
-22359
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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, '预签约订单(专员)')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || '—' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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' } }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user