Files
lehuo/apps/admin-web/src/components/OldItemInventoryPanel.vue
T

496 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>