feat: 为现场日历增加单据类型并同步派车记录,减少重复录入。
按起止地关键词生成现场出入库计划,同步来源修改和删除,保留历史记录不补录。调整卡片及复制文本的单据类型格式,并记录测试库和生产库迁移结果。
This commit is contained in:
1 parent
1ae8603045
commit
fcbf8a9b65
17 files changed
+732
-42
No files matched your search
@@ -148,3 +148,47 @@ test('数量是可空文本,复制逐条展示,日汇总仍只计车次', ()
|
||||
)
|
||||
assert.equal(validateManualDraft({ ...row('1'), quantity: '1吨' }), '数量须为数字,最多两位小数')
|
||||
})
|
||||
|
||||
test('现场复制隐藏起止地和备注,单据类型放在每条记录末尾的中文括号中', () => {
|
||||
for (const documentType of ['入库计划', '出库计划', '出入库计划', '现场调拨']) {
|
||||
const record = {
|
||||
...row('site'),
|
||||
calendarKind: 'site' as const,
|
||||
documentType,
|
||||
quantity: '32.50',
|
||||
remark: '内部备注'
|
||||
}
|
||||
assert.equal(validateManualDraft(record), '')
|
||||
assert.deepEqual(
|
||||
manualCopyText('2026-10-26', [record, { ...record, id: 'site-2', quantity: '' }]).split('\n'),
|
||||
[
|
||||
'2026-10-26 派车计划',
|
||||
' ·皖H12345',
|
||||
` 液碱 32.50吨(${documentType})`,
|
||||
` 液碱(${documentType})`
|
||||
]
|
||||
)
|
||||
assert.equal(
|
||||
manualCalendarDays([record], '2026-10').find((d) => d.date === record.dispatchDate)?.rows
|
||||
.length,
|
||||
1
|
||||
)
|
||||
}
|
||||
for (const documentType of [null, undefined, '', ' ']) {
|
||||
const record = {
|
||||
...row('site'),
|
||||
calendarKind: 'site' as const,
|
||||
documentType,
|
||||
remark: '内部备注'
|
||||
}
|
||||
assert.equal(manualCopyText('2026-10-26', [record]), '2026-10-26 派车计划\n ·皖H12345\n 液碱')
|
||||
}
|
||||
})
|
||||
|
||||
test('单据类型选填且最多50个字符', () => {
|
||||
assert.equal(validateManualDraft({ ...row('site'), calendarKind: 'site', documentType: '' }), '')
|
||||
assert.match(
|
||||
validateManualDraft({ ...row('site'), calendarKind: 'site', documentType: '字'.repeat(51) }),
|
||||
/单据类型.*50/
|
||||
)
|
||||
})
|
||||
@@ -14,7 +14,8 @@ try {
|
||||
viewport: mobile ? { width: 375, height: 812 } : { width: 1440, height: 1000 }
|
||||
})
|
||||
const page = await context.newPage()
|
||||
await page.clock.setFixedTime(new Date('2026-10-26T04:00:00Z'))
|
||||
// 固定起始日期但保持时钟前进;冻结 Date.now 会触发 Vue 嵌套点击事件的时间戳保护。
|
||||
await page.clock.install({ time: new Date('2026-10-26T04:00:00Z') })
|
||||
page.setDefaultTimeout(20000)
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') console.log('CONSOLE', msg.text())
|
||||
@@ -188,6 +189,13 @@ try {
|
||||
await editor.getByRole('combobox', { name: '产品名称', exact: true }).click()
|
||||
await page.getByRole('option', { name: '液碱', exact: true }).click()
|
||||
}
|
||||
if (kind === 'site') {
|
||||
await editor.getByRole('combobox', { name: '单据类型', exact: true }).click()
|
||||
await page.getByRole('option', { name: '出库计划', exact: true }).waitFor()
|
||||
await page.getByRole('option', { name: '入库计划', exact: true }).click()
|
||||
} else {
|
||||
assert.equal(await editor.getByRole('combobox', { name: '单据类型', exact: true }).count(), 0)
|
||||
}
|
||||
// 起止地留空也应保存成功,编辑后仍可补填。
|
||||
await editor.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await editor.waitFor({ state: 'hidden' })
|
||||
@@ -195,8 +203,16 @@ try {
|
||||
assert.equal(rows[0].origin, '')
|
||||
assert.equal(rows[0].destination, '')
|
||||
assert.equal(rows[0].calendarKind, kind)
|
||||
if (kind === 'site') assert.equal(rows[0].documentType, '入库计划')
|
||||
await page.locator('[data-testid="manual-record"]').first().click()
|
||||
if (kind === 'site')
|
||||
await page.locator('.el-dialog:visible').getByText('入库计划', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
if (kind === 'site') {
|
||||
assert.match(await editor.innerText(), /入库计划/)
|
||||
await editor.getByRole('combobox', { name: '单据类型', exact: true }).fill('现场调拨')
|
||||
await page.getByRole('option', { name: '现场调拨', exact: true }).click()
|
||||
}
|
||||
await editor.getByPlaceholder('请输入始发地').fill('山峡')
|
||||
await editor.getByPlaceholder('请输入目的地').fill('云栋')
|
||||
failSave = true
|
||||
@@ -214,21 +230,32 @@ try {
|
||||
fullPage: false
|
||||
})
|
||||
assert.match(await card.innerText(), /山峡 → 云栋/)
|
||||
if (kind === 'site') {
|
||||
assert.equal(rows[0].documentType, '现场调拨')
|
||||
assert.equal(await card.locator('.route').innerText(), '山峡 → 云栋(现场调拨)')
|
||||
assert.equal(await card.locator('.el-tag').count(), 0)
|
||||
}
|
||||
await page
|
||||
.locator('[data-date="2026-10-26"]')
|
||||
.getByRole('button', { name: mobile ? '复制' : '复制2026-10-26派车计划', exact: true })
|
||||
.click()
|
||||
assert.match(
|
||||
await page.evaluate(() => navigator.clipboard.readText()),
|
||||
/皖H12345.*液碱.*山峡 → 云栋/s
|
||||
)
|
||||
const copiedText = await page.evaluate(() => navigator.clipboard.readText())
|
||||
if (kind === 'site') {
|
||||
assert.equal(copiedText, '2026-10-26 派车计划\n ·皖H12345\n 液碱(现场调拨)')
|
||||
} else {
|
||||
assert.match(copiedText, /皖H12345.*液碱.*山峡 → 云栋/s)
|
||||
}
|
||||
|
||||
await card.click()
|
||||
await page.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
await editor.getByPlaceholder('请输入目的地').fill('安庆')
|
||||
await editor.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await editor.waitFor({ state: 'hidden' })
|
||||
await page.getByText('山峡 → 安庆', { exact: true }).first().waitFor()
|
||||
if (kind === 'site') assert.equal(rows[0].documentType, '现场调拨')
|
||||
await page
|
||||
.getByText(kind === 'site' ? '山峡 → 安庆(现场调拨)' : '山峡 → 安庆', { exact: true })
|
||||
.first()
|
||||
.waitFor()
|
||||
await page.locator('[data-testid="manual-record"]').first().click()
|
||||
await page.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await page.getByRole('button', { name: mobile ? '确认删除' : '确定', exact: true }).click()
|
||||
@@ -266,6 +293,31 @@ try {
|
||||
calendarKind: kind
|
||||
}
|
||||
]
|
||||
if (kind === 'site') {
|
||||
allowManage = true
|
||||
rows[0].sourceRecordId = 'source-fixture'
|
||||
rows[0].documentType = '出入库计划'
|
||||
rows.push({
|
||||
...rows[0],
|
||||
id: 'second-site',
|
||||
sourceRecordId: 'second-source',
|
||||
documentType: '入库计划'
|
||||
})
|
||||
await page.getByRole('tab', { name: title, exact: true }).click()
|
||||
await page.locator('.pair-trip').nth(1).waitFor()
|
||||
assert.match(await page.locator('.pair-trip').nth(0).innerText(), /山峡 → 云栋(出入库计划)/)
|
||||
assert.match(await page.locator('.pair-trip').nth(1).innerText(), /山峡 → 云栋(入库计划)/)
|
||||
assert.equal(await page.locator('.manual-day-content .el-tag').count(), 0)
|
||||
await page.locator('[data-testid="manual-record"]').first().click()
|
||||
await page.getByText('自动同步,请在来源派车日历修改或删除', { exact: true }).waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0)
|
||||
assert.equal(await page.getByRole('button', { name: '删除', exact: true }).count(), 0)
|
||||
await page.locator('.el-dialog:visible .el-dialog__headerbtn').click()
|
||||
await page.getByRole('tab', { name: '实际派车信息', exact: true }).click()
|
||||
allowManage = false
|
||||
delete rows[0].sourceRecordId
|
||||
rows.pop()
|
||||
}
|
||||
await page.getByRole('tab', { name: title, exact: true }).click()
|
||||
await page.locator('[data-testid="manual-record"]').first().waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: '新增派车计划', exact: true }).count(), 0)
|
||||
|
||||
@@ -10,10 +10,12 @@ export interface DispatchCalendarBody {
|
||||
destination: string
|
||||
quantity: string | null
|
||||
calendarKind: DispatchCalendarKind
|
||||
documentType?: string | null
|
||||
remark: string
|
||||
}
|
||||
export interface DispatchCalendarRecord extends DispatchCalendarBody {
|
||||
id: string
|
||||
sourceRecordId?: string | null
|
||||
licensePlate: string
|
||||
productName: string
|
||||
createTime: string | null
|
||||
|
||||
@@ -171,11 +171,7 @@
|
||||
:label="row.productName" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="数量"
|
||||
><el-input
|
||||
v-model="draft.quantity"
|
||||
maxlength="15"
|
||||
placeholder="选填"
|
||||
aria-label="数量"
|
||||
><el-input v-model="draft.quantity" maxlength="15" placeholder="选填" aria-label="数量"
|
||||
><template #append>吨</template></el-input
|
||||
></el-form-item
|
||||
>
|
||||
@@ -185,6 +181,21 @@
|
||||
<el-form-item label="目的地"
|
||||
><el-input v-model="draft.destination" maxlength="255" placeholder="请输入目的地"
|
||||
/></el-form-item>
|
||||
<el-form-item v-if="calendarKind === 'site'" label="单据类型" class="form-col-span-2">
|
||||
<el-select
|
||||
v-model="draft.documentType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
popper-class="manual-calendar-popper"
|
||||
placeholder="选择或输入单据类型"
|
||||
aria-label="单据类型"
|
||||
>
|
||||
<el-option label="入库计划" value="入库计划" />
|
||||
<el-option label="出库计划" value="出库计划" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2"
|
||||
><el-input
|
||||
v-model="draft.remark"
|
||||
@@ -222,7 +233,7 @@
|
||||
width="720px"
|
||||
>
|
||||
<template #hero-actions
|
||||
><template v-if="canManage && viewRow"
|
||||
><template v-if="canManage && viewRow && !viewRow.sourceRecordId"
|
||||
><el-button :disabled="saving" @click="editViewed"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
><el-button type="danger" :disabled="saving" @click="deleteViewed"
|
||||
@@ -302,6 +313,18 @@
|
||||
fields: viewRow.value
|
||||
? [
|
||||
{ label: '派车日期', value: viewRow.value.dispatchDate },
|
||||
...(viewRow.value.sourceRecordId
|
||||
? [{ label: '维护方式', value: '自动同步,请在来源派车日历修改或删除' }]
|
||||
: []),
|
||||
...(viewRow.value.calendarKind === 'site'
|
||||
? [
|
||||
{
|
||||
label: '单据类型',
|
||||
value: viewRow.value.documentType,
|
||||
tagType: 'primary' as const
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ label: '车牌号', value: viewRow.value.licensePlate },
|
||||
{ label: '产品名称', value: viewRow.value.productName },
|
||||
{ label: '始发地', value: viewRow.value.origin },
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
}}</span>
|
||||
1车</span
|
||||
>
|
||||
<span class="route">{{ group.rows[0].origin }} → {{ group.rows[0].destination }}</span>
|
||||
<span class="route"
|
||||
>{{ group.rows[0].origin }} → {{ group.rows[0].destination
|
||||
}}<template v-if="group.rows[0].calendarKind === 'site' && group.rows[0].documentType"
|
||||
>({{ group.rows[0].documentType }})</template
|
||||
></span
|
||||
>
|
||||
<span v-if="group.rows[0].remark" class="remark">{{ group.rows[0].remark }}</span>
|
||||
</component>
|
||||
<div v-else class="pair">
|
||||
@@ -58,7 +63,10 @@
|
||||
><span v-if="manualQuantityLabel(row.quantity)" class="quantity">{{
|
||||
' ' + manualQuantityLabel(row.quantity)
|
||||
}}</span>
|
||||
· {{ row.origin }} → {{ row.destination }}</span
|
||||
· {{ row.origin }} → {{ row.destination
|
||||
}}<template v-if="row.calendarKind === 'site' && row.documentType"
|
||||
>({{ row.documentType }})</template
|
||||
></span
|
||||
>
|
||||
<span v-if="row.remark" class="remark">{{ row.remark }}</span>
|
||||
</component>
|
||||
|
||||
@@ -37,10 +37,7 @@ function byCreateOrder(a: DispatchCalendarRecord, b: DispatchCalendarRecord) {
|
||||
}
|
||||
|
||||
function groupByVehicle(rows: DispatchCalendarRecord[]) {
|
||||
const vehicles = new Map<
|
||||
string,
|
||||
{ id: string; plate: string; rows: DispatchCalendarRecord[] }
|
||||
>()
|
||||
const vehicles = new Map<string, { id: string; plate: string; rows: DispatchCalendarRecord[] }>()
|
||||
for (const row of rows) {
|
||||
if (!vehicles.has(row.vehicleId))
|
||||
vehicles.set(row.vehicleId, { id: row.vehicleId, plate: row.licensePlate, rows: [] })
|
||||
@@ -91,9 +88,13 @@ export function manualQuantityLabel(quantity?: string | null) {
|
||||
}
|
||||
|
||||
function manualTripText(row: DispatchCalendarRecord) {
|
||||
const remark = row.remark ? `(${row.remark.replace(/\s+/g, ' ')})` : ''
|
||||
const quantity = manualQuantityLabel(row.quantity)
|
||||
const amount = quantity ? ` ${quantity}` : ''
|
||||
if (row.calendarKind === 'site') {
|
||||
const documentType = (row.documentType || '').trim().replace(/\s+/g, ' ')
|
||||
return `${row.productName}${amount}${documentType ? `(${documentType})` : ''}`
|
||||
}
|
||||
const remark = row.remark ? `(${row.remark.replace(/\s+/g, ' ')})` : ''
|
||||
return `${row.productName}${amount} ${row.origin} → ${row.destination}${remark}`
|
||||
}
|
||||
|
||||
@@ -123,5 +124,6 @@ export function validateManualDraft(body: DispatchCalendarBody): string {
|
||||
if (manualQuantityText(body.quantity) && !QUANTITY_TEXT.test(manualQuantityText(body.quantity)))
|
||||
return '数量须为数字,最多两位小数'
|
||||
if (body.remark.length > 500) return '备注最多500个字符'
|
||||
if ((body.documentType || '').trim().length > 50) return '单据类型最多50个字符'
|
||||
return ''
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export function useManualCalendar(
|
||||
destination: '',
|
||||
quantity: '',
|
||||
calendarKind,
|
||||
documentType: '',
|
||||
remark: ''
|
||||
})
|
||||
const draft = reactive<DispatchCalendarBody>(blank(selectedDate.value))
|
||||
@@ -113,7 +114,7 @@ export function useManualCalendar(
|
||||
editorOpen.value = true
|
||||
}
|
||||
function openEdit(row: DispatchCalendarRecord) {
|
||||
if (!canManage.value || !live() || saving.value) return
|
||||
if (!canManage.value || !live() || saving.value || row.sourceRecordId) return
|
||||
editingId.value = row.id
|
||||
Object.assign(draft, {
|
||||
dispatchDate: row.dispatchDate,
|
||||
@@ -123,6 +124,7 @@ export function useManualCalendar(
|
||||
destination: row.destination,
|
||||
quantity: row.quantity || '',
|
||||
calendarKind,
|
||||
documentType: row.documentType || '',
|
||||
remark: row.remark || ''
|
||||
})
|
||||
formError.value = ''
|
||||
@@ -139,6 +141,7 @@ export function useManualCalendar(
|
||||
origin: draft.origin.trim(),
|
||||
destination: draft.destination.trim(),
|
||||
quantity: (draft.quantity || '').trim(),
|
||||
documentType: (draft.documentType || '').trim(),
|
||||
remark: draft.remark.trim()
|
||||
}
|
||||
try {
|
||||
@@ -157,7 +160,7 @@ export function useManualCalendar(
|
||||
}
|
||||
}
|
||||
async function remove(row: DispatchCalendarRecord) {
|
||||
if (!live() || !canManage.value || saving.value) return false
|
||||
if (!live() || !canManage.value || saving.value || row.sourceRecordId) return false
|
||||
saving.value = true
|
||||
try {
|
||||
await dispatchCalendarApi.delete(row.id)
|
||||
|
||||
Reference in new issue
Block a user