496 lines
19 KiB
Vue
496 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import type { CellValue, Worksheet } from 'exceljs';
|
||
import {
|
||
OLD_ITEM_CATEGORIES,
|
||
OLD_ITEM_CATEGORY_LABELS,
|
||
OLD_ITEM_INVENTORY_STATUS_LABELS,
|
||
type OldItemCategory,
|
||
type OldItemDonationItem,
|
||
type OldItemInventoryExportRow,
|
||
type OldItemInventoryImportRow,
|
||
type OldItemInventoryItem,
|
||
type OldItemInventoryStatus,
|
||
type OldItemNeedItem,
|
||
type BeneficiaryListItem,
|
||
} from '@lehuo/contracts';
|
||
import { ApiClientError } from '../api/api-client';
|
||
import { fetchBeneficiaries } from '../api/case-management';
|
||
import {
|
||
createOldItemInventory,
|
||
exportOldItemInventory,
|
||
getOldItemInventory,
|
||
importOldItemInventory,
|
||
listOldItemInventory,
|
||
transitionOldItemInventory,
|
||
} from '../api/old-items';
|
||
|
||
const props = defineProps<{ donations: OldItemDonationItem[]; needs: OldItemNeedItem[] }>();
|
||
const items = ref<OldItemInventoryItem[]>([]),
|
||
selected = ref<OldItemInventoryItem | null>(null),
|
||
loading = ref(false),
|
||
message = ref(''),
|
||
importInput = ref<HTMLInputElement | null>(null);
|
||
const beneficiaries = ref<BeneficiaryListItem[]>([]),
|
||
outboundNeedPublicId = ref(''),
|
||
outboundBeneficiaryPublicId = ref(''),
|
||
outboundRegion = ref('');
|
||
const form = reactive({
|
||
sourceDonationPublicId: '',
|
||
category: 'CLOTHING' as OldItemCategory,
|
||
itemName: '',
|
||
quantity: 1,
|
||
unit: '件',
|
||
storageLocation: '',
|
||
targetNeedPublicId: '',
|
||
note: '',
|
||
});
|
||
const eligibleDonations = computed(() =>
|
||
props.donations.filter((x) => ['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(x.status)),
|
||
);
|
||
const nextStatus: Record<OldItemInventoryStatus, OldItemInventoryStatus | null> = {
|
||
IN_STOCK: 'SORTED',
|
||
SORTED: 'DISINFECTED',
|
||
DISINFECTED: 'OUTBOUND',
|
||
OUTBOUND: 'DELIVERED',
|
||
DELIVERED: null,
|
||
};
|
||
const error = (e: unknown) =>
|
||
e instanceof ApiClientError
|
||
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
|
||
: e instanceof Error
|
||
? e.message
|
||
: '操作失败,请稍后重试';
|
||
async function createWorkbook() {
|
||
const { Workbook } = await import('exceljs');
|
||
return new Workbook();
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
try {
|
||
items.value = (await listOldItemInventory()).items;
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
async function loadBeneficiaries() {
|
||
try {
|
||
beneficiaries.value = (
|
||
await fetchBeneficiaries({ page: 1, pageSize: 100, status: 'ACTIVE' })
|
||
).items;
|
||
} catch {
|
||
beneficiaries.value = [];
|
||
}
|
||
}
|
||
async function add() {
|
||
message.value = '';
|
||
try {
|
||
await createOldItemInventory({
|
||
...form,
|
||
sourceDonationPublicId: form.sourceDonationPublicId || undefined,
|
||
targetNeedPublicId: form.targetNeedPublicId || undefined,
|
||
note: form.note || undefined,
|
||
});
|
||
Object.assign(form, {
|
||
sourceDonationPublicId: '',
|
||
category: 'CLOTHING',
|
||
itemName: '',
|
||
quantity: 1,
|
||
unit: '件',
|
||
storageLocation: '',
|
||
targetNeedPublicId: '',
|
||
note: '',
|
||
});
|
||
message.value = '物资已入库并写入首个流转节点';
|
||
await load();
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
}
|
||
}
|
||
async function open(id: string) {
|
||
try {
|
||
selected.value = (await getOldItemInventory(id)).item;
|
||
outboundNeedPublicId.value = selected.value.targetNeed?.publicId ?? '';
|
||
outboundBeneficiaryPublicId.value = selected.value.beneficiaryPublicId ?? '';
|
||
outboundRegion.value = selected.value.destinationRegion ?? '';
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
}
|
||
}
|
||
async function advance() {
|
||
if (!selected.value) return;
|
||
const targetStatus = nextStatus[selected.value.status];
|
||
if (!targetStatus) return;
|
||
const input: Parameters<typeof transitionOldItemInventory>[1] = {
|
||
expectedRevision: selected.value.revision,
|
||
targetStatus,
|
||
};
|
||
if (targetStatus === 'OUTBOUND') {
|
||
if (
|
||
!outboundRegion.value &&
|
||
!outboundNeedPublicId.value &&
|
||
!outboundBeneficiaryPublicId.value
|
||
) {
|
||
message.value = '出库前请至少绑定物资需求、受助对象或帮扶区域之一';
|
||
return;
|
||
}
|
||
input.destinationRegion = outboundRegion.value.trim() || undefined;
|
||
input.targetNeedPublicId = outboundNeedPublicId.value || undefined;
|
||
input.beneficiaryPublicId = outboundBeneficiaryPublicId.value || undefined;
|
||
}
|
||
if (targetStatus === 'DELIVERED') {
|
||
const people = Number(window.prompt('请输入实际帮扶人数', '0') ?? '');
|
||
if (!Number.isInteger(people) || people < 0) return;
|
||
input.assistedPeople = people;
|
||
}
|
||
const note = window.prompt(`补充说明(可选)`, '')?.trim();
|
||
if (note) input.note = note;
|
||
try {
|
||
selected.value = (await transitionOldItemInventory(selected.value.publicId, input)).item;
|
||
message.value = `已更新为${OLD_ITEM_INVENTORY_STATUS_LABELS[targetStatus]}`;
|
||
await load();
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
}
|
||
}
|
||
|
||
function styleSheet(sheet: Worksheet, rowCount: number) {
|
||
sheet.views = [{ state: 'frozen', ySplit: 1, showGridLines: false }];
|
||
sheet.autoFilter = { from: 'A1', to: `J${Math.max(1, rowCount)}` };
|
||
sheet.getRow(1).height = 26;
|
||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF6B35' } };
|
||
sheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
|
||
sheet.columns.forEach((column) => {
|
||
column.alignment = { vertical: 'middle', wrapText: true };
|
||
});
|
||
sheet.properties.defaultRowHeight = 22;
|
||
}
|
||
function download(buffer: ArrayBuffer, filename: string) {
|
||
const url = URL.createObjectURL(
|
||
new Blob([buffer], {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
}),
|
||
);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
function configureColumns(sheet: Worksheet) {
|
||
sheet.columns = [
|
||
{ header: '来源捐赠编号(可选)', key: 'sourceDonationNo', width: 24 },
|
||
{ header: '品类', key: 'category', width: 14 },
|
||
{ header: '物资名称', key: 'itemName', width: 24 },
|
||
{ header: '数量', key: 'quantity', width: 12 },
|
||
{ header: '单位', key: 'unit', width: 10 },
|
||
{ header: '入库位置', key: 'storageLocation', width: 24 },
|
||
{ header: '定向需求公开ID(可选)', key: 'targetNeedPublicId', width: 40 },
|
||
{ header: '备注(可选)', key: 'note', width: 30 },
|
||
{ header: '入库时间(可选)', key: 'inboundAt', width: 22 },
|
||
{ header: '填写说明', key: 'help', width: 36 },
|
||
];
|
||
}
|
||
async function template() {
|
||
const workbook = await createWorkbook();
|
||
const sheet = workbook.addWorksheet('物资入库模板');
|
||
configureColumns(sheet);
|
||
sheet.addRow({
|
||
sourceDonationNo: '',
|
||
category: '服饰',
|
||
itemName: '冬季外套',
|
||
quantity: 10,
|
||
unit: '件',
|
||
storageLocation: '一号仓 A-01',
|
||
targetNeedPublicId: '',
|
||
note: '已清洁装袋',
|
||
inboundAt: new Date(),
|
||
help: '品类仅限:服饰、书籍、生活用品、文体用品',
|
||
});
|
||
sheet.getColumn('quantity').numFmt = '0';
|
||
sheet.getColumn('inboundAt').numFmt = 'yyyy-mm-dd hh:mm';
|
||
for (let row = 2; row <= 501; row += 1) {
|
||
sheet.getCell(`B${row}`).dataValidation = {
|
||
type: 'list',
|
||
allowBlank: false,
|
||
formulae: ['"服饰,书籍,生活用品,文体用品"'],
|
||
showErrorMessage: true,
|
||
errorTitle: '品类无效',
|
||
error: '请选择模板提供的品类',
|
||
};
|
||
}
|
||
styleSheet(sheet, 2);
|
||
download((await workbook.xlsx.writeBuffer()) as ArrayBuffer, '旧物善用-物资入库模板.xlsx');
|
||
}
|
||
function value(cell: CellValue): string {
|
||
if (cell && typeof cell === 'object' && 'formula' in cell)
|
||
throw new Error('导入文件不能包含公式');
|
||
if (cell instanceof Date) return cell.toISOString();
|
||
if (cell && typeof cell === 'object' && 'text' in cell) return String(cell.text).trim();
|
||
return String(cell ?? '').trim();
|
||
}
|
||
const categoryByLabel = Object.fromEntries(
|
||
Object.entries(OLD_ITEM_CATEGORY_LABELS).map(([key, label]) => [label, key]),
|
||
) as Record<string, OldItemCategory>;
|
||
async function importFile(event: Event) {
|
||
const file = (event.target as HTMLInputElement).files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const workbook = await createWorkbook();
|
||
await workbook.xlsx.load(await file.arrayBuffer());
|
||
const sheet = workbook.worksheets[0];
|
||
if (!sheet) throw new Error('Excel 中没有可读取的工作表');
|
||
const expected = [
|
||
'来源捐赠编号(可选)',
|
||
'品类',
|
||
'物资名称',
|
||
'数量',
|
||
'单位',
|
||
'入库位置',
|
||
'定向需求公开ID(可选)',
|
||
'备注(可选)',
|
||
'入库时间(可选)',
|
||
];
|
||
expected.forEach((header, index) => {
|
||
if (value(sheet.getCell(1, index + 1).value) !== header)
|
||
throw new Error(`第 ${index + 1} 列表头应为“${header}”`);
|
||
});
|
||
const rows: OldItemInventoryImportRow[] = [];
|
||
for (let index = 2; index <= sheet.actualRowCount; index += 1) {
|
||
const cells = Array.from({ length: 9 }, (_, i) => value(sheet.getCell(index, i + 1).value));
|
||
if (cells.every((x) => !x)) continue;
|
||
const category =
|
||
categoryByLabel[cells[1]!] ??
|
||
(OLD_ITEM_CATEGORIES.includes(cells[1] as OldItemCategory)
|
||
? (cells[1] as OldItemCategory)
|
||
: undefined);
|
||
const quantity = Number(cells[3]);
|
||
if (!category) throw new Error(`第 ${index} 行品类无效`);
|
||
if (!cells[2] || !Number.isInteger(quantity) || quantity < 1 || !cells[4] || !cells[5])
|
||
throw new Error(`第 ${index} 行名称、正整数数量、单位和入库位置为必填`);
|
||
rows.push({
|
||
sourceDonationNo: cells[0] || undefined,
|
||
category,
|
||
itemName: cells[2]!,
|
||
quantity,
|
||
unit: cells[4]!,
|
||
storageLocation: cells[5]!,
|
||
targetNeedPublicId: cells[6] || undefined,
|
||
note: cells[7] || undefined,
|
||
inboundAt: cells[8] ? new Date(cells[8]).toISOString() : undefined,
|
||
});
|
||
}
|
||
if (!rows.length) throw new Error('Excel 中没有可导入的数据行');
|
||
if (rows.length > 500) throw new Error('单次最多导入 500 行');
|
||
await importOldItemInventory({ originalFilename: file.name, rows });
|
||
message.value = `已原子导入 ${rows.length} 条物资,未产生部分成功数据`;
|
||
await load();
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
} finally {
|
||
if (importInput.value) importInput.value.value = '';
|
||
}
|
||
}
|
||
async function exportExcel() {
|
||
try {
|
||
const response = await exportOldItemInventory();
|
||
const workbook = await createWorkbook();
|
||
const sheet = workbook.addWorksheet('物资全流程台账');
|
||
sheet.columns = [
|
||
{ header: '台账编号', key: 'inventoryNo', width: 28 },
|
||
{ header: '来源捐赠编号', key: 'sourceDonationNo', width: 24 },
|
||
{ header: '品类', key: 'category', width: 14 },
|
||
{ header: '物资名称', key: 'itemName', width: 24 },
|
||
{ header: '数量', key: 'quantity', width: 10 },
|
||
{ header: '单位', key: 'unit', width: 10 },
|
||
{ header: '当前位置', key: 'storageLocation', width: 24 },
|
||
{ header: '状态', key: 'status', width: 14 },
|
||
{ header: '定向需求', key: 'targetNeedTitle', width: 24 },
|
||
{ header: '帮扶地区', key: 'destinationRegion', width: 20 },
|
||
{ header: '落地时间', key: 'landingAt', width: 22 },
|
||
{ header: '入库时间', key: 'createdAt', width: 22 },
|
||
];
|
||
response.rows.forEach((row: OldItemInventoryExportRow) =>
|
||
sheet.addRow({
|
||
...row,
|
||
category: OLD_ITEM_CATEGORY_LABELS[row.category],
|
||
status: OLD_ITEM_INVENTORY_STATUS_LABELS[row.status],
|
||
landingAt: row.landingAt ? new Date(row.landingAt) : null,
|
||
createdAt: new Date(row.createdAt),
|
||
}),
|
||
);
|
||
sheet.getColumn('quantity').numFmt = '0';
|
||
sheet.getColumn('landingAt').numFmt = 'yyyy-mm-dd hh:mm';
|
||
sheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd hh:mm';
|
||
styleSheet(sheet, response.rows.length + 1);
|
||
download(
|
||
(await workbook.xlsx.writeBuffer()) as ArrayBuffer,
|
||
`旧物善用-物资台账-${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||
);
|
||
message.value = `已导出 ${response.rows.length} 条最小化台账,导出操作已审计`;
|
||
} catch (e) {
|
||
message.value = error(e);
|
||
}
|
||
}
|
||
function printLedger() {
|
||
window.print();
|
||
}
|
||
onMounted(() => {
|
||
void load();
|
||
void loadBeneficiaries();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="inventory-panel">
|
||
<p v-if="message" class="form-message" role="status">{{ message }}</p>
|
||
<div class="inventory-toolbar no-print">
|
||
<button class="secondary-button" @click="template">下载 Excel 模板</button>
|
||
<button class="secondary-button" @click="importInput?.click()">批量导入</button>
|
||
<input ref="importInput" type="file" accept=".xlsx" hidden @change="importFile" />
|
||
<button class="secondary-button" @click="exportExcel">导出 Excel</button>
|
||
<button class="secondary-button" @click="printLedger">打印当前台账</button>
|
||
<button class="secondary-button" :disabled="loading" @click="load">刷新</button>
|
||
</div>
|
||
<div class="old-items-columns no-print">
|
||
<form class="panel compact-form" @submit.prevent="add">
|
||
<h2>新增物资入库</h2>
|
||
<label
|
||
>来源捐赠(可选)<select v-model="form.sourceDonationPublicId">
|
||
<option value="">独立入库</option>
|
||
<option v-for="d in eligibleDonations" :key="d.publicId" :value="d.publicId">
|
||
{{ d.donationNo }}
|
||
</option>
|
||
</select></label
|
||
>
|
||
<label
|
||
>品类<select v-model="form.category">
|
||
<option v-for="(label, key) in OLD_ITEM_CATEGORY_LABELS" :key="key" :value="key">
|
||
{{ label }}
|
||
</option>
|
||
</select></label
|
||
>
|
||
<label>物资名称<input v-model="form.itemName" required maxlength="160" /></label>
|
||
<label>数量<input v-model.number="form.quantity" required type="number" min="1" /></label>
|
||
<label>单位<input v-model="form.unit" required maxlength="20" /></label>
|
||
<label>入库位置<input v-model="form.storageLocation" required maxlength="160" /></label>
|
||
<label
|
||
>定向需求(可选)<select v-model="form.targetNeedPublicId">
|
||
<option value="">暂不绑定</option>
|
||
<option v-for="need in needs" :key="need.publicId" :value="need.publicId">
|
||
{{ need.title }}
|
||
</option>
|
||
</select></label
|
||
>
|
||
<label>备注<textarea v-model="form.note" maxlength="1000"></textarea></label>
|
||
<button class="primary-button">确认入库</button>
|
||
</form>
|
||
<aside class="panel inventory-help">
|
||
<h2>流程规则</h2>
|
||
<ol>
|
||
<li>在库</li>
|
||
<li>已分拣</li>
|
||
<li>已消毒</li>
|
||
<li>已出库</li>
|
||
<li>已落地</li>
|
||
</ol>
|
||
<p>
|
||
状态不可跳级或回退。出库必须绑定需求、受助对象或帮扶区域之一;公开端仅展示脱敏流转节点。
|
||
</p>
|
||
</aside>
|
||
</div>
|
||
<div class="panel print-ledger">
|
||
<div class="print-title">
|
||
<h2>旧物善用物资全流程台账</h2>
|
||
<small>打印时间:{{ new Date().toLocaleString('zh-CN') }}</small>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>台账编号</th>
|
||
<th>来源</th>
|
||
<th>物资</th>
|
||
<th>数量</th>
|
||
<th>位置/地区</th>
|
||
<th>状态</th>
|
||
<th class="no-print">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in items" :key="item.publicId">
|
||
<td>{{ item.inventoryNo }}</td>
|
||
<td>{{ item.sourceDonation?.donationNo || '独立入库' }}</td>
|
||
<td>{{ OLD_ITEM_CATEGORY_LABELS[item.category] }} · {{ item.itemName }}</td>
|
||
<td>{{ item.quantity }}{{ item.unit }}</td>
|
||
<td>{{ item.destinationRegion || item.storageLocation }}</td>
|
||
<td>
|
||
<span class="status-pill">{{ OLD_ITEM_INVENTORY_STATUS_LABELS[item.status] }}</span>
|
||
</td>
|
||
<td class="no-print">
|
||
<button class="text-button" @click="open(item.publicId)">查看流转</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p v-if="!items.length && !loading" class="empty-state">暂无物资台账</p>
|
||
</div>
|
||
<div v-if="selected" class="modal-backdrop no-print" @click.self="selected = null">
|
||
<section class="detail-modal">
|
||
<button class="modal-close" @click="selected = null">关闭</button>
|
||
<h2>{{ selected.inventoryNo }}</h2>
|
||
<p>
|
||
{{ OLD_ITEM_CATEGORY_LABELS[selected.category] }} · {{ selected.itemName }} ·
|
||
{{ selected.quantity }}{{ selected.unit }}
|
||
</p>
|
||
<p>当前状态:{{ OLD_ITEM_INVENTORY_STATUS_LABELS[selected.status] }}</p>
|
||
<p>当前位置:{{ selected.destinationRegion || selected.storageLocation }}</p>
|
||
<div v-if="selected.status === 'DISINFECTED'" class="compact-form inventory-outbound-form">
|
||
<label
|
||
>定向需求<select v-model="outboundNeedPublicId">
|
||
<option value="">不绑定</option>
|
||
<option v-for="need in needs" :key="need.publicId" :value="need.publicId">
|
||
{{ need.title }}
|
||
</option>
|
||
</select></label
|
||
>
|
||
<label v-if="beneficiaries.length"
|
||
>受助对象<select v-model="outboundBeneficiaryPublicId">
|
||
<option value="">不绑定个人对象</option>
|
||
<option
|
||
v-for="person in beneficiaries"
|
||
:key="person.publicId"
|
||
:value="person.publicId"
|
||
>
|
||
{{ person.displayName }}
|
||
</option>
|
||
</select></label
|
||
>
|
||
<label
|
||
>帮扶区域<input v-model="outboundRegion" maxlength="120" placeholder="可填写区县/街道"
|
||
/></label>
|
||
</div>
|
||
<button v-if="nextStatus[selected.status]" class="primary-button" @click="advance">
|
||
推进至{{ OLD_ITEM_INVENTORY_STATUS_LABELS[nextStatus[selected.status]!] }}
|
||
</button>
|
||
<ol class="trace-list">
|
||
<li v-for="event in selected.events" :key="event.eventAt">
|
||
<strong>{{ OLD_ITEM_INVENTORY_STATUS_LABELS[event.status] }}</strong
|
||
><span
|
||
>{{ new Date(event.eventAt).toLocaleString('zh-CN') }} ·
|
||
{{ event.operatorName }}</span
|
||
>
|
||
<p v-if="event.locationText">{{ event.locationText }}</p>
|
||
<p v-if="event.note">{{ event.note }}</p>
|
||
</li>
|
||
</ol>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|