feat(old-items): complete reuse workflow and refine miniapp home
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@lehuo/contracts": "workspace:*",
|
||||
"dompurify": "^3.4.13",
|
||||
"exceljs": "4.4.0",
|
||||
"mammoth": "^1.12.1",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.20",
|
||||
|
||||
@@ -18,6 +18,7 @@ import StatisticsView from './views/StatisticsView.vue';
|
||||
import DashboardView from './views/DashboardView.vue';
|
||||
import AdminIcon from './components/AdminIcon.vue';
|
||||
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
|
||||
import OldItemsView from './views/OldItemsView.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const form = reactive({ username: '', password: '' });
|
||||
@@ -38,6 +39,7 @@ const activeView = ref<
|
||||
| 'notifications'
|
||||
| 'statistics'
|
||||
| 'financial-disclosures'
|
||||
| 'old-items'
|
||||
>('dashboard');
|
||||
|
||||
onMounted(() => auth.restore());
|
||||
@@ -141,6 +143,14 @@ async function logout(): Promise<void> {
|
||||
<div><strong>乐活公益</strong><small>管理平台</small></div>
|
||||
</div>
|
||||
<nav>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'old-items' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'old-items' ? 'page' : undefined"
|
||||
@click="activeView = 'old-items'"
|
||||
>
|
||||
<AdminIcon name="content" />旧物善用管理
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'dashboard' }]"
|
||||
type="button"
|
||||
@@ -264,6 +274,7 @@ async function logout(): Promise<void> {
|
||||
<ActivitiesView v-else-if="activeView === 'activities'" />
|
||||
<ContentView v-else-if="activeView === 'content'" />
|
||||
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
|
||||
<OldItemsView v-else-if="activeView === 'old-items'" />
|
||||
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
|
||||
<StatisticsView v-else />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
ActivateOldItemRewardConfigRequest,
|
||||
AdjustOldItemPointsRequest,
|
||||
OldItemCertificateItem,
|
||||
OldItemPointAccountItem,
|
||||
OldItemPointEntryItem,
|
||||
OldItemRewardConfig,
|
||||
} from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
import type { Page } from './old-items';
|
||||
|
||||
export const getOldItemRewardConfig = () =>
|
||||
apiRequest<{ config: OldItemRewardConfig; usesDefault: boolean }>(
|
||||
'/api/v1/admin/old-items/reward-config',
|
||||
);
|
||||
export const activateOldItemRewardConfig = (
|
||||
input: Omit<ActivateOldItemRewardConfigRequest, 'clientRequestId'>,
|
||||
) =>
|
||||
apiRequest<{ config: OldItemRewardConfig }>('/api/v1/admin/old-items/reward-config/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
});
|
||||
export const listOldItemPointAccounts = () =>
|
||||
apiRequest<Page<OldItemPointAccountItem>>(
|
||||
'/api/v1/admin/old-items/reward-accounts?page=1&pageSize=100',
|
||||
);
|
||||
export const adjustOldItemPoints = (
|
||||
userPublicId: string,
|
||||
input: Omit<AdjustOldItemPointsRequest, 'clientRequestId'>,
|
||||
) =>
|
||||
apiRequest<{ entry: OldItemPointEntryItem }>(
|
||||
`/api/v1/admin/old-items/reward-accounts/${userPublicId}/adjust`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
},
|
||||
);
|
||||
export const listOldItemCertificates = () =>
|
||||
apiRequest<Page<OldItemCertificateItem>>(
|
||||
'/api/v1/admin/old-items/certificates?page=1&pageSize=100',
|
||||
);
|
||||
export const issueOldItemCertificate = (donationPublicId: string) =>
|
||||
apiRequest<{ certificate: OldItemCertificateItem }>(
|
||||
`/api/v1/admin/old-items/donations/${donationPublicId}/certificate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ clientRequestId: crypto.randomUUID() }),
|
||||
},
|
||||
);
|
||||
export const revokeOldItemCertificate = (publicId: string, reason: string) =>
|
||||
apiRequest<{ certificate: OldItemCertificateItem }>(
|
||||
`/api/v1/admin/old-items/certificates/${publicId}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ clientRequestId: crypto.randomUUID(), reason }),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
CreateOldItemNeedRequest,
|
||||
OldItemDonationItem,
|
||||
OldItemDonationStatus,
|
||||
OldItemAssigneeOption,
|
||||
DownloadUrlResponse,
|
||||
OldItemNeedItem,
|
||||
OldItemNeedStatus,
|
||||
OldItemStats,
|
||||
OldItemDropOffPoint,
|
||||
CreateOldItemInventoryRequest,
|
||||
ImportOldItemInventoryRequest,
|
||||
OldItemInventoryExportRow,
|
||||
OldItemInventoryItem,
|
||||
OldItemInventoryStatus,
|
||||
} from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
export const getOldItemStats = () => apiRequest<OldItemStats>('/api/v1/old-items/stats');
|
||||
export const listOldItemDonations = (status = '') =>
|
||||
apiRequest<Page<OldItemDonationItem>>(
|
||||
`/api/v1/admin/old-items/donations?page=1&pageSize=100${status ? `&status=${status}` : ''}`,
|
||||
);
|
||||
export const getOldItemDonation = (id: string) =>
|
||||
apiRequest<{ donation: OldItemDonationItem }>(`/api/v1/admin/old-items/donations/${id}`);
|
||||
export const transitionOldItemDonation = (
|
||||
id: string,
|
||||
expectedRevision: number,
|
||||
targetStatus: OldItemDonationStatus,
|
||||
note?: string,
|
||||
assistedPeople?: number,
|
||||
assignedUserPublicId?: string,
|
||||
) =>
|
||||
apiRequest<{ donation: OldItemDonationItem }>(
|
||||
`/api/v1/admin/old-items/donations/${id}/transition`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision,
|
||||
targetStatus,
|
||||
note,
|
||||
assistedPeople,
|
||||
assignedUserPublicId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
export const listOldItemNeeds = () =>
|
||||
apiRequest<Page<OldItemNeedItem>>('/api/v1/admin/old-items/needs?page=1&pageSize=100');
|
||||
export const createOldItemNeed = (input: Omit<CreateOldItemNeedRequest, 'clientRequestId'>) =>
|
||||
apiRequest<{ need: OldItemNeedItem }>('/api/v1/admin/old-items/needs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
});
|
||||
export const listOldItemAssignees = () =>
|
||||
apiRequest<{ items: OldItemAssigneeOption[] }>('/api/v1/admin/old-items/assignees');
|
||||
export const getOldItemImageUrl = (id: string) =>
|
||||
apiRequest<DownloadUrlResponse>(`/api/v1/admin/old-items/media/${id}/download`);
|
||||
export const listOldItemDropOffPoints = () =>
|
||||
apiRequest<{ items: OldItemDropOffPoint[] }>('/api/v1/old-items/drop-off-points');
|
||||
export const createOldItemDropOffPoint = (input: {
|
||||
name: string;
|
||||
regionText: string;
|
||||
addressText: string;
|
||||
openingHours: string;
|
||||
sortOrder: number;
|
||||
}) =>
|
||||
apiRequest<{ point: OldItemDropOffPoint }>('/api/v1/admin/old-items/drop-off-points', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export const transitionOldItemNeed = (
|
||||
id: string,
|
||||
revision: number,
|
||||
targetStatus: OldItemNeedStatus,
|
||||
) =>
|
||||
apiRequest<{ need: OldItemNeedItem }>(`/api/v1/admin/old-items/needs/${id}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: revision,
|
||||
targetStatus,
|
||||
}),
|
||||
});
|
||||
|
||||
export const listOldItemInventory = (status = '') =>
|
||||
apiRequest<Page<OldItemInventoryItem>>(
|
||||
`/api/v1/admin/old-items/inventory?page=1&pageSize=100${status ? `&status=${status}` : ''}`,
|
||||
);
|
||||
export const getOldItemInventory = (id: string) =>
|
||||
apiRequest<{ item: OldItemInventoryItem }>(`/api/v1/admin/old-items/inventory/${id}`);
|
||||
export const createOldItemInventory = (
|
||||
input: Omit<CreateOldItemInventoryRequest, 'clientRequestId'>,
|
||||
) =>
|
||||
apiRequest<{ item: OldItemInventoryItem }>('/api/v1/admin/old-items/inventory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
});
|
||||
export const importOldItemInventory = (
|
||||
input: Omit<ImportOldItemInventoryRequest, 'clientRequestId'>,
|
||||
) =>
|
||||
apiRequest<{ importPublicId: string; items: OldItemInventoryItem[] }>(
|
||||
'/api/v1/admin/old-items/inventory/import',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
},
|
||||
);
|
||||
export const transitionOldItemInventory = (
|
||||
id: string,
|
||||
input: {
|
||||
expectedRevision: number;
|
||||
targetStatus: OldItemInventoryStatus;
|
||||
eventAt?: string;
|
||||
locationText?: string;
|
||||
note?: string;
|
||||
targetNeedPublicId?: string;
|
||||
beneficiaryPublicId?: string;
|
||||
destinationRegion?: string;
|
||||
assistedPeople?: number;
|
||||
},
|
||||
) =>
|
||||
apiRequest<{ item: OldItemInventoryItem }>(`/api/v1/admin/old-items/inventory/${id}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...input, clientRequestId: crypto.randomUUID() }),
|
||||
});
|
||||
export const exportOldItemInventory = () =>
|
||||
apiRequest<{ rows: OldItemInventoryExportRow[]; exportedAt: string }>(
|
||||
'/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100',
|
||||
);
|
||||
@@ -0,0 +1,495 @@
|
||||
<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>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import type {
|
||||
OldItemCertificateItem,
|
||||
OldItemDonationItem,
|
||||
OldItemPointAccountItem,
|
||||
OldItemRewardConfig,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import {
|
||||
activateOldItemRewardConfig,
|
||||
adjustOldItemPoints,
|
||||
getOldItemRewardConfig,
|
||||
issueOldItemCertificate,
|
||||
listOldItemCertificates,
|
||||
listOldItemPointAccounts,
|
||||
revokeOldItemCertificate,
|
||||
} from '../api/old-item-rewards';
|
||||
|
||||
const props = defineProps<{ donations: OldItemDonationItem[] }>();
|
||||
const config = ref<OldItemRewardConfig | null>(null),
|
||||
accounts = ref<OldItemPointAccountItem[]>([]),
|
||||
certificates = ref<OldItemCertificateItem[]>([]),
|
||||
loading = ref(false),
|
||||
message = ref(''),
|
||||
usesDefault = ref(true);
|
||||
const form = reactive({
|
||||
certificateTitle: '',
|
||||
certificateMessage: '',
|
||||
issuerName: '',
|
||||
legalNotice: '',
|
||||
clothingPoints: 1,
|
||||
booksPoints: 1,
|
||||
householdPoints: 1,
|
||||
sportsPoints: 1,
|
||||
maxPointsPerDonation: 500,
|
||||
});
|
||||
const completedDonations = computed(() => props.donations.filter((x) => x.status === 'COMPLETED'));
|
||||
const activeDonationIds = computed(
|
||||
() =>
|
||||
new Set(certificates.value.filter((x) => x.status === 'ISSUED').map((x) => x.donationPublicId)),
|
||||
);
|
||||
const error = (e: unknown) =>
|
||||
e instanceof ApiClientError
|
||||
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [configured, accountPage, certificatePage] = await Promise.all([
|
||||
getOldItemRewardConfig(),
|
||||
listOldItemPointAccounts(),
|
||||
listOldItemCertificates(),
|
||||
]);
|
||||
config.value = configured.config;
|
||||
usesDefault.value = configured.usesDefault;
|
||||
accounts.value = accountPage.items;
|
||||
certificates.value = certificatePage.items;
|
||||
Object.assign(form, configured.config);
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const response = await activateOldItemRewardConfig({ ...form });
|
||||
config.value = response.config;
|
||||
usesDefault.value = false;
|
||||
message.value = `规则版本 v${response.config.version} 已启用,仅影响之后完成的捐赠`;
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function issue(donation: OldItemDonationItem) {
|
||||
const alreadyIssued = activeDonationIds.value.has(donation.publicId);
|
||||
if (
|
||||
alreadyIssued &&
|
||||
!window.confirm('补发新证书会自动撤销当前有效证书,但历史仍会保留。确定继续吗?')
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await issueOldItemCertificate(donation.publicId);
|
||||
message.value = alreadyIssued ? '新证书已补发,旧证书已保留为撤销状态' : '证书已补发';
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function revoke(certificate: OldItemCertificateItem) {
|
||||
const reason = window.prompt('请输入撤销原因(会保留在审计与证书历史中)')?.trim();
|
||||
if (!reason) return;
|
||||
try {
|
||||
await revokeOldItemCertificate(certificate.publicId, reason);
|
||||
message.value = '证书已撤销,历史记录未删除';
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function adjust(account: OldItemPointAccountItem) {
|
||||
const delta = Number(window.prompt('请输入积分调整值,扣减请输入负数', '0') ?? '');
|
||||
if (!Number.isInteger(delta) || delta === 0) return;
|
||||
const reason = window.prompt('请输入调整原因(必填)')?.trim();
|
||||
if (!reason) return;
|
||||
try {
|
||||
await adjustOldItemPoints(account.userPublicId, {
|
||||
expectedRevision: account.revision,
|
||||
pointsDelta: delta,
|
||||
reason,
|
||||
});
|
||||
message.value = `已${delta > 0 ? '增加' : '扣减'} ${Math.abs(delta)} 公益积分`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rewards-admin">
|
||||
<p v-if="message" class="form-message" role="status">{{ message }}</p>
|
||||
<div class="reward-boundary panel">
|
||||
<strong>公益荣誉积分边界</strong>
|
||||
<p>积分只记录公益参与,不可购买、兑换、转让、提现或抵扣;证书不是捐赠票据或税务凭证。</p>
|
||||
</div>
|
||||
<div class="old-items-columns">
|
||||
<form class="panel compact-form reward-config-form" @submit.prevent="saveConfig">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>证书与积分规则</h2>
|
||||
<small>{{
|
||||
usesDefault ? '当前使用系统默认规则' : `当前版本 v${config?.version}`
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label>证书标题<input v-model="form.certificateTitle" required maxlength="120" /></label>
|
||||
<label
|
||||
>证书正文<textarea v-model="form.certificateMessage" required maxlength="1000"></textarea>
|
||||
</label>
|
||||
<small>支持:{recipient}、{donationNo}、{quantity}、{category}、{completedDate}</small>
|
||||
<label>签发机构<input v-model="form.issuerName" required maxlength="120" /></label>
|
||||
<label
|
||||
>法律说明<textarea v-model="form.legalNotice" required maxlength="500"></textarea>
|
||||
</label>
|
||||
<div class="reward-point-grid">
|
||||
<label
|
||||
>服饰/件<input v-model.number="form.clothingPoints" type="number" min="0" max="1000"
|
||||
/></label>
|
||||
<label
|
||||
>书籍/件<input v-model.number="form.booksPoints" type="number" min="0" max="1000"
|
||||
/></label>
|
||||
<label
|
||||
>生活用品/件<input
|
||||
v-model.number="form.householdPoints"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
/></label>
|
||||
<label
|
||||
>文体用品/件<input v-model.number="form.sportsPoints" type="number" min="0" max="1000"
|
||||
/></label>
|
||||
</div>
|
||||
<label
|
||||
>单次积分上限<input
|
||||
v-model.number="form.maxPointsPerDonation"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100000"
|
||||
/></label>
|
||||
<button class="primary-button">启用为新版本</button>
|
||||
</form>
|
||||
<article class="certificate-preview panel" aria-label="电子证书预览">
|
||||
<span class="certificate-seal">♥</span>
|
||||
<small>电子证书预览</small>
|
||||
<h2>{{ form.certificateTitle }}</h2>
|
||||
<strong>爱心用户</strong>
|
||||
<p>
|
||||
{{
|
||||
form.certificateMessage
|
||||
.replace('{recipient}', '爱心用户')
|
||||
.replace('{donationNo}', 'OI-DEMO')
|
||||
.replace('{quantity}', '10')
|
||||
.replace('{category}', '服饰')
|
||||
.replace('{completedDate}', new Date().toISOString().slice(0, 10))
|
||||
}}
|
||||
</p>
|
||||
<div>
|
||||
<b>{{ form.issuerName }}</b
|
||||
><small>{{ form.legalNotice }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>证书发放管理</h2>
|
||||
<small>完成捐赠时自动发放;历史完成记录可手动补发</small>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公益编号</th>
|
||||
<th>完成时间</th>
|
||||
<th>当前证书</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="donation in completedDonations" :key="donation.publicId">
|
||||
<td>{{ donation.donationNo }}</td>
|
||||
<td>{{ new Date(donation.createdAt).toLocaleDateString('zh-CN') }}</td>
|
||||
<td>{{ activeDonationIds.has(donation.publicId) ? '有效' : '未发放/已撤销' }}</td>
|
||||
<td>
|
||||
<button class="text-button" @click="issue(donation)">
|
||||
{{ activeDonationIds.has(donation.publicId) ? '补发' : '发放' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!completedDonations.length" class="empty-state">暂无已完成捐赠</p>
|
||||
<div class="certificate-history">
|
||||
<article v-for="item in certificates" :key="item.publicId">
|
||||
<div>
|
||||
<strong>{{ item.certificateNo }}</strong
|
||||
><span class="status-pill">{{ item.status === 'ISSUED' ? '有效' : '已撤销' }}</span>
|
||||
</div>
|
||||
<p>{{ item.donationNo }} · {{ new Date(item.issuedAt).toLocaleString('zh-CN') }}</p>
|
||||
<button v-if="item.status === 'ISSUED'" class="danger-button" @click="revoke(item)">
|
||||
撤销证书
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-heading">
|
||||
<h2>公益积分账户</h2>
|
||||
<small>所有调整形成只追加流水,余额不得小于 0</small>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>当前积分</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="account in accounts" :key="account.publicId">
|
||||
<td>{{ account.displayName }}</td>
|
||||
<td>
|
||||
<strong>{{ account.balance }}</strong>
|
||||
</td>
|
||||
<td>{{ new Date(account.updatedAt).toLocaleString('zh-CN') }}</td>
|
||||
<td><button class="text-button" @click="adjust(account)">调整积分</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!accounts.length && !loading" class="empty-state">暂无积分账户</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,6 +27,334 @@
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.old-items-view .metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.old-items-view .metric-grid article {
|
||||
background: #fff;
|
||||
border: 1px solid #eee8e1;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
.old-items-view .metric-grid span {
|
||||
display: block;
|
||||
color: #716b65;
|
||||
}
|
||||
.old-items-view .metric-grid strong {
|
||||
font-size: 30px;
|
||||
color: #1f9d55;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.segmented button {
|
||||
border: 1px solid #e6dfd8;
|
||||
background: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.segmented button.active {
|
||||
background: #ff6b35;
|
||||
color: #fff;
|
||||
border-color: #ff6b35;
|
||||
}
|
||||
.old-items-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.compact-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.compact-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.need-admin-card {
|
||||
border-bottom: 1px solid #eee8e1;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.need-admin-card div,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.detail-modal {
|
||||
width: min(680px, 92vw);
|
||||
max-height: 86vh;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0008;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 30;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
}
|
||||
.detail-modal dl {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.trace-list li {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.trace-list span {
|
||||
color: #777;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.danger-button {
|
||||
background: #fff0ed;
|
||||
color: #c73b22;
|
||||
border: 1px solid #ffc4b6;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.old-item-admin-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.old-item-admin-images img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.old-item-assignee {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 16px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.old-item-assignee select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd4cc;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inventory-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inventory-help ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
counter-reset: inventory-step;
|
||||
}
|
||||
|
||||
.inventory-help li {
|
||||
counter-increment: inventory-step;
|
||||
padding: 14px 8px;
|
||||
border-radius: 12px;
|
||||
background: #fff3ec;
|
||||
color: #8b3519;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inventory-help li::before {
|
||||
content: counter(inventory-step);
|
||||
display: block;
|
||||
margin: 0 auto 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #ff6b35;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.print-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.reward-boundary {
|
||||
margin-bottom: 16px;
|
||||
border-left: 4px solid #52c41a;
|
||||
background: #f3fbef;
|
||||
}
|
||||
|
||||
.reward-boundary p,
|
||||
.reward-config-form small,
|
||||
.panel-heading small {
|
||||
color: #6f6964;
|
||||
}
|
||||
|
||||
.reward-point-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.certificate-preview {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
overflow: hidden;
|
||||
min-height: 420px;
|
||||
padding: 44px;
|
||||
border: 8px double #f4b15f;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0, rgb(255 107 53 / 16%), transparent 34%),
|
||||
linear-gradient(145deg, #fffdf9, #fff5e8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.certificate-preview::before,
|
||||
.certificate-preview::after {
|
||||
position: absolute;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border: 2px solid rgb(255 107 53 / 22%);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.certificate-preview::before {
|
||||
top: -45px;
|
||||
left: -45px;
|
||||
}
|
||||
|
||||
.certificate-preview::after {
|
||||
right: -45px;
|
||||
bottom: -45px;
|
||||
}
|
||||
|
||||
.certificate-seal {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ff6b35, #ff9636);
|
||||
box-shadow: 0 8px 24px rgb(255 107 53 / 24%);
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.certificate-preview h2 {
|
||||
margin: 12px 0 30px;
|
||||
color: #8a351c;
|
||||
font-size: 30px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.certificate-preview > strong {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
color: #2f2b28;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.certificate-preview > p {
|
||||
min-height: 100px;
|
||||
color: #574e48;
|
||||
line-height: 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.certificate-preview > div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.certificate-history {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.certificate-history article {
|
||||
padding: 16px;
|
||||
border: 1px solid #eee7e1;
|
||||
border-radius: 14px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.certificate-history article > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory-outbound-form,
|
||||
.reward-config-form {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.print-ledger,
|
||||
.print-ledger * {
|
||||
visibility: visible;
|
||||
}
|
||||
.print-ledger {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.print-ledger table {
|
||||
font-size: 10pt;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.old-items-columns,
|
||||
.old-items-view .metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.reward-point-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.certificate-preview {
|
||||
min-height: 0;
|
||||
padding: 28px 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.financial-workspace {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
OLD_ITEM_CATEGORY_LABELS,
|
||||
OLD_ITEM_DONATION_STATUS_LABELS,
|
||||
PERMISSION_CODES,
|
||||
type OldItemDonationItem,
|
||||
type OldItemAssigneeOption,
|
||||
type OldItemNeedItem,
|
||||
type OldItemStats,
|
||||
type OldItemDropOffPoint,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import OldItemInventoryPanel from '../components/OldItemInventoryPanel.vue';
|
||||
import OldItemRewardsPanel from '../components/OldItemRewardsPanel.vue';
|
||||
import {
|
||||
createOldItemNeed,
|
||||
getOldItemDonation,
|
||||
getOldItemStats,
|
||||
listOldItemDonations,
|
||||
listOldItemAssignees,
|
||||
getOldItemImageUrl,
|
||||
listOldItemNeeds,
|
||||
transitionOldItemDonation,
|
||||
transitionOldItemNeed,
|
||||
listOldItemDropOffPoints,
|
||||
createOldItemDropOffPoint,
|
||||
} from '../api/old-items';
|
||||
const tab = ref<'donations' | 'inventory' | 'needs' | 'points' | 'rewards'>('donations'),
|
||||
loading = ref(false),
|
||||
message = ref('');
|
||||
const auth = useAuthStore();
|
||||
const canReadRewards = computed(() =>
|
||||
auth.user?.permissions.includes(PERMISSION_CODES.OLD_ITEM_REWARDS_READ),
|
||||
);
|
||||
const donations = ref<OldItemDonationItem[]>([]),
|
||||
needs = ref<OldItemNeedItem[]>([]),
|
||||
selected = ref<OldItemDonationItem | null>(null),
|
||||
stats = ref<OldItemStats>({ totalItems: 0, assistedPeople: 0, monthlyKindActs: 0 });
|
||||
const assignees = ref<OldItemAssigneeOption[]>([]),
|
||||
assigneePublicId = ref(''),
|
||||
imageUrls = ref<string[]>([]);
|
||||
const points = ref<OldItemDropOffPoint[]>([]);
|
||||
const pointForm = reactive({
|
||||
name: '',
|
||||
regionText: '',
|
||||
addressText: '',
|
||||
openingHours: '',
|
||||
sortOrder: 0,
|
||||
});
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: 'CLOTHING',
|
||||
regionText: '',
|
||||
beneficiaryGroup: '',
|
||||
description: '',
|
||||
requestedQuantity: 1,
|
||||
priority: 50,
|
||||
});
|
||||
const error = (e: unknown) =>
|
||||
e instanceof ApiClientError
|
||||
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [d, n, s, a, p] = await Promise.all([
|
||||
listOldItemDonations(),
|
||||
listOldItemNeeds(),
|
||||
getOldItemStats(),
|
||||
listOldItemAssignees(),
|
||||
listOldItemDropOffPoints(),
|
||||
]);
|
||||
donations.value = d.items;
|
||||
needs.value = n.items;
|
||||
stats.value = s;
|
||||
assignees.value = a.items;
|
||||
points.value = p.items;
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function open(id: string) {
|
||||
try {
|
||||
selected.value = (await getOldItemDonation(id)).donation;
|
||||
assigneePublicId.value = '';
|
||||
imageUrls.value = (
|
||||
await Promise.all(
|
||||
selected.value.imagePublicIds.map(async (fileId) => {
|
||||
try {
|
||||
return (await getOldItemImageUrl(fileId)).downloadUrl;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function move(target: OldItemDonationItem['status']) {
|
||||
if (!selected.value) return;
|
||||
let note: string | undefined, people: number | undefined;
|
||||
let assignedUserPublicId: string | undefined;
|
||||
if (target === 'REJECTED') {
|
||||
note = window.prompt('请输入驳回原因')?.trim();
|
||||
if (!note) return;
|
||||
}
|
||||
if (target === 'COMPLETED') {
|
||||
people = Number(window.prompt('请输入本次实际帮扶人数', '0') ?? '');
|
||||
if (!Number.isInteger(people) || people < 0) return;
|
||||
}
|
||||
if (target === 'WAITING_PICKUP') {
|
||||
assignedUserPublicId = assigneePublicId.value || undefined;
|
||||
if (!assignedUserPublicId) {
|
||||
message.value = '上门取件审核通过前请选择工作人员';
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
selected.value = (
|
||||
await transitionOldItemDonation(
|
||||
selected.value.publicId,
|
||||
selected.value.revision,
|
||||
target,
|
||||
note,
|
||||
people,
|
||||
assignedUserPublicId,
|
||||
)
|
||||
).donation;
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function addNeed() {
|
||||
try {
|
||||
await createOldItemNeed({ ...form, category: form.category as 'CLOTHING' });
|
||||
Object.assign(form, {
|
||||
title: '',
|
||||
category: 'CLOTHING',
|
||||
regionText: '',
|
||||
beneficiaryGroup: '',
|
||||
description: '',
|
||||
requestedQuantity: 1,
|
||||
priority: 50,
|
||||
});
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function publish(n: OldItemNeedItem, target: 'PUBLISHED' | 'CLOSED') {
|
||||
try {
|
||||
await transitionOldItemNeed(n.publicId, n.revision, target);
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
async function addPoint() {
|
||||
try {
|
||||
await createOldItemDropOffPoint(pointForm);
|
||||
Object.assign(pointForm, {
|
||||
name: '',
|
||||
regionText: '',
|
||||
addressText: '',
|
||||
openingHours: '',
|
||||
sortOrder: 0,
|
||||
});
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.value = error(e);
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
<template>
|
||||
<section class="workspace-view old-items-view">
|
||||
<header class="view-header">
|
||||
<div>
|
||||
<p class="eyebrow">旧物善用管理</p>
|
||||
<h1>闲置不浪费,旧物有温度</h1>
|
||||
<p>审核捐赠、跟进取件与投递,并发布真实物资需求。</p>
|
||||
</div>
|
||||
<button class="secondary-button" :disabled="loading" @click="load">刷新</button>
|
||||
</header>
|
||||
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
|
||||
<div class="metric-grid">
|
||||
<article>
|
||||
<span>累计接收物资</span><strong>{{ stats.totalItems }}</strong
|
||||
><small>件</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>累计帮扶</span><strong>{{ stats.assistedPeople }}</strong
|
||||
><small>人</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>本月爱心人次</span><strong>{{ stats.monthlyKindActs }}</strong
|
||||
><small>人次</small>
|
||||
</article>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button :class="{ active: tab === 'donations' }" @click="tab = 'donations'">
|
||||
捐赠订单审核</button
|
||||
><button :class="{ active: tab === 'inventory' }" @click="tab = 'inventory'">物资台账</button
|
||||
><button :class="{ active: tab === 'needs' }" @click="tab = 'needs'">物资需求</button
|
||||
><button :class="{ active: tab === 'points' }" @click="tab = 'points'">投递点</button
|
||||
><button
|
||||
v-if="canReadRewards"
|
||||
:class="{ active: tab === 'rewards' }"
|
||||
@click="tab = 'rewards'"
|
||||
>
|
||||
证书积分
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="tab === 'donations'" class="panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公益编号</th>
|
||||
<th>品类/数量</th>
|
||||
<th>方式</th>
|
||||
<th>状态</th>
|
||||
<th>提交时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in donations" :key="d.publicId">
|
||||
<td>{{ d.donationNo }}</td>
|
||||
<td>{{ OLD_ITEM_CATEGORY_LABELS[d.category] }} · {{ d.quantity }}件</td>
|
||||
<td>{{ d.method === 'PICKUP' ? '上门取件' : '定点投递' }}</td>
|
||||
<td>
|
||||
<span class="status-pill">{{ OLD_ITEM_DONATION_STATUS_LABELS[d.status] }}</span>
|
||||
</td>
|
||||
<td>{{ new Date(d.createdAt).toLocaleString('zh-CN') }}</td>
|
||||
<td><button class="text-button" @click="open(d.publicId)">查看处理</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!donations.length && !loading" class="empty-state">暂无捐赠订单</p>
|
||||
</div>
|
||||
<OldItemInventoryPanel v-else-if="tab === 'inventory'" :donations="donations" :needs="needs" />
|
||||
<div v-else-if="tab === 'needs'" class="old-items-columns">
|
||||
<form class="panel compact-form" @submit.prevent="addNeed">
|
||||
<h2>新增需求草稿</h2>
|
||||
<label>标题<input v-model="form.title" required maxlength="160" /></label
|
||||
><label
|
||||
>品类<select v-model="form.category">
|
||||
<option v-for="(v, k) in OLD_ITEM_CATEGORY_LABELS" :key="k" :value="k">{{ v }}</option>
|
||||
</select></label
|
||||
><label>受助区域<input v-model="form.regionText" required /></label
|
||||
><label>受助群体<input v-model="form.beneficiaryGroup" required /></label
|
||||
><label>需求说明<textarea v-model="form.description" required></textarea></label
|
||||
><label
|
||||
>需求数量<input
|
||||
v-model.number="form.requestedQuantity"
|
||||
type="number"
|
||||
min="1"
|
||||
required /></label
|
||||
><button class="primary-button">保存草稿</button>
|
||||
</form>
|
||||
<div class="panel">
|
||||
<h2>需求列表</h2>
|
||||
<article v-for="n in needs" :key="n.publicId" class="need-admin-card">
|
||||
<strong>{{ n.title }}</strong>
|
||||
<p>{{ n.regionText }} · {{ n.beneficiaryGroup }}</p>
|
||||
<small
|
||||
>{{ OLD_ITEM_CATEGORY_LABELS[n.category] }} 剩余 {{ n.remainingQuantity }}/{{
|
||||
n.requestedQuantity
|
||||
}}</small
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
v-if="n.status === 'DRAFT'"
|
||||
class="primary-button"
|
||||
@click="publish(n, 'PUBLISHED')"
|
||||
>
|
||||
发布</button
|
||||
><button
|
||||
v-if="n.status === 'PUBLISHED'"
|
||||
class="secondary-button"
|
||||
@click="publish(n, 'CLOSED')"
|
||||
>
|
||||
下架</button
|
||||
><span class="status-pill">{{ n.status }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tab === 'points'" class="old-items-columns">
|
||||
<form class="panel compact-form" @submit.prevent="addPoint">
|
||||
<h2>新增定点投递地址</h2>
|
||||
<label>点位名称<input v-model="pointForm.name" required /></label>
|
||||
<label>区域<input v-model="pointForm.regionText" required /></label>
|
||||
<label>详细地址<input v-model="pointForm.addressText" required /></label>
|
||||
<label>开放时间<input v-model="pointForm.openingHours" required /></label>
|
||||
<label>排序<input v-model.number="pointForm.sortOrder" type="number" /></label>
|
||||
<button class="primary-button">启用投递点</button>
|
||||
</form>
|
||||
<div class="panel">
|
||||
<h2>已启用投递点</h2>
|
||||
<article v-for="point in points" :key="point.publicId" class="need-admin-card">
|
||||
<strong>{{ point.name }}</strong>
|
||||
<p>{{ point.regionText }} · {{ point.addressText }}</p>
|
||||
<small>{{ point.openingHours }}</small>
|
||||
</article>
|
||||
<p v-if="!points.length" class="empty-state">暂无启用投递点</p>
|
||||
</div>
|
||||
</div>
|
||||
<OldItemRewardsPanel v-else :donations="donations" />
|
||||
<div v-if="selected" class="modal-backdrop" @click.self="selected = null">
|
||||
<section class="detail-modal">
|
||||
<button class="modal-close" @click="selected = null">关闭</button>
|
||||
<h2>{{ selected.donationNo }}</h2>
|
||||
<dl>
|
||||
<dt>物品</dt>
|
||||
<dd>{{ OLD_ITEM_CATEGORY_LABELS[selected.category] }} · {{ selected.quantity }}件</dd>
|
||||
<dt>描述</dt>
|
||||
<dd>{{ selected.description }}</dd>
|
||||
<dt>联系人</dt>
|
||||
<dd>{{ selected.contactName }} {{ selected.mobileMasked }}</dd>
|
||||
<dt v-if="selected.address">取件地址</dt>
|
||||
<dd v-if="selected.address">{{ selected.address }}</dd>
|
||||
<dt>状态</dt>
|
||||
<dd>{{ OLD_ITEM_DONATION_STATUS_LABELS[selected.status] }}</dd>
|
||||
</dl>
|
||||
<div v-if="imageUrls.length" class="old-item-admin-images">
|
||||
<img v-for="url in imageUrls" :key="url" :src="url" alt="用户上传的捐赠物品照片" />
|
||||
</div>
|
||||
<label
|
||||
v-if="selected.status === 'SUBMITTED' && selected.method === 'PICKUP'"
|
||||
class="old-item-assignee"
|
||||
>取件工作人员<select v-model="assigneePublicId">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="person in assignees" :key="person.publicId" :value="person.publicId">
|
||||
{{ person.displayName }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<div class="action-row">
|
||||
<button
|
||||
v-if="selected.status === 'SUBMITTED'"
|
||||
class="primary-button"
|
||||
@click="move(selected.method === 'PICKUP' ? 'WAITING_PICKUP' : 'WAITING_DROP_OFF')"
|
||||
>
|
||||
审核通过</button
|
||||
><button
|
||||
v-if="selected.status === 'SUBMITTED'"
|
||||
class="danger-button"
|
||||
@click="move('REJECTED')"
|
||||
>
|
||||
驳回</button
|
||||
><button
|
||||
v-if="['WAITING_PICKUP', 'WAITING_DROP_OFF'].includes(selected.status)"
|
||||
class="primary-button"
|
||||
@click="move('RECEIVED')"
|
||||
>
|
||||
确认接收</button
|
||||
><button
|
||||
v-if="selected.status === 'RECEIVED'"
|
||||
class="primary-button"
|
||||
@click="move('PROCESSING')"
|
||||
>
|
||||
开始分拣</button
|
||||
><button
|
||||
v-if="selected.status === 'PROCESSING'"
|
||||
class="primary-button"
|
||||
@click="move('COMPLETED')"
|
||||
>
|
||||
完成帮扶
|
||||
</button>
|
||||
</div>
|
||||
<ol class="trace-list">
|
||||
<li v-for="h in selected.history" :key="h.createdAt">
|
||||
<strong>{{ OLD_ITEM_DONATION_STATUS_LABELS[h.toStatus] }}</strong
|
||||
><span>{{ new Date(h.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
<p v-if="h.note">{{ h.note }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD','PROJECT_COVER','ACTIVITY_COVER','CONTENT_COVER','OLD_ITEM_DONATION_IMAGE') NOT NULL;
|
||||
|
||||
CREATE TABLE `old_item_drop_off_points` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL, `region_text` VARCHAR(120) NOT NULL,
|
||||
`address_text` VARCHAR(300) NOT NULL, `opening_hours` VARCHAR(200) NOT NULL,
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT true, `sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL, UNIQUE INDEX `old_item_drop_off_points_public_id_key` (`public_id`),
|
||||
INDEX `old_item_drop_off_points_active_sort_idx` (`is_active`,`sort_order`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_needs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`title` VARCHAR(160) NOT NULL, `category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`region_text` VARCHAR(120) NOT NULL, `beneficiary_group` VARCHAR(160) NOT NULL, `description` TEXT NOT NULL,
|
||||
`requested_quantity` INTEGER UNSIGNED NOT NULL, `fulfilled_quantity` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`priority` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL DEFAULT 'DRAFT',
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `published_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL, `updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_needs_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_needs_creator_request_key` (`created_by`,`client_request_id`),
|
||||
INDEX `old_item_needs_public_list_idx` (`status`,`category`,`priority`,`sort_order`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_need_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `need_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NULL,
|
||||
`to_status` ENUM('DRAFT','PUBLISHED','FULFILLED','CLOSED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_need_status_history_request_id_key` (`request_id`),
|
||||
INDEX `old_item_need_history_need_created_idx` (`need_id`,`created_at`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donations` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `public_id` CHAR(36) NOT NULL, `donation_no` VARCHAR(32) NOT NULL,
|
||||
`donor_user_id` BIGINT UNSIGNED NOT NULL, `client_request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`item_condition` ENUM('NEW','LIKE_NEW','GOOD','FAIR') NOT NULL, `quantity` INTEGER UNSIGNED NOT NULL,
|
||||
`description_encrypted` TEXT NOT NULL, `method` ENUM('PICKUP','DROP_OFF') NOT NULL,
|
||||
`contact_name_encrypted` VARCHAR(512) NOT NULL, `mobile_encrypted` VARCHAR(512) NOT NULL, `mobile_hash` CHAR(64) NOT NULL,
|
||||
`address_encrypted` TEXT NULL, `appointment_at` DATETIME(3) NULL, `drop_off_point_id` BIGINT UNSIGNED NULL,
|
||||
`target_need_id` BIGINT UNSIGNED NULL, `assigned_user_id` BIGINT UNSIGNED NULL,
|
||||
`rejection_reason_encrypted` TEXT NULL,
|
||||
`status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL DEFAULT 'SUBMITTED',
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1, `assisted_people` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`received_at` DATETIME(3) NULL, `completed_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_donations_public_id_key` (`public_id`), UNIQUE INDEX `old_item_donations_donation_no_key` (`donation_no`),
|
||||
UNIQUE INDEX `old_item_donations_donor_request_key` (`donor_user_id`,`client_request_id`),
|
||||
INDEX `old_item_donations_donor_status_created_idx` (`donor_user_id`,`status`,`created_at`),
|
||||
INDEX `old_item_donations_admin_list_idx` (`status`,`method`,`created_at`),
|
||||
INDEX `old_item_donations_need_status_idx` (`target_need_id`,`status`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donation_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `donation_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL, `request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NULL,
|
||||
`to_status` ENUM('SUBMITTED','REJECTED','WAITING_PICKUP','WAITING_DROP_OFF','RECEIVED','PROCESSING','COMPLETED','CANCELLED') NOT NULL,
|
||||
`note_encrypted` TEXT NULL, `operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_donation_status_history_request_id_key` (`request_id`),
|
||||
INDEX `old_item_donation_history_donation_created_idx` (`donation_id`,`created_at`), PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_donation_files` (
|
||||
`donation_id` BIGINT UNSIGNED NOT NULL, `file_id` BIGINT UNSIGNED NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0, `attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `old_item_donation_files_file_idx` (`file_id`), PRIMARY KEY (`donation_id`,`file_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_drop_off_points` ADD CONSTRAINT `old_item_drop_off_points_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_needs` ADD CONSTRAINT `old_item_needs_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_need_fkey` FOREIGN KEY (`need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_need_status_history` ADD CONSTRAINT `old_item_need_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_donor_fkey` FOREIGN KEY (`donor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_assignee_fkey` FOREIGN KEY (`assigned_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_drop_off_point_fkey` FOREIGN KEY (`drop_off_point_id`) REFERENCES `old_item_drop_off_points`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donations` ADD CONSTRAINT `old_item_donations_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_status_history` ADD CONSTRAINT `old_item_donation_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_donation_files` ADD CONSTRAINT `old_item_donation_files_file_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`,`name`,`description`) VALUES
|
||||
('old_items:read','查看旧物善用','查看旧物捐赠、需求与统计'),
|
||||
('old_items:manage','管理旧物善用','审核、分配并更新旧物捐赠状态'),
|
||||
('old_item_needs:manage','管理旧物需求','发布、编辑和下架物资需求');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
|
||||
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_items:read','old_items:manage','old_item_needs:manage');
|
||||
INSERT INTO `role_permissions` (`role_id`,`permission_id`)
|
||||
SELECT r.id,p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_items:read','old_items:manage')
|
||||
ON DUPLICATE KEY UPDATE `role_id`=`role_id`;
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE TABLE `old_item_inventory_imports` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`original_filename_encrypted` VARCHAR(512) NOT NULL,
|
||||
`row_count` INTEGER UNSIGNED NOT NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_inventory_imports_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_inventory_import_creator_request_key` (`created_by`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_inventory_items` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`inventory_no` VARCHAR(32) NOT NULL,
|
||||
`source_donation_id` BIGINT UNSIGNED NULL,
|
||||
`import_id` BIGINT UNSIGNED NULL,
|
||||
`category` ENUM('CLOTHING','BOOKS','HOUSEHOLD','SPORTS') NOT NULL,
|
||||
`item_name` VARCHAR(160) NOT NULL,
|
||||
`quantity` INTEGER UNSIGNED NOT NULL,
|
||||
`unit` VARCHAR(20) NOT NULL DEFAULT '件',
|
||||
`storage_location` VARCHAR(160) NOT NULL,
|
||||
`status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL DEFAULT 'IN_STOCK',
|
||||
`target_need_id` BIGINT UNSIGNED NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NULL,
|
||||
`destination_region` VARCHAR(120) NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`landing_at` DATETIME(3) NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_inventory_items_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_inventory_items_inventory_no_key` (`inventory_no`),
|
||||
UNIQUE INDEX `old_item_inventory_items_source_donation_id_key` (`source_donation_id`),
|
||||
INDEX `old_item_inventory_status_category_created_idx` (`status`, `category`, `created_at`),
|
||||
INDEX `old_item_inventory_need_status_idx` (`target_need_id`, `status`),
|
||||
INDEX `old_item_inventory_beneficiary_status_idx` (`beneficiary_id`, `status`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_inventory_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`inventory_item_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`event_type` ENUM('INBOUND','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
|
||||
`from_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NULL,
|
||||
`to_status` ENUM('IN_STOCK','SORTED','DISINFECTED','OUTBOUND','DELIVERED') NOT NULL,
|
||||
`location_text` VARCHAR(160) NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`event_at` DATETIME(3) NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_inventory_events_request_id_key` (`request_id`),
|
||||
INDEX `old_item_inventory_events_item_event_idx` (`inventory_item_id`, `event_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_inventory_imports` ADD CONSTRAINT `old_item_inventory_imports_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_source_donation_fkey` FOREIGN KEY (`source_donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_import_fkey` FOREIGN KEY (`import_id`) REFERENCES `old_item_inventory_imports`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_target_need_fkey` FOREIGN KEY (`target_need_id`) REFERENCES `old_item_needs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_beneficiary_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_items` ADD CONSTRAINT `old_item_inventory_items_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_item_fkey` FOREIGN KEY (`inventory_item_id`) REFERENCES `old_item_inventory_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_inventory_events` ADD CONSTRAINT `old_item_inventory_events_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('old_item_inventory:read', '查看旧物台账', '查看旧物物资全流程台账'),
|
||||
('old_item_inventory:manage', '管理旧物台账', '入库并更新旧物物资流转状态'),
|
||||
('old_item_inventory:import', '导入旧物台账', '从标准 Excel 模板批量导入物资'),
|
||||
('old_item_inventory:export', '导出旧物台账', '导出和打印最小化旧物台账');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN (
|
||||
'old_item_inventory:read','old_item_inventory:manage','old_item_inventory:import','old_item_inventory:export'
|
||||
);
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code IN ('old_item_inventory:read','old_item_inventory:manage')
|
||||
ON DUPLICATE KEY UPDATE `role_id` = `role_id`;
|
||||
@@ -0,0 +1,113 @@
|
||||
CREATE TABLE `old_item_reward_configs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`version` INTEGER UNSIGNED NOT NULL,
|
||||
`active_key` VARCHAR(16) NULL,
|
||||
`certificate_title` VARCHAR(120) NOT NULL,
|
||||
`certificate_message` VARCHAR(1000) NOT NULL,
|
||||
`issuer_name` VARCHAR(120) NOT NULL,
|
||||
`legal_notice` VARCHAR(500) NOT NULL,
|
||||
`clothing_points` INTEGER UNSIGNED NOT NULL,
|
||||
`books_points` INTEGER UNSIGNED NOT NULL,
|
||||
`household_points` INTEGER UNSIGNED NOT NULL,
|
||||
`sports_points` INTEGER UNSIGNED NOT NULL,
|
||||
`max_points_per_donation` INTEGER UNSIGNED NOT NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_reward_configs_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_reward_configs_client_request_id_key` (`client_request_id`),
|
||||
UNIQUE INDEX `old_item_reward_configs_version_key` (`version`),
|
||||
UNIQUE INDEX `old_item_reward_configs_active_key_key` (`active_key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_certificates` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`certificate_no` VARCHAR(40) NOT NULL,
|
||||
`donation_id` BIGINT UNSIGNED NOT NULL,
|
||||
`config_id` BIGINT UNSIGNED NULL,
|
||||
`issue_request_id` CHAR(36) NOT NULL,
|
||||
`issue_fingerprint` CHAR(64) NOT NULL,
|
||||
`active_key` VARCHAR(16) NULL,
|
||||
`status` ENUM('ISSUED','REVOKED') NOT NULL DEFAULT 'ISSUED',
|
||||
`recipient_name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`title_snapshot` VARCHAR(120) NOT NULL,
|
||||
`message_snapshot` VARCHAR(1000) NOT NULL,
|
||||
`issuer_snapshot` VARCHAR(120) NOT NULL,
|
||||
`legal_notice_snapshot` VARCHAR(500) NOT NULL,
|
||||
`issued_by` BIGINT UNSIGNED NOT NULL,
|
||||
`issued_at` DATETIME(3) NOT NULL,
|
||||
`revoked_by` BIGINT UNSIGNED NULL,
|
||||
`revoked_request_id` CHAR(36) NULL,
|
||||
`revoked_fingerprint` CHAR(64) NULL,
|
||||
`revoke_reason_encrypted` TEXT NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_certificates_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_certificates_certificate_no_key` (`certificate_no`),
|
||||
UNIQUE INDEX `old_item_certificates_issue_request_id_key` (`issue_request_id`),
|
||||
UNIQUE INDEX `old_item_certificates_revoked_request_id_key` (`revoked_request_id`),
|
||||
UNIQUE INDEX `old_item_certificates_donation_active_key` (`donation_id`, `active_key`),
|
||||
INDEX `old_item_certificates_donation_issued_idx` (`donation_id`, `issued_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_point_accounts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`balance` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `old_item_point_accounts_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_point_accounts_user_id_key` (`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `old_item_point_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`donation_id` BIGINT UNSIGNED NULL,
|
||||
`config_id` BIGINT UNSIGNED NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`source_key` VARCHAR(100) NOT NULL,
|
||||
`entry_type` ENUM('EARNED','ADJUSTMENT') NOT NULL,
|
||||
`points_delta` INTEGER NOT NULL,
|
||||
`balance_after` INTEGER UNSIGNED NOT NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `old_item_point_entries_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `old_item_point_entries_request_id_key` (`request_id`),
|
||||
UNIQUE INDEX `old_item_point_entries_source_key_key` (`source_key`),
|
||||
INDEX `old_item_point_entries_user_created_idx` (`user_id`, `created_at`),
|
||||
INDEX `old_item_point_entries_donation_idx` (`donation_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `old_item_reward_configs` ADD CONSTRAINT `old_item_reward_configs_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_certificates` ADD CONSTRAINT `old_item_certificates_revoked_by_fkey` FOREIGN KEY (`revoked_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_accounts` ADD CONSTRAINT `old_item_point_accounts_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_account_fkey` FOREIGN KEY (`account_id`) REFERENCES `old_item_point_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_user_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_donation_fkey` FOREIGN KEY (`donation_id`) REFERENCES `old_item_donations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_config_fkey` FOREIGN KEY (`config_id`) REFERENCES `old_item_reward_configs`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `old_item_point_entries` ADD CONSTRAINT `old_item_point_entries_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('old_item_rewards:read', '查看旧物证书积分', '查看旧物善用证书、积分账户和规则'),
|
||||
('old_item_rewards:manage', '管理旧物证书积分', '配置规则、补发撤销证书和调整公益积分');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('old_item_rewards:read','old_item_rewards:manage');
|
||||
+529
-133
@@ -153,6 +153,70 @@ enum FileBusinessType {
|
||||
PROJECT_COVER
|
||||
ACTIVITY_COVER
|
||||
CONTENT_COVER
|
||||
OLD_ITEM_DONATION_IMAGE
|
||||
}
|
||||
|
||||
enum OldItemCategory {
|
||||
CLOTHING
|
||||
BOOKS
|
||||
HOUSEHOLD
|
||||
SPORTS
|
||||
}
|
||||
|
||||
enum OldItemCondition {
|
||||
NEW
|
||||
LIKE_NEW
|
||||
GOOD
|
||||
FAIR
|
||||
}
|
||||
|
||||
enum OldItemDonationMethod {
|
||||
PICKUP
|
||||
DROP_OFF
|
||||
}
|
||||
|
||||
enum OldItemDonationStatus {
|
||||
SUBMITTED
|
||||
REJECTED
|
||||
WAITING_PICKUP
|
||||
WAITING_DROP_OFF
|
||||
RECEIVED
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum OldItemNeedStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
FULFILLED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum OldItemInventoryStatus {
|
||||
IN_STOCK
|
||||
SORTED
|
||||
DISINFECTED
|
||||
OUTBOUND
|
||||
DELIVERED
|
||||
}
|
||||
|
||||
enum OldItemInventoryEventType {
|
||||
INBOUND
|
||||
SORTED
|
||||
DISINFECTED
|
||||
OUTBOUND
|
||||
DELIVERED
|
||||
}
|
||||
|
||||
enum OldItemCertificateStatus {
|
||||
ISSUED
|
||||
REVOKED
|
||||
}
|
||||
|
||||
enum OldItemPointEntryType {
|
||||
EARNED
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
enum ContentPostType {
|
||||
@@ -261,70 +325,87 @@ enum FollowUpSource {
|
||||
}
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
status UserStatus @default(ACTIVE)
|
||||
userType UserType @map("user_type")
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
wechatIdentities WechatIdentity[]
|
||||
staffProfile StaffProfile?
|
||||
adminCredential AdminCredential?
|
||||
sessions AuthSession[]
|
||||
userRoles UserRole[]
|
||||
auditLogs AuditLog[] @relation("AuditActor")
|
||||
assignedRoles UserRole[] @relation("UserRoleAssigner")
|
||||
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
|
||||
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
|
||||
consents Consent[]
|
||||
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
|
||||
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
|
||||
ownedServiceCases ServiceCase[] @relation("CaseOwner")
|
||||
createdServiceCases ServiceCase[] @relation("CaseCreator")
|
||||
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
|
||||
caseAssignments CaseAssignee[]
|
||||
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
|
||||
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
|
||||
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
|
||||
uploadedFiles File[] @relation("FileUploader")
|
||||
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
|
||||
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
|
||||
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
|
||||
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
|
||||
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
|
||||
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
|
||||
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
|
||||
volunteerProfile Volunteer?
|
||||
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
|
||||
createdVolunteers Volunteer[] @relation("VolunteerCreator")
|
||||
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
|
||||
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
|
||||
managedProjects Project[] @relation("ProjectManager")
|
||||
createdProjects Project[] @relation("ProjectCreator")
|
||||
updatedProjects Project[] @relation("ProjectUpdater")
|
||||
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
|
||||
createdActivities Activity[] @relation("ActivityCreator")
|
||||
updatedActivities Activity[] @relation("ActivityUpdater")
|
||||
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
|
||||
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
|
||||
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
|
||||
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
|
||||
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
|
||||
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
|
||||
createdContentPosts ContentPost[] @relation("ContentCreator")
|
||||
updatedContentPosts ContentPost[] @relation("ContentUpdater")
|
||||
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
status UserStatus @default(ACTIVE)
|
||||
userType UserType @map("user_type")
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @unique @map("mobile_hash") @db.Char(64)
|
||||
nickname String? @db.VarChar(100)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
wechatIdentities WechatIdentity[]
|
||||
staffProfile StaffProfile?
|
||||
adminCredential AdminCredential?
|
||||
sessions AuthSession[]
|
||||
userRoles UserRole[]
|
||||
auditLogs AuditLog[] @relation("AuditActor")
|
||||
assignedRoles UserRole[] @relation("UserRoleAssigner")
|
||||
serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
|
||||
applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
|
||||
consents Consent[]
|
||||
createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
|
||||
updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
|
||||
ownedServiceCases ServiceCase[] @relation("CaseOwner")
|
||||
createdServiceCases ServiceCase[] @relation("CaseCreator")
|
||||
updatedServiceCases ServiceCase[] @relation("CaseUpdater")
|
||||
caseAssignments CaseAssignee[]
|
||||
caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
|
||||
createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
|
||||
updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
|
||||
uploadedFiles File[] @relation("FileUploader")
|
||||
assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
|
||||
assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
|
||||
revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
|
||||
ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
|
||||
createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
|
||||
updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
|
||||
followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
|
||||
volunteerProfile Volunteer?
|
||||
reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
|
||||
createdVolunteers Volunteer[] @relation("VolunteerCreator")
|
||||
updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
|
||||
volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
|
||||
managedProjects Project[] @relation("ProjectManager")
|
||||
createdProjects Project[] @relation("ProjectCreator")
|
||||
updatedProjects Project[] @relation("ProjectUpdater")
|
||||
projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
|
||||
createdActivities Activity[] @relation("ActivityCreator")
|
||||
updatedActivities Activity[] @relation("ActivityUpdater")
|
||||
activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
|
||||
activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
|
||||
reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
|
||||
checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
|
||||
recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
|
||||
registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
|
||||
createdContentPosts ContentPost[] @relation("ContentCreator")
|
||||
updatedContentPosts ContentPost[] @relation("ContentUpdater")
|
||||
reviewedContentPosts ContentPost[] @relation("ContentReviewer")
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
|
||||
oldItemDonations OldItemDonation[] @relation("OldItemDonor")
|
||||
assignedOldItemDonations OldItemDonation[] @relation("OldItemAssignee")
|
||||
oldItemDonationStatusChanges OldItemDonationStatusHistory[] @relation("OldItemDonationStatusOperator")
|
||||
createdOldItemNeeds OldItemNeed[] @relation("OldItemNeedCreator")
|
||||
updatedOldItemNeeds OldItemNeed[] @relation("OldItemNeedUpdater")
|
||||
oldItemNeedStatusChanges OldItemNeedStatusHistory[] @relation("OldItemNeedStatusOperator")
|
||||
createdOldItemDropOffPoints OldItemDropOffPoint[] @relation("OldItemDropOffPointCreator")
|
||||
createdOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryCreator")
|
||||
updatedOldItemInventoryItems OldItemInventoryItem[] @relation("OldItemInventoryUpdater")
|
||||
oldItemInventoryEvents OldItemInventoryEvent[] @relation("OldItemInventoryEventOperator")
|
||||
oldItemInventoryImports OldItemInventoryImport[] @relation("OldItemInventoryImportCreator")
|
||||
createdOldItemRewardConfigs OldItemRewardConfig[] @relation("OldItemRewardConfigCreator")
|
||||
issuedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateIssuer")
|
||||
revokedOldItemCertificates OldItemCertificate[] @relation("OldItemCertificateRevoker")
|
||||
oldItemPointAccount OldItemPointAccount?
|
||||
oldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOwner")
|
||||
operatedOldItemPointEntries OldItemPointEntry[] @relation("OldItemPointOperator")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -552,27 +633,27 @@ model ServiceApplicationStatusHistory {
|
||||
}
|
||||
|
||||
model BeneficiaryProfile {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
|
||||
displayNameHash String @map("display_name_hash") @db.Char(64)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
|
||||
displayNameHash String @map("display_name_hash") @db.Char(64)
|
||||
gender BeneficiaryGender?
|
||||
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @map("mobile_hash") @db.Char(64)
|
||||
regionCode String @map("region_code") @db.VarChar(100)
|
||||
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
|
||||
primaryCategory ServiceCategory @map("primary_category")
|
||||
keyCareFlag Boolean @default(false) @map("key_care_flag")
|
||||
isMinor Boolean @default(false) @map("is_minor")
|
||||
status BeneficiaryStatus @default(ACTIVE)
|
||||
notesEncrypted String? @map("notes_encrypted") @db.Text
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
birthYear Int? @map("birth_year") @db.UnsignedSmallInt
|
||||
mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String? @map("mobile_hash") @db.Char(64)
|
||||
regionCode String @map("region_code") @db.VarChar(100)
|
||||
addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
|
||||
primaryCategory ServiceCategory @map("primary_category")
|
||||
keyCareFlag Boolean @default(false) @map("key_care_flag")
|
||||
isMinor Boolean @default(false) @map("is_minor")
|
||||
status BeneficiaryStatus @default(ACTIVE)
|
||||
notesEncrypted String? @map("notes_encrypted") @db.Text
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
contacts BeneficiaryContact[]
|
||||
beneficiaryTags BeneficiaryTag[]
|
||||
applications ServiceApplication[]
|
||||
@@ -580,6 +661,7 @@ model BeneficiaryProfile {
|
||||
serviceRecords ServiceRecord[]
|
||||
consents Consent[]
|
||||
followUpTasks FollowUpTask[]
|
||||
oldItemInventoryItems OldItemInventoryItem[]
|
||||
|
||||
@@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
|
||||
@@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
|
||||
@@ -769,29 +851,30 @@ model ServiceRecordRevision {
|
||||
}
|
||||
|
||||
model File {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
caseId BigInt? @map("case_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
storageProvider String @map("storage_provider") @db.VarChar(50)
|
||||
bucket String @db.VarChar(255)
|
||||
objectKey String @unique @map("object_key") @db.VarChar(512)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
|
||||
mimeType String @map("mime_type") @db.VarChar(100)
|
||||
size BigInt @db.UnsignedBigInt
|
||||
sha256 String? @db.Char(64)
|
||||
businessType FileBusinessType @map("business_type")
|
||||
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
|
||||
status FileStatus @default(UPLOAD_PENDING)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
|
||||
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
caseId BigInt? @map("case_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
storageProvider String @map("storage_provider") @db.VarChar(50)
|
||||
bucket String @db.VarChar(255)
|
||||
objectKey String @unique @map("object_key") @db.VarChar(512)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
|
||||
mimeType String @map("mime_type") @db.VarChar(100)
|
||||
size BigInt @db.UnsignedBigInt
|
||||
sha256 String? @db.Char(64)
|
||||
businessType FileBusinessType @map("business_type")
|
||||
uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
|
||||
status FileStatus @default(UPLOAD_PENDING)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
|
||||
uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
|
||||
serviceRecords ServiceRecordFile[]
|
||||
projectCover Project? @relation("ProjectCover")
|
||||
activityCover Activity? @relation("ActivityCover")
|
||||
contentCover ContentPost? @relation("ContentCover")
|
||||
projectCover Project? @relation("ProjectCover")
|
||||
activityCover Activity? @relation("ActivityCover")
|
||||
contentCover ContentPost? @relation("ContentCover")
|
||||
oldItemDonations OldItemDonationFile[]
|
||||
|
||||
@@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
|
||||
@@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
|
||||
@@ -799,6 +882,319 @@ model File {
|
||||
@@map("files")
|
||||
}
|
||||
|
||||
model OldItemDropOffPoint {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
name String @db.VarChar(120)
|
||||
regionText String @map("region_text") @db.VarChar(120)
|
||||
addressText String @map("address_text") @db.VarChar(300)
|
||||
openingHours String @map("opening_hours") @db.VarChar(200)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemDropOffPointCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
donations OldItemDonation[]
|
||||
|
||||
@@index([isActive, sortOrder], map: "old_item_drop_off_points_active_sort_idx")
|
||||
@@map("old_item_drop_off_points")
|
||||
}
|
||||
|
||||
model OldItemNeed {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(160)
|
||||
category OldItemCategory
|
||||
regionText String @map("region_text") @db.VarChar(120)
|
||||
beneficiaryGroup String @map("beneficiary_group") @db.VarChar(160)
|
||||
description String @db.Text
|
||||
requestedQuantity Int @map("requested_quantity") @db.UnsignedInt
|
||||
fulfilledQuantity Int @default(0) @map("fulfilled_quantity") @db.UnsignedInt
|
||||
priority Int @default(0)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
status OldItemNeedStatus @default(DRAFT)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemNeedCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("OldItemNeedUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
donations OldItemDonation[]
|
||||
history OldItemNeedStatusHistory[]
|
||||
inventoryItems OldItemInventoryItem[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "old_item_needs_creator_request_key")
|
||||
@@index([status, category, priority, sortOrder], map: "old_item_needs_public_list_idx")
|
||||
@@map("old_item_needs")
|
||||
}
|
||||
|
||||
model OldItemNeedStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
needId BigInt @map("need_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus OldItemNeedStatus? @map("from_status")
|
||||
toStatus OldItemNeedStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
need OldItemNeed @relation(fields: [needId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemNeedStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([needId, createdAt], map: "old_item_need_history_need_created_idx")
|
||||
@@map("old_item_need_status_history")
|
||||
}
|
||||
|
||||
model OldItemDonation {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
donationNo String @unique @map("donation_no") @db.VarChar(32)
|
||||
donorUserId BigInt @map("donor_user_id") @db.UnsignedBigInt
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
category OldItemCategory
|
||||
itemCondition OldItemCondition @map("item_condition")
|
||||
quantity Int @db.UnsignedInt
|
||||
descriptionEncrypted String @map("description_encrypted") @db.Text
|
||||
method OldItemDonationMethod
|
||||
contactNameEncrypted String @map("contact_name_encrypted") @db.VarChar(512)
|
||||
mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512)
|
||||
mobileHash String @map("mobile_hash") @db.Char(64)
|
||||
addressEncrypted String? @map("address_encrypted") @db.Text
|
||||
appointmentAt DateTime? @map("appointment_at") @db.DateTime(3)
|
||||
dropOffPointId BigInt? @map("drop_off_point_id") @db.UnsignedBigInt
|
||||
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
|
||||
assignedUserId BigInt? @map("assigned_user_id") @db.UnsignedBigInt
|
||||
rejectionReasonEncrypted String? @map("rejection_reason_encrypted") @db.Text
|
||||
status OldItemDonationStatus @default(SUBMITTED)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
assistedPeople Int @default(0) @map("assisted_people") @db.UnsignedInt
|
||||
receivedAt DateTime? @map("received_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
donor User @relation("OldItemDonor", fields: [donorUserId], references: [id], onDelete: Restrict)
|
||||
assignee User? @relation("OldItemAssignee", fields: [assignedUserId], references: [id], onDelete: Restrict)
|
||||
dropOffPoint OldItemDropOffPoint? @relation(fields: [dropOffPointId], references: [id], onDelete: Restrict)
|
||||
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
|
||||
history OldItemDonationStatusHistory[]
|
||||
files OldItemDonationFile[]
|
||||
inventoryItems OldItemInventoryItem[]
|
||||
certificates OldItemCertificate[]
|
||||
pointEntries OldItemPointEntry[]
|
||||
|
||||
@@unique([donorUserId, clientRequestId], map: "old_item_donations_donor_request_key")
|
||||
@@index([donorUserId, status, createdAt], map: "old_item_donations_donor_status_created_idx")
|
||||
@@index([status, method, createdAt], map: "old_item_donations_admin_list_idx")
|
||||
@@index([targetNeedId, status], map: "old_item_donations_need_status_idx")
|
||||
@@map("old_item_donations")
|
||||
}
|
||||
|
||||
model OldItemRewardConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @unique @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
version Int @unique @db.UnsignedInt
|
||||
activeKey String? @unique @map("active_key") @db.VarChar(16)
|
||||
certificateTitle String @map("certificate_title") @db.VarChar(120)
|
||||
certificateMessage String @map("certificate_message") @db.VarChar(1000)
|
||||
issuerName String @map("issuer_name") @db.VarChar(120)
|
||||
legalNotice String @map("legal_notice") @db.VarChar(500)
|
||||
clothingPoints Int @map("clothing_points") @db.UnsignedInt
|
||||
booksPoints Int @map("books_points") @db.UnsignedInt
|
||||
householdPoints Int @map("household_points") @db.UnsignedInt
|
||||
sportsPoints Int @map("sports_points") @db.UnsignedInt
|
||||
maxPointsPerDonation Int @map("max_points_per_donation") @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemRewardConfigCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
certificates OldItemCertificate[]
|
||||
pointEntries OldItemPointEntry[]
|
||||
|
||||
@@map("old_item_reward_configs")
|
||||
}
|
||||
|
||||
model OldItemCertificate {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
certificateNo String @unique @map("certificate_no") @db.VarChar(40)
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
configId BigInt? @map("config_id") @db.UnsignedBigInt
|
||||
issueRequestId String @unique @map("issue_request_id") @db.Char(36)
|
||||
issueFingerprint String @map("issue_fingerprint") @db.Char(64)
|
||||
activeKey String? @map("active_key") @db.VarChar(16)
|
||||
status OldItemCertificateStatus @default(ISSUED)
|
||||
recipientNameEncrypted String @map("recipient_name_encrypted") @db.VarChar(512)
|
||||
titleSnapshot String @map("title_snapshot") @db.VarChar(120)
|
||||
messageSnapshot String @map("message_snapshot") @db.VarChar(1000)
|
||||
issuerSnapshot String @map("issuer_snapshot") @db.VarChar(120)
|
||||
legalNoticeSnapshot String @map("legal_notice_snapshot") @db.VarChar(500)
|
||||
issuedById BigInt @map("issued_by") @db.UnsignedBigInt
|
||||
issuedAt DateTime @map("issued_at") @db.DateTime(3)
|
||||
revokedById BigInt? @map("revoked_by") @db.UnsignedBigInt
|
||||
revokedRequestId String? @unique @map("revoked_request_id") @db.Char(36)
|
||||
revokedFingerprint String? @map("revoked_fingerprint") @db.Char(64)
|
||||
revokeReasonEncrypted String? @map("revoke_reason_encrypted") @db.Text
|
||||
revokedAt DateTime? @map("revoked_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
|
||||
issuer User @relation("OldItemCertificateIssuer", fields: [issuedById], references: [id], onDelete: Restrict)
|
||||
revoker User? @relation("OldItemCertificateRevoker", fields: [revokedById], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([donationId, activeKey], map: "old_item_certificates_donation_active_key")
|
||||
@@index([donationId, issuedAt], map: "old_item_certificates_donation_issued_idx")
|
||||
@@map("old_item_certificates")
|
||||
}
|
||||
|
||||
model OldItemPointAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
|
||||
balance Int @default(0) @db.UnsignedInt
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
entries OldItemPointEntry[]
|
||||
|
||||
@@map("old_item_point_accounts")
|
||||
}
|
||||
|
||||
model OldItemPointEntry {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
accountId BigInt @map("account_id") @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
donationId BigInt? @map("donation_id") @db.UnsignedBigInt
|
||||
configId BigInt? @map("config_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
sourceKey String @unique @map("source_key") @db.VarChar(100)
|
||||
entryType OldItemPointEntryType @map("entry_type")
|
||||
pointsDelta Int @map("points_delta")
|
||||
balanceAfter Int @map("balance_after") @db.UnsignedInt
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
account OldItemPointAccount @relation(fields: [accountId], references: [id], onDelete: Restrict)
|
||||
user User @relation("OldItemPointOwner", fields: [userId], references: [id], onDelete: Restrict)
|
||||
donation OldItemDonation? @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
config OldItemRewardConfig? @relation(fields: [configId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemPointOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([userId, createdAt], map: "old_item_point_entries_user_created_idx")
|
||||
@@index([donationId], map: "old_item_point_entries_donation_idx")
|
||||
@@map("old_item_point_entries")
|
||||
}
|
||||
|
||||
model OldItemInventoryImport {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(512)
|
||||
rowCount Int @map("row_count") @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
creator User @relation("OldItemInventoryImportCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
items OldItemInventoryItem[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "old_item_inventory_import_creator_request_key")
|
||||
@@map("old_item_inventory_imports")
|
||||
}
|
||||
|
||||
model OldItemInventoryItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
inventoryNo String @unique @map("inventory_no") @db.VarChar(32)
|
||||
sourceDonationId BigInt? @unique @map("source_donation_id") @db.UnsignedBigInt
|
||||
importId BigInt? @map("import_id") @db.UnsignedBigInt
|
||||
category OldItemCategory
|
||||
itemName String @map("item_name") @db.VarChar(160)
|
||||
quantity Int @db.UnsignedInt
|
||||
unit String @default("件") @db.VarChar(20)
|
||||
storageLocation String @map("storage_location") @db.VarChar(160)
|
||||
status OldItemInventoryStatus @default(IN_STOCK)
|
||||
targetNeedId BigInt? @map("target_need_id") @db.UnsignedBigInt
|
||||
beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt
|
||||
destinationRegion String? @map("destination_region") @db.VarChar(120)
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
landingAt DateTime? @map("landing_at") @db.DateTime(3)
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
sourceDonation OldItemDonation? @relation(fields: [sourceDonationId], references: [id], onDelete: Restrict)
|
||||
import OldItemInventoryImport? @relation(fields: [importId], references: [id], onDelete: Restrict)
|
||||
targetNeed OldItemNeed? @relation(fields: [targetNeedId], references: [id], onDelete: Restrict)
|
||||
beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
|
||||
creator User @relation("OldItemInventoryCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("OldItemInventoryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
events OldItemInventoryEvent[]
|
||||
|
||||
@@index([status, category, createdAt], map: "old_item_inventory_status_category_created_idx")
|
||||
@@index([targetNeedId, status], map: "old_item_inventory_need_status_idx")
|
||||
@@index([beneficiaryId, status], map: "old_item_inventory_beneficiary_status_idx")
|
||||
@@map("old_item_inventory_items")
|
||||
}
|
||||
|
||||
model OldItemInventoryEvent {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
inventoryItemId BigInt @map("inventory_item_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
eventType OldItemInventoryEventType @map("event_type")
|
||||
fromStatus OldItemInventoryStatus? @map("from_status")
|
||||
toStatus OldItemInventoryStatus @map("to_status")
|
||||
locationText String? @map("location_text") @db.VarChar(160)
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
eventAt DateTime @map("event_at") @db.DateTime(3)
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
inventoryItem OldItemInventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemInventoryEventOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([inventoryItemId, eventAt], map: "old_item_inventory_events_item_event_idx")
|
||||
@@map("old_item_inventory_events")
|
||||
}
|
||||
|
||||
model OldItemDonationStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus OldItemDonationStatus? @map("from_status")
|
||||
toStatus OldItemDonationStatus @map("to_status")
|
||||
noteEncrypted String? @map("note_encrypted") @db.Text
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("OldItemDonationStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([donationId, createdAt], map: "old_item_donation_history_donation_created_idx")
|
||||
@@map("old_item_donation_status_history")
|
||||
}
|
||||
|
||||
model OldItemDonationFile {
|
||||
donationId BigInt @map("donation_id") @db.UnsignedBigInt
|
||||
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
attachedAt DateTime @default(now()) @map("attached_at") @db.DateTime(3)
|
||||
donation OldItemDonation @relation(fields: [donationId], references: [id], onDelete: Restrict)
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([donationId, fileId])
|
||||
@@index([fileId], map: "old_item_donation_files_file_idx")
|
||||
@@map("old_item_donation_files")
|
||||
}
|
||||
|
||||
model ServiceRecordFile {
|
||||
serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
|
||||
fileId BigInt @map("file_id") @db.UnsignedBigInt
|
||||
@@ -1158,24 +1554,24 @@ model ContentPostStatusHistory {
|
||||
}
|
||||
|
||||
model FinancialDisclosure {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
statusHistory FinancialDisclosureStatusHistory[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
|
||||
@@ -1185,16 +1581,16 @@ model FinancialDisclosure {
|
||||
}
|
||||
|
||||
model FinancialDisclosureStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
|
||||
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RequestMetricsService } from './common/request-metrics.service';
|
||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
|
||||
import { OldItemsModule } from './old-items/old-items.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,6 +45,7 @@ import { FinancialDisclosuresModule } from './financial-disclosures/financial-di
|
||||
ContentNotificationsModule,
|
||||
StatisticsModule,
|
||||
FinancialDisclosuresModule,
|
||||
OldItemsModule,
|
||||
],
|
||||
controllers: [HealthController, SystemMetricsController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsMobilePhone,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
OLD_ITEM_CATEGORIES,
|
||||
OLD_ITEM_CONDITIONS,
|
||||
OLD_ITEM_DONATION_METHODS,
|
||||
OLD_ITEM_DONATION_STATUSES,
|
||||
OLD_ITEM_NEED_STATUSES,
|
||||
OLD_ITEM_INVENTORY_STATUSES,
|
||||
} from '@lehuo/contracts';
|
||||
import { PaginationDto } from '../../case-management/dto/case-management.dto';
|
||||
|
||||
export class OldItemDonationQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_DONATION_STATUSES) status?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_DONATION_METHODS) method?: string;
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
export class OldItemNeedQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_NEED_STATUSES) status?: string;
|
||||
}
|
||||
export class CreateOldItemDonationDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsIn(OLD_ITEM_CONDITIONS) itemCondition!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(9999) quantity!: number;
|
||||
@IsString() @Length(2, 1000) description!: string;
|
||||
@IsIn(OLD_ITEM_DONATION_METHODS) method!: 'PICKUP' | 'DROP_OFF';
|
||||
@IsString() @Length(2, 50) contactName!: string;
|
||||
@IsMobilePhone('zh-CN') mobile!: string;
|
||||
@IsOptional() @IsString() @Length(5, 500) address?: string;
|
||||
@IsOptional() @IsDateString() appointmentAt?: string;
|
||||
@IsOptional() @IsUUID('4') dropOffPointPublicId?: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsArray() @IsUUID('4', { each: true }) imagePublicIds?: string[];
|
||||
}
|
||||
export class CreateOldItemMediaUploadIntentDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['OLD_ITEM_DONATION_IMAGE']) businessType!: 'OLD_ITEM_DONATION_IMAGE';
|
||||
@IsString() @Length(1, 255) filename!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
|
||||
'image/jpeg' | 'image/png' | 'image/webp';
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
|
||||
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
||||
}
|
||||
export class TransitionOldItemDonationDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_DONATION_STATUSES) targetStatus!: string;
|
||||
@IsOptional() @IsString() @Length(1, 500) note?: string;
|
||||
@IsOptional() @IsUUID('4') assignedUserPublicId?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
|
||||
}
|
||||
export class CreateOldItemNeedDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 160) title!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(2, 160) beneficiaryGroup!: string;
|
||||
@IsString() @Length(2, 2000) description!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class UpdateOldItemNeedDto {
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsString() @Length(2, 160) title!: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(2, 160) beneficiaryGroup!: string;
|
||||
@IsString() @Length(2, 2000) description!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class TransitionOldItemNeedDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_NEED_STATUSES) targetStatus!: string;
|
||||
}
|
||||
export class CreateOldItemDropOffPointDto {
|
||||
@IsString() @Length(2, 120) name!: string;
|
||||
@IsString() @Length(2, 120) regionText!: string;
|
||||
@IsString() @Length(5, 300) addressText!: string;
|
||||
@IsString() @Length(2, 200) openingHours!: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
|
||||
export class OldItemInventoryQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(OLD_ITEM_INVENTORY_STATUSES) status?: string;
|
||||
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
|
||||
export class CreateOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsOptional() @IsUUID('4') sourceDonationPublicId?: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(1, 160) itemName!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
|
||||
@IsString() @Length(1, 20) unit!: string;
|
||||
@IsString() @Length(2, 160) storageLocation!: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsDateString() inboundAt?: string;
|
||||
}
|
||||
|
||||
export class OldItemInventoryImportRowDto {
|
||||
@IsOptional() @IsString() @Length(1, 32) sourceDonationNo?: string;
|
||||
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
|
||||
@IsString() @Length(1, 160) itemName!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
|
||||
@IsString() @Length(1, 20) unit!: string;
|
||||
@IsString() @Length(2, 160) storageLocation!: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsDateString() inboundAt?: string;
|
||||
}
|
||||
|
||||
export class ImportOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(1, 255) originalFilename!: string;
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OldItemInventoryImportRowDto)
|
||||
rows!: OldItemInventoryImportRowDto[];
|
||||
}
|
||||
|
||||
export class TransitionOldItemInventoryDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsIn(OLD_ITEM_INVENTORY_STATUSES) targetStatus!: string;
|
||||
@IsOptional() @IsDateString() eventAt?: string;
|
||||
@IsOptional() @IsString() @Length(2, 160) locationText?: string;
|
||||
@IsOptional() @IsString() @Length(1, 1000) note?: string;
|
||||
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
|
||||
@IsOptional() @IsUUID('4') beneficiaryPublicId?: string;
|
||||
@IsOptional() @IsString() @Length(2, 120) destinationRegion?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
|
||||
}
|
||||
|
||||
export class ActivateOldItemRewardConfigDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 120) certificateTitle!: string;
|
||||
@IsString() @Length(10, 1000) certificateMessage!: string;
|
||||
@IsString() @Length(2, 120) issuerName!: string;
|
||||
@IsString() @Length(10, 500) legalNotice!: string;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) clothingPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) booksPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) householdPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(1000) sportsPoints!: number;
|
||||
@Type(() => Number) @IsInt() @Min(0) @Max(100000) maxPointsPerDonation!: number;
|
||||
}
|
||||
|
||||
export class IssueOldItemCertificateDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
}
|
||||
|
||||
export class RevokeOldItemCertificateDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 500) reason!: string;
|
||||
}
|
||||
|
||||
export class AdjustOldItemPointsDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@Type(() => Number) @IsInt() @Min(-100000) @Max(100000) pointsDelta!: number;
|
||||
@IsString() @Length(2, 500) reason!: string;
|
||||
}
|
||||
|
||||
export class OldItemPointAccountQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { API_ERROR_CODES, PERMISSION_CODES, type OldItemInventoryItem } from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
CreateOldItemInventoryDto,
|
||||
ImportOldItemInventoryDto,
|
||||
OldItemInventoryImportRowDto,
|
||||
OldItemInventoryQueryDto,
|
||||
TransitionOldItemInventoryDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
IN_STOCK: ['SORTED'],
|
||||
SORTED: ['DISINFECTED'],
|
||||
DISINFECTED: ['OUTBOUND'],
|
||||
OUTBOUND: ['DELIVERED'],
|
||||
DELIVERED: [],
|
||||
};
|
||||
const EVENT_BY_STATUS = {
|
||||
IN_STOCK: 'INBOUND',
|
||||
SORTED: 'SORTED',
|
||||
DISINFECTED: 'DISINFECTED',
|
||||
OUTBOUND: 'OUTBOUND',
|
||||
DELIVERED: 'DELIVERED',
|
||||
} as const;
|
||||
const INCLUDE = {
|
||||
sourceDonation: { select: { publicId: true, donationNo: true, status: true, revision: true } },
|
||||
targetNeed: { select: { publicId: true, title: true } },
|
||||
beneficiary: { select: { publicId: true } },
|
||||
events: {
|
||||
include: { operator: { include: { staffProfile: true } } },
|
||||
orderBy: { eventAt: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.OldItemInventoryItemInclude;
|
||||
type InventoryWithRelations = Prisma.OldItemInventoryItemGetPayload<{ include: typeof INCLUDE }>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemInventoryService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
|
||||
async list(q: OldItemInventoryQueryDto) {
|
||||
const where: Prisma.OldItemInventoryItemWhereInput = {
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.category ? { category: q.category as never } : {}),
|
||||
...(q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ inventoryNo: { contains: q.keyword } },
|
||||
{ itemName: { contains: q.keyword } },
|
||||
{ sourceDonation: { donationNo: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemInventoryItem.count({ where }),
|
||||
this.prisma.oldItemInventoryItem.findMany({
|
||||
where,
|
||||
include: INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((item) => this.map(item)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async get(publicId: string, actor: AuthContext, req: RequestContext) {
|
||||
const item = await this.find(publicId);
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_INVENTORY_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { item: this.map(item) };
|
||||
}
|
||||
|
||||
async create(input: CreateOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-inventory-create', input);
|
||||
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { inventoryItem: { include: INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
|
||||
}
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const links = await this.resolveLinks(
|
||||
tx,
|
||||
input.sourceDonationPublicId,
|
||||
undefined,
|
||||
input.targetNeedPublicId,
|
||||
);
|
||||
const eventAt = this.eventAt(input.inboundAt);
|
||||
const item = await tx.oldItemInventoryItem.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
inventoryNo: this.inventoryNo(),
|
||||
sourceDonationId: links.donation?.id,
|
||||
category: input.category as never,
|
||||
itemName: input.itemName.trim(),
|
||||
quantity: input.quantity,
|
||||
unit: input.unit.trim(),
|
||||
storageLocation: input.storageLocation.trim(),
|
||||
targetNeedId: links.need?.id,
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: item.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
eventType: 'INBOUND',
|
||||
toStatus: 'IN_STOCK',
|
||||
locationText: input.storageLocation.trim(),
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
eventAt,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: item.publicId,
|
||||
metadata: { category: input.category, quantity: input.quantity, source: 'MANUAL' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({
|
||||
where: { id: item.id },
|
||||
include: INCLUDE,
|
||||
});
|
||||
});
|
||||
return { item: this.map(created), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async import(input: ImportOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-inventory-import', input);
|
||||
const replay = await this.prisma.oldItemInventoryImport.findUnique({
|
||||
where: {
|
||||
createdById_clientRequestId: {
|
||||
createdById: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
include: { items: { include: INCLUDE, orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return {
|
||||
importPublicId: replay.publicId,
|
||||
items: replay.items.map((x) => this.map(x)),
|
||||
idempotentReplay: true,
|
||||
};
|
||||
}
|
||||
this.validateImportDuplicates(input.rows);
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const batch = await tx.oldItemInventoryImport.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
originalFilenameEncrypted: this.sensitive.encrypt(input.originalFilename.trim()),
|
||||
rowCount: input.rows.length,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
const items: InventoryWithRelations[] = [];
|
||||
for (const [index, row] of input.rows.entries()) {
|
||||
const links = await this.resolveLinks(
|
||||
tx,
|
||||
undefined,
|
||||
row.sourceDonationNo,
|
||||
row.targetNeedPublicId,
|
||||
);
|
||||
const item = await this.createImportRow(
|
||||
tx,
|
||||
batch.id,
|
||||
row,
|
||||
index,
|
||||
links,
|
||||
actor.userId,
|
||||
input.clientRequestId,
|
||||
fp,
|
||||
);
|
||||
items.push(item);
|
||||
}
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_IMPORTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY_IMPORT',
|
||||
resourcePublicId: batch.publicId,
|
||||
metadata: { rowCount: input.rows.length },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { batch, items };
|
||||
});
|
||||
return {
|
||||
importPublicId: created.batch.publicId,
|
||||
items: created.items.map((x) => this.map(x)),
|
||||
idempotentReplay: false,
|
||||
};
|
||||
}
|
||||
|
||||
async transition(
|
||||
publicId: string,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-inventory-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { inventoryItem: { include: INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
|
||||
}
|
||||
const current = await this.find(publicId);
|
||||
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||
if (!TRANSITIONS[current.status]?.includes(input.targetStatus)) throw this.invalid();
|
||||
if (
|
||||
input.targetStatus === 'OUTBOUND' &&
|
||||
!input.destinationRegion &&
|
||||
!input.targetNeedPublicId &&
|
||||
!input.beneficiaryPublicId
|
||||
)
|
||||
throw this.validation('出库时必须绑定物资需求、受助对象或帮扶区域之一');
|
||||
if (input.targetStatus === 'DELIVERED' && input.assistedPeople === undefined)
|
||||
throw this.validation('帮扶落地时必须填写实际帮扶人数');
|
||||
const eventAt = this.eventAt(input.eventAt);
|
||||
const previousAt = current.events.at(-1)?.eventAt;
|
||||
if (previousAt && eventAt < previousAt) throw this.validation('流转时间不能早于上一节点');
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const links = await this.resolveDestination(tx, current, input, actor);
|
||||
const changed = await tx.oldItemInventoryItem.updateMany({
|
||||
where: { id: current.id, revision: input.expectedRevision, status: current.status },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
revision: { increment: 1 },
|
||||
updatedById: actor.userId,
|
||||
...(input.locationText ? { storageLocation: input.locationText.trim() } : {}),
|
||||
...(input.targetStatus === 'OUTBOUND'
|
||||
? {
|
||||
targetNeedId: links.needId,
|
||||
beneficiaryId: links.beneficiaryId,
|
||||
destinationRegion: input.destinationRegion?.trim() ?? current.destinationRegion,
|
||||
}
|
||||
: {}),
|
||||
...(input.targetStatus === 'DELIVERED' ? { landingAt: eventAt } : {}),
|
||||
...(input.note ? { noteEncrypted: this.sensitive.encrypt(input.note.trim()) } : {}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: current.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
eventType: EVENT_BY_STATUS[input.targetStatus as keyof typeof EVENT_BY_STATUS],
|
||||
fromStatus: current.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
locationText:
|
||||
input.locationText?.trim() ??
|
||||
(input.targetStatus === 'OUTBOUND' ? input.destinationRegion?.trim() : null),
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
|
||||
eventAt,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.syncDonation(tx, current, input, actor, req, fp, eventAt);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_INVENTORY_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: INCLUDE,
|
||||
});
|
||||
});
|
||||
return { item: this.map(updated), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async export(q: OldItemInventoryQueryDto, actor: AuthContext, req: RequestContext) {
|
||||
const result = await this.list({
|
||||
...q,
|
||||
page: 1,
|
||||
pageSize: Math.min(5000, Math.max(q.pageSize, 5000)),
|
||||
});
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_INVENTORY_EXPORTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_INVENTORY_EXPORT',
|
||||
metadata: {
|
||||
rowCount: result.items.length,
|
||||
status: q.status ?? null,
|
||||
category: q.category ?? null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
rows: result.items.map((x) => ({
|
||||
inventoryNo: x.inventoryNo,
|
||||
sourceDonationNo: x.sourceDonation?.donationNo ?? '',
|
||||
category: x.category,
|
||||
itemName: x.itemName,
|
||||
quantity: x.quantity,
|
||||
unit: x.unit,
|
||||
storageLocation: x.storageLocation,
|
||||
status: x.status,
|
||||
targetNeedTitle: x.targetNeed?.title ?? '',
|
||||
beneficiaryPublicId: x.beneficiaryPublicId ?? '',
|
||||
destinationRegion: x.destinationRegion ?? '',
|
||||
landingAt: x.landingAt ?? '',
|
||||
createdAt: x.createdAt,
|
||||
})),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async createImportRow(
|
||||
tx: Prisma.TransactionClient,
|
||||
importId: bigint,
|
||||
row: OldItemInventoryImportRowDto,
|
||||
index: number,
|
||||
links: { donation: { id: bigint } | null; need: { id: bigint } | null },
|
||||
actorId: bigint,
|
||||
requestId: string,
|
||||
fp: string,
|
||||
) {
|
||||
const item = await tx.oldItemInventoryItem.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
inventoryNo: this.inventoryNo(),
|
||||
sourceDonationId: links.donation?.id,
|
||||
importId,
|
||||
category: row.category as never,
|
||||
itemName: row.itemName.trim(),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit.trim(),
|
||||
storageLocation: row.storageLocation.trim(),
|
||||
targetNeedId: links.need?.id,
|
||||
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
|
||||
createdById: actorId,
|
||||
updatedById: actorId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemInventoryEvent.create({
|
||||
data: {
|
||||
inventoryItemId: item.id,
|
||||
requestId: this.derivedRequestId(requestId, index),
|
||||
requestFingerprint: fp,
|
||||
eventType: 'INBOUND',
|
||||
toStatus: 'IN_STOCK',
|
||||
locationText: row.storageLocation.trim(),
|
||||
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
|
||||
eventAt: this.eventAt(row.inboundAt),
|
||||
operatorUserId: actorId,
|
||||
},
|
||||
});
|
||||
return tx.oldItemInventoryItem.findUniqueOrThrow({ where: { id: item.id }, include: INCLUDE });
|
||||
}
|
||||
|
||||
private async resolveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
donationPublicId?: string,
|
||||
donationNo?: string,
|
||||
needPublicId?: string,
|
||||
) {
|
||||
const [donation, need] = await Promise.all([
|
||||
donationPublicId || donationNo
|
||||
? tx.oldItemDonation.findFirst({
|
||||
where: donationPublicId ? { publicId: donationPublicId } : { donationNo },
|
||||
})
|
||||
: null,
|
||||
needPublicId ? tx.oldItemNeed.findUnique({ where: { publicId: needPublicId } }) : null,
|
||||
]);
|
||||
if ((donationPublicId || donationNo) && !donation) throw this.validation('来源捐赠编号不存在');
|
||||
if (donation && !['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(donation.status))
|
||||
throw this.validation('来源捐赠尚未接收,不能入库');
|
||||
if (donation) {
|
||||
const existing = await tx.oldItemInventoryItem.findUnique({
|
||||
where: { sourceDonationId: donation.id },
|
||||
select: { publicId: true },
|
||||
});
|
||||
if (existing) throw this.validation('来源捐赠已完成入库,不能重复绑定');
|
||||
}
|
||||
if (needPublicId && !need) throw this.validation('定向物资需求不存在');
|
||||
return { donation, need };
|
||||
}
|
||||
|
||||
private async resolveDestination(
|
||||
tx: Prisma.TransactionClient,
|
||||
current: InventoryWithRelations,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
) {
|
||||
const need = input.targetNeedPublicId
|
||||
? await tx.oldItemNeed.findUnique({ where: { publicId: input.targetNeedPublicId } })
|
||||
: null;
|
||||
if (
|
||||
input.beneficiaryPublicId &&
|
||||
!actor.permissions.includes(PERMISSION_CODES.BENEFICIARIES_READ) &&
|
||||
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
|
||||
)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.FORBIDDEN,
|
||||
'无权绑定受助对象',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
const beneficiary = input.beneficiaryPublicId
|
||||
? await tx.beneficiaryProfile.findFirst({
|
||||
where: { publicId: input.beneficiaryPublicId, status: 'ACTIVE' },
|
||||
})
|
||||
: null;
|
||||
if (input.targetNeedPublicId && !need) throw this.validation('物资需求不存在');
|
||||
if (input.beneficiaryPublicId && !beneficiary) throw this.validation('受助对象不存在或不可用');
|
||||
return {
|
||||
needId: need?.id ?? (input.targetNeedPublicId ? null : current.targetNeedId),
|
||||
beneficiaryId: beneficiary?.id ?? (input.beneficiaryPublicId ? null : current.beneficiaryId),
|
||||
};
|
||||
}
|
||||
|
||||
private async syncDonation(
|
||||
tx: Prisma.TransactionClient,
|
||||
current: InventoryWithRelations,
|
||||
input: TransitionOldItemInventoryDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
fp: string,
|
||||
eventAt: Date,
|
||||
) {
|
||||
const donation = current.sourceDonation;
|
||||
if (
|
||||
input.targetStatus === 'DELIVERED' &&
|
||||
current.targetNeedId &&
|
||||
(!donation || donation.status !== 'COMPLETED')
|
||||
) {
|
||||
const need = await tx.oldItemNeed.findUniqueOrThrow({
|
||||
where: { id: current.targetNeedId },
|
||||
});
|
||||
const fulfilled = Math.min(need.requestedQuantity, need.fulfilledQuantity + current.quantity);
|
||||
await tx.oldItemNeed.update({
|
||||
where: { id: need.id },
|
||||
data: {
|
||||
fulfilledQuantity: fulfilled,
|
||||
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
|
||||
? { status: 'FULFILLED', revision: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!donation) return;
|
||||
if (input.targetStatus === 'SORTED' && donation.status === 'RECEIVED') {
|
||||
await tx.oldItemDonation.update({
|
||||
where: { id: current.sourceDonationId! },
|
||||
data: { status: 'PROCESSING', revision: { increment: 1 } },
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.sourceDonationId!,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: 'RECEIVED',
|
||||
toStatus: 'PROCESSING',
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: eventAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.targetStatus === 'DELIVERED' && donation.status !== 'COMPLETED') {
|
||||
const allowed = donation.status === 'PROCESSING' || donation.status === 'RECEIVED';
|
||||
if (!allowed) throw this.invalid();
|
||||
await tx.oldItemDonation.update({
|
||||
where: { id: current.sourceDonationId! },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
revision: { increment: 1 },
|
||||
completedAt: eventAt,
|
||||
assistedPeople: input.assistedPeople ?? 0,
|
||||
},
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.sourceDonationId!,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: donation.status,
|
||||
toStatus: 'COMPLETED',
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: eventAt,
|
||||
},
|
||||
});
|
||||
await this.rewards.grantOnCompletion(
|
||||
tx,
|
||||
current.sourceDonationId!,
|
||||
input.clientRequestId,
|
||||
eventAt,
|
||||
actor,
|
||||
req,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async find(publicId: string) {
|
||||
const item = await this.prisma.oldItemInventoryItem.findUnique({
|
||||
where: { publicId },
|
||||
include: INCLUDE,
|
||||
});
|
||||
if (!item)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
'未找到该物资台账',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
return item;
|
||||
}
|
||||
private map(x: InventoryWithRelations): OldItemInventoryItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
inventoryNo: x.inventoryNo,
|
||||
sourceDonation: x.sourceDonation
|
||||
? { publicId: x.sourceDonation.publicId, donationNo: x.sourceDonation.donationNo }
|
||||
: null,
|
||||
category: x.category,
|
||||
itemName: x.itemName,
|
||||
quantity: x.quantity,
|
||||
unit: x.unit,
|
||||
storageLocation: x.storageLocation,
|
||||
status: x.status,
|
||||
targetNeed: x.targetNeed,
|
||||
beneficiaryPublicId: x.beneficiary?.publicId ?? null,
|
||||
destinationRegion: x.destinationRegion,
|
||||
note: x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
|
||||
landingAt: x.landingAt?.toISOString() ?? null,
|
||||
revision: x.revision,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
events: x.events.map((e) => ({
|
||||
eventType: e.eventType,
|
||||
fromStatus: e.fromStatus,
|
||||
status: e.toStatus,
|
||||
locationText: e.locationText,
|
||||
note: e.noteEncrypted ? this.sensitive.decrypt(e.noteEncrypted) : null,
|
||||
operatorName: e.operator.staffProfile?.displayName ?? e.operator.nickname ?? '工作人员',
|
||||
eventAt: e.eventAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
private validateImportDuplicates(rows: OldItemInventoryImportRowDto[]) {
|
||||
const numbers = rows.map((x) => x.sourceDonationNo).filter((x): x is string => Boolean(x));
|
||||
if (new Set(numbers).size !== numbers.length)
|
||||
throw this.validation('同一导入文件不能重复绑定来源捐赠编号');
|
||||
}
|
||||
private eventAt(value?: string) {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
if (Number.isNaN(date.getTime()) || date.getTime() > Date.now() + 300_000)
|
||||
throw this.validation('流转时间无效或晚于当前时间');
|
||||
return date;
|
||||
}
|
||||
private inventoryNo() {
|
||||
return `INV-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(5).toString('hex').toUpperCase()}`;
|
||||
}
|
||||
private derivedRequestId(requestId: string, index: number) {
|
||||
const hex = createHash('sha256').update(`${requestId}:${index}`).digest('hex').slice(0, 32);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private validation(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'台账已被更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private invalid() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
'当前台账状态不能执行该操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
OLD_ITEM_CATEGORY_LABELS,
|
||||
type OldItemCertificateItem,
|
||||
type OldItemPointAccountItem,
|
||||
type OldItemPointEntryItem,
|
||||
type OldItemRewardConfig,
|
||||
} from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { OldItemCategory, Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
ActivateOldItemRewardConfigDto,
|
||||
AdjustOldItemPointsDto,
|
||||
IssueOldItemCertificateDto,
|
||||
OldItemPointAccountQueryDto,
|
||||
RevokeOldItemCertificateDto,
|
||||
} from './dto/old-items.dto';
|
||||
|
||||
const DEFAULT_CONFIG: OldItemRewardConfig = {
|
||||
publicId: null,
|
||||
version: 0,
|
||||
certificateTitle: '爱心捐赠纪念证书',
|
||||
certificateMessage:
|
||||
'感谢 {recipient} 完成公益编号 {donationNo} 的旧物捐赠,共捐赠 {quantity} 件{category}物资。您的善意让闲置物品重新拥有温度。',
|
||||
issuerName: '乐活公益',
|
||||
legalNotice: '本证书仅用于记录公益参与,不作为捐赠收据、税务凭证或任何财产权利证明。',
|
||||
clothingPoints: 1,
|
||||
booksPoints: 1,
|
||||
householdPoints: 1,
|
||||
sportsPoints: 1,
|
||||
maxPointsPerDonation: 500,
|
||||
createdAt: null,
|
||||
};
|
||||
const ALLOWED_PLACEHOLDERS = new Set([
|
||||
'recipient',
|
||||
'donationNo',
|
||||
'quantity',
|
||||
'category',
|
||||
'completedDate',
|
||||
]);
|
||||
const CERTIFICATE_INCLUDE = { donation: true } satisfies Prisma.OldItemCertificateInclude;
|
||||
type CertificateWithDonation = Prisma.OldItemCertificateGetPayload<{
|
||||
include: typeof CERTIFICATE_INCLUDE;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemRewardsService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async config() {
|
||||
const active = await this.prisma.oldItemRewardConfig.findUnique({
|
||||
where: { activeKey: 'ACTIVE' },
|
||||
});
|
||||
return { config: active ? this.mapConfig(active) : DEFAULT_CONFIG, usesDefault: !active };
|
||||
}
|
||||
|
||||
async activateConfig(
|
||||
input: ActivateOldItemRewardConfigDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
this.validateTemplate(input.certificateMessage);
|
||||
const fp = this.fingerprint('old-item-reward-config', input);
|
||||
const replay = await this.prisma.oldItemRewardConfig.findUnique({
|
||||
where: { clientRequestId: input.clientRequestId },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { config: this.mapConfig(replay), idempotentReplay: true };
|
||||
}
|
||||
const config = await this.prisma.$transaction(async (tx) => {
|
||||
const latest = await tx.oldItemRewardConfig.aggregate({ _max: { version: true } });
|
||||
await tx.oldItemRewardConfig.updateMany({
|
||||
where: { activeKey: 'ACTIVE' },
|
||||
data: { activeKey: null },
|
||||
});
|
||||
const created = await tx.oldItemRewardConfig.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
version: (latest._max.version ?? 0) + 1,
|
||||
activeKey: 'ACTIVE',
|
||||
certificateTitle: input.certificateTitle.trim(),
|
||||
certificateMessage: input.certificateMessage.trim(),
|
||||
issuerName: input.issuerName.trim(),
|
||||
legalNotice: input.legalNotice.trim(),
|
||||
clothingPoints: input.clothingPoints,
|
||||
booksPoints: input.booksPoints,
|
||||
householdPoints: input.householdPoints,
|
||||
sportsPoints: input.sportsPoints,
|
||||
maxPointsPerDonation: input.maxPointsPerDonation,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_REWARD_CONFIG_ACTIVATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_REWARD_CONFIG',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { version: created.version },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return { config: this.mapConfig(config), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async grantOnCompletion(
|
||||
tx: Prisma.TransactionClient,
|
||||
donationId: bigint,
|
||||
completionRequestId: string,
|
||||
completedAt: Date,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const donation = await tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: donationId },
|
||||
include: { donor: true },
|
||||
});
|
||||
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
|
||||
const view = config ? this.mapConfig(config) : DEFAULT_CONFIG;
|
||||
const activeCertificate = await tx.oldItemCertificate.findFirst({
|
||||
where: { donationId, activeKey: 'ACTIVE' },
|
||||
});
|
||||
if (!activeCertificate) {
|
||||
const certificate = await this.createCertificate(
|
||||
tx,
|
||||
donation,
|
||||
config,
|
||||
view,
|
||||
this.derivedRequestId(completionRequestId, 'certificate'),
|
||||
actor.userId,
|
||||
completedAt,
|
||||
);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_CERTIFICATE_ISSUED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: certificate.publicId,
|
||||
metadata: { source: 'DONATION_COMPLETED' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
const sourceKey = `DONATION:${donation.publicId}`;
|
||||
const existingPoints = await tx.oldItemPointEntry.findUnique({ where: { sourceKey } });
|
||||
const points = this.points(view, donation.category, donation.quantity);
|
||||
if (!existingPoints && points > 0) {
|
||||
const account = await this.lockAccount(tx, donation.donorUserId);
|
||||
const balanceAfter = account.balance + points;
|
||||
await tx.oldItemPointAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { balance: balanceAfter, revision: { increment: 1 } },
|
||||
});
|
||||
const entry = await tx.oldItemPointEntry.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
accountId: account.id,
|
||||
userId: donation.donorUserId,
|
||||
donationId,
|
||||
configId: config?.id,
|
||||
requestId: this.derivedRequestId(completionRequestId, 'points'),
|
||||
requestFingerprint: this.fingerprint('old-item-points-earned', {
|
||||
donationPublicId: donation.publicId,
|
||||
points,
|
||||
}),
|
||||
sourceKey,
|
||||
entryType: 'EARNED',
|
||||
pointsDelta: points,
|
||||
balanceAfter,
|
||||
noteEncrypted: this.sensitive.encrypt('旧物捐赠完成自动获得公益荣誉积分'),
|
||||
operatorUserId: actor.userId,
|
||||
createdAt: completedAt,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_POINTS_EARNED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_POINT_ENTRY',
|
||||
resourcePublicId: entry.publicId,
|
||||
metadata: { points, source: 'DONATION_COMPLETED' },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async myRewards(actor: AuthContext) {
|
||||
const [account, certificates, entries] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemPointAccount.findUnique({ where: { userId: actor.userId } }),
|
||||
this.prisma.oldItemCertificate.findMany({
|
||||
where: { donation: { donorUserId: actor.userId } },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.oldItemPointEntry.findMany({
|
||||
where: { userId: actor.userId },
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
balance: account?.balance ?? 0,
|
||||
certificates: certificates.map((x) => this.mapCertificate(x, true)),
|
||||
entries: entries.map((x) => this.mapPointEntry(x, true)),
|
||||
};
|
||||
}
|
||||
|
||||
async myCertificate(publicId: string, actor: AuthContext) {
|
||||
const certificate = await this.prisma.oldItemCertificate.findFirst({
|
||||
where: { publicId, donation: { donorUserId: actor.userId } },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (!certificate) throw this.notFound('未找到该电子证书');
|
||||
return { certificate: this.mapCertificate(certificate, true) };
|
||||
}
|
||||
|
||||
async accounts(q: OldItemPointAccountQueryDto) {
|
||||
const where: Prisma.OldItemPointAccountWhereInput = q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ publicId: { contains: q.keyword } },
|
||||
{ user: { publicId: { contains: q.keyword } } },
|
||||
{ user: { nickname: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [total, accounts] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemPointAccount.count({ where }),
|
||||
this.prisma.oldItemPointAccount.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: accounts.map((x): OldItemPointAccountItem => ({
|
||||
publicId: x.publicId,
|
||||
userPublicId: x.user.publicId,
|
||||
displayName: x.user.nickname ?? '爱心用户',
|
||||
balance: x.balance,
|
||||
revision: x.revision,
|
||||
updatedAt: x.updatedAt.toISOString(),
|
||||
})),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async certificates(q: OldItemPointAccountQueryDto) {
|
||||
const where: Prisma.OldItemCertificateWhereInput = q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ certificateNo: { contains: q.keyword } },
|
||||
{ donation: { donationNo: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [total, certificates] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemCertificate.count({ where }),
|
||||
this.prisma.oldItemCertificate.findMany({
|
||||
where,
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: certificates.map((x) => this.mapCertificate(x, false)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async issue(
|
||||
donationPublicId: string,
|
||||
input: IssueOldItemCertificateDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-certificate-issue', { donationPublicId, ...input });
|
||||
const replay = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { issueRequestId: input.clientRequestId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.issueFingerprint !== fp) throw this.duplicate();
|
||||
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const donation = await this.prisma.oldItemDonation.findUnique({
|
||||
where: { publicId: donationPublicId },
|
||||
include: { donor: true },
|
||||
});
|
||||
if (!donation) throw this.notFound('未找到该捐赠记录');
|
||||
if (donation.status !== 'COMPLETED' || !donation.completedAt)
|
||||
throw this.invalid('捐赠完成后才能补发证书');
|
||||
const certificate = await this.prisma.$transaction(async (tx) => {
|
||||
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
|
||||
const active = await tx.oldItemCertificate.findFirst({
|
||||
where: { donationId: donation.id, activeKey: 'ACTIVE' },
|
||||
});
|
||||
if (active) {
|
||||
await tx.oldItemCertificate.update({
|
||||
where: { id: active.id },
|
||||
data: {
|
||||
activeKey: null,
|
||||
status: 'REVOKED',
|
||||
revokedById: actor.userId,
|
||||
revokedRequestId: this.derivedRequestId(input.clientRequestId, 'reissue-revoke'),
|
||||
revokedFingerprint: fp,
|
||||
revokeReasonEncrypted: this.sensitive.encrypt('因后台补发新证书自动撤销旧证书'),
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
const created = await this.createCertificate(
|
||||
tx,
|
||||
donation,
|
||||
config,
|
||||
config ? this.mapConfig(config) : DEFAULT_CONFIG,
|
||||
input.clientRequestId,
|
||||
actor.userId,
|
||||
new Date(),
|
||||
fp,
|
||||
);
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: active ? 'OLD_ITEM_CERTIFICATE_REISSUED' : 'OLD_ITEM_CERTIFICATE_ISSUED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { donationPublicId, replacedCertificate: Boolean(active) },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemCertificate.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async revoke(
|
||||
publicId: string,
|
||||
input: RevokeOldItemCertificateDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const fp = this.fingerprint('old-item-certificate-revoke', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { revokedRequestId: input.clientRequestId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.revokedFingerprint !== fp) throw this.duplicate();
|
||||
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const current = await this.prisma.oldItemCertificate.findUnique({
|
||||
where: { publicId },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
if (!current) throw this.notFound('未找到该电子证书');
|
||||
if (current.status !== 'ISSUED') throw this.invalid('该证书已撤销');
|
||||
const certificate = await this.prisma.$transaction(async (tx) => {
|
||||
const changed = await tx.oldItemCertificate.updateMany({
|
||||
where: { id: current.id, status: 'ISSUED' },
|
||||
data: {
|
||||
activeKey: null,
|
||||
status: 'REVOKED',
|
||||
revokedById: actor.userId,
|
||||
revokedRequestId: input.clientRequestId,
|
||||
revokedFingerprint: fp,
|
||||
revokeReasonEncrypted: this.sensitive.encrypt(input.reason.trim()),
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
const updated = await tx.oldItemCertificate.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: CERTIFICATE_INCLUDE,
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_CERTIFICATE_REVOKED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_CERTIFICATE',
|
||||
resourcePublicId: publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async adjust(
|
||||
userPublicId: string,
|
||||
input: AdjustOldItemPointsDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
if (input.pointsDelta === 0) throw this.validation('积分调整值不能为 0');
|
||||
const fp = this.fingerprint('old-item-points-adjust', { userPublicId, ...input });
|
||||
const replay = await this.prisma.oldItemPointEntry.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { entry: this.mapPointEntry(replay, false), idempotentReplay: true };
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { publicId: userPublicId } });
|
||||
if (!user) throw this.notFound('未找到该用户');
|
||||
const entry = await this.prisma.$transaction(async (tx) => {
|
||||
const account = await this.lockAccount(tx, user.id);
|
||||
if (account.revision !== input.expectedRevision) throw this.conflict();
|
||||
const balanceAfter = account.balance + input.pointsDelta;
|
||||
if (balanceAfter < 0) throw this.validation('扣减后积分不能小于 0');
|
||||
const changed = await tx.oldItemPointAccount.updateMany({
|
||||
where: { id: account.id, revision: input.expectedRevision },
|
||||
data: { balance: balanceAfter, revision: { increment: 1 } },
|
||||
});
|
||||
if (changed.count !== 1) throw this.conflict();
|
||||
const created = await tx.oldItemPointEntry.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
accountId: account.id,
|
||||
userId: user.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
sourceKey: `ADJUSTMENT:${input.clientRequestId}`,
|
||||
entryType: 'ADJUSTMENT',
|
||||
pointsDelta: input.pointsDelta,
|
||||
balanceAfter,
|
||||
noteEncrypted: this.sensitive.encrypt(input.reason.trim()),
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
include: { donation: { select: { donationNo: true } } },
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_POINTS_ADJUSTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_POINT_ENTRY',
|
||||
resourcePublicId: created.publicId,
|
||||
metadata: { pointsDelta: input.pointsDelta },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return { entry: this.mapPointEntry(entry, false), idempotentReplay: false };
|
||||
}
|
||||
|
||||
private async createCertificate(
|
||||
tx: Prisma.TransactionClient,
|
||||
donation: Prisma.OldItemDonationGetPayload<{ include: { donor: true } }>,
|
||||
config: Prisma.OldItemRewardConfigGetPayload<object> | null,
|
||||
view: OldItemRewardConfig,
|
||||
requestId: string,
|
||||
actorId: bigint,
|
||||
issuedAt: Date,
|
||||
fingerprint?: string,
|
||||
) {
|
||||
const recipient = donation.donor.nickname?.trim() || '爱心用户';
|
||||
return tx.oldItemCertificate.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
certificateNo: this.certificateNo(),
|
||||
donationId: donation.id,
|
||||
configId: config?.id,
|
||||
issueRequestId: requestId,
|
||||
issueFingerprint:
|
||||
fingerprint ??
|
||||
this.fingerprint('old-item-certificate-auto', {
|
||||
donationPublicId: donation.publicId,
|
||||
requestId,
|
||||
}),
|
||||
activeKey: 'ACTIVE',
|
||||
recipientNameEncrypted: this.sensitive.encrypt(recipient),
|
||||
titleSnapshot: view.certificateTitle,
|
||||
messageSnapshot: this.render(view.certificateMessage, {
|
||||
recipient,
|
||||
donationNo: donation.donationNo,
|
||||
quantity: String(donation.quantity),
|
||||
category: OLD_ITEM_CATEGORY_LABELS[donation.category],
|
||||
completedDate: (donation.completedAt ?? issuedAt).toISOString().slice(0, 10),
|
||||
}),
|
||||
issuerSnapshot: view.issuerName,
|
||||
legalNoticeSnapshot: view.legalNotice,
|
||||
issuedById: actorId,
|
||||
issuedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async lockAccount(tx: Prisma.TransactionClient, userId: bigint) {
|
||||
await tx.oldItemPointAccount.upsert({
|
||||
where: { userId },
|
||||
update: {},
|
||||
create: { publicId: randomUUID(), userId },
|
||||
});
|
||||
await tx.$queryRaw`SELECT id FROM old_item_point_accounts WHERE user_id = ${userId} FOR UPDATE`;
|
||||
return tx.oldItemPointAccount.findUniqueOrThrow({ where: { userId } });
|
||||
}
|
||||
|
||||
private points(config: OldItemRewardConfig, category: OldItemCategory, quantity: number) {
|
||||
const perItem = {
|
||||
CLOTHING: config.clothingPoints,
|
||||
BOOKS: config.booksPoints,
|
||||
HOUSEHOLD: config.householdPoints,
|
||||
SPORTS: config.sportsPoints,
|
||||
}[category];
|
||||
return Math.min(config.maxPointsPerDonation, perItem * quantity);
|
||||
}
|
||||
|
||||
private mapConfig(x: Prisma.OldItemRewardConfigGetPayload<object>): OldItemRewardConfig {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
version: x.version,
|
||||
certificateTitle: x.certificateTitle,
|
||||
certificateMessage: x.certificateMessage,
|
||||
issuerName: x.issuerName,
|
||||
legalNotice: x.legalNotice,
|
||||
clothingPoints: x.clothingPoints,
|
||||
booksPoints: x.booksPoints,
|
||||
householdPoints: x.householdPoints,
|
||||
sportsPoints: x.sportsPoints,
|
||||
maxPointsPerDonation: x.maxPointsPerDonation,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapCertificate(
|
||||
x: CertificateWithDonation,
|
||||
revealRecipient: boolean,
|
||||
): OldItemCertificateItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
certificateNo: x.certificateNo,
|
||||
donationPublicId: x.donation.publicId,
|
||||
donationNo: x.donation.donationNo,
|
||||
status: x.status,
|
||||
recipientName: revealRecipient
|
||||
? this.sensitive.decrypt(x.recipientNameEncrypted)
|
||||
: '爱心用户',
|
||||
title: x.titleSnapshot,
|
||||
message: x.messageSnapshot,
|
||||
issuerName: x.issuerSnapshot,
|
||||
legalNotice: x.legalNoticeSnapshot,
|
||||
issuedAt: x.issuedAt.toISOString(),
|
||||
revokedAt: x.revokedAt?.toISOString() ?? null,
|
||||
revokeReason:
|
||||
revealRecipient && x.revokeReasonEncrypted
|
||||
? this.sensitive.decrypt(x.revokeReasonEncrypted)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPointEntry(
|
||||
x: Prisma.OldItemPointEntryGetPayload<{
|
||||
include: { donation: { select: { donationNo: true } } };
|
||||
}>,
|
||||
revealNote: boolean,
|
||||
): OldItemPointEntryItem {
|
||||
return {
|
||||
publicId: x.publicId,
|
||||
entryType: x.entryType,
|
||||
pointsDelta: x.pointsDelta,
|
||||
balanceAfter: x.balanceAfter,
|
||||
donationNo: x.donation?.donationNo ?? null,
|
||||
note: revealNote && x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private validateTemplate(template: string) {
|
||||
for (const match of template.matchAll(/\{([^{}]+)\}/g)) {
|
||||
if (!ALLOWED_PLACEHOLDERS.has(match[1]!))
|
||||
throw this.validation(`证书模板包含不支持的占位符:{${match[1]}}`);
|
||||
}
|
||||
}
|
||||
private render(template: string, values: Record<string, string>) {
|
||||
return template.replace(/\{([^{}]+)\}/g, (all, key: string) => values[key] ?? all);
|
||||
}
|
||||
private certificateNo() {
|
||||
return `CERT-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(6).toString('hex').toUpperCase()}`;
|
||||
}
|
||||
private derivedRequestId(requestId: string, domain: string) {
|
||||
const hex = createHash('sha256').update(`${domain}:${requestId}`).digest('hex').slice(0, 32);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private validation(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'积分账户已更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private invalid(message: string) {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
message,
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private notFound(message: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
PERMISSION_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { MediaService } from '../participation/media.service';
|
||||
import {
|
||||
CreateOldItemDonationDto,
|
||||
CreateOldItemMediaUploadIntentDto,
|
||||
CreateOldItemDropOffPointDto,
|
||||
CreateOldItemNeedDto,
|
||||
OldItemDonationQueryDto,
|
||||
OldItemNeedQueryDto,
|
||||
TransitionOldItemDonationDto,
|
||||
TransitionOldItemNeedDto,
|
||||
UpdateOldItemNeedDto,
|
||||
CreateOldItemInventoryDto,
|
||||
ImportOldItemInventoryDto,
|
||||
OldItemInventoryQueryDto,
|
||||
TransitionOldItemInventoryDto,
|
||||
ActivateOldItemRewardConfigDto,
|
||||
AdjustOldItemPointsDto,
|
||||
IssueOldItemCertificateDto,
|
||||
OldItemPointAccountQueryDto,
|
||||
RevokeOldItemCertificateDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemInventoryService } from './old-item-inventory.service';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
import { OldItemsService } from './old-items.service';
|
||||
const strict = (expectedType: new () => object) =>
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType,
|
||||
});
|
||||
|
||||
@Controller('api/v1/old-items')
|
||||
export class OldItemsController {
|
||||
constructor(
|
||||
@Inject(OldItemsService) private readonly service: OldItemsService,
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
@Get('stats') @Public() stats() {
|
||||
return this.service.stats();
|
||||
}
|
||||
@Get('needs') @Public() needs(@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto) {
|
||||
return this.service.listNeeds(q);
|
||||
}
|
||||
@Get('needs/:publicId') @Public() need(@Param('publicId') id: string) {
|
||||
return this.service.getNeed(id);
|
||||
}
|
||||
@Get('drop-off-points') @Public() points() {
|
||||
return this.service.dropOffPoints();
|
||||
}
|
||||
@Post('media/upload-intents') @RequireSessionChannel(SessionChannel.WECHAT) mediaIntent(
|
||||
@Body(strict(CreateOldItemMediaUploadIntentDto)) input: CreateOldItemMediaUploadIntentDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
if (input.businessType !== 'OLD_ITEM_DONATION_IMAGE')
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.VALIDATION_ERROR,
|
||||
'图片业务类型无效',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
return this.media.createIntent(input, actor, req);
|
||||
}
|
||||
@Post('media/:publicId/confirm') @RequireSessionChannel(SessionChannel.WECHAT) confirm(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
): Promise<ConfirmUploadResponse> {
|
||||
return this.media.confirm(id, actor, req);
|
||||
}
|
||||
@Post('donations') @RequireSessionChannel(SessionChannel.WECHAT) create(
|
||||
@Body(strict(CreateOldItemDonationDto)) input: CreateOldItemDonationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createDonation(input, actor, req);
|
||||
}
|
||||
@Get('me/donations') @RequireSessionChannel(SessionChannel.WECHAT) mine(
|
||||
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.service.myDonations(q, actor);
|
||||
}
|
||||
@Get('me/donations/:publicId') @RequireSessionChannel(SessionChannel.WECHAT) mineOne(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.service.myDonation(id, actor);
|
||||
}
|
||||
@Get('me/media/:publicId/download')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
image(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.imageDownload(id, actor, req);
|
||||
}
|
||||
@Get('me/rewards') @RequireSessionChannel(SessionChannel.WECHAT) myRewards(
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
) {
|
||||
return this.rewards.myRewards(actor);
|
||||
}
|
||||
@Get('me/certificates/:publicId')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
myCertificate(@Param('publicId') id: string, @CurrentAuth() actor: AuthContext) {
|
||||
return this.rewards.myCertificate(id, actor);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/v1/admin/old-items')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class AdminOldItemsController {
|
||||
constructor(
|
||||
@Inject(OldItemsService) private readonly service: OldItemsService,
|
||||
@Inject(OldItemInventoryService) private readonly inventory: OldItemInventoryService,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
@Get('assignees') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) assignees() {
|
||||
return this.service.assignees();
|
||||
}
|
||||
@Get('media/:publicId/download') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) image(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.imageDownload(id, actor, req, true);
|
||||
}
|
||||
@Get('donations') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donations(
|
||||
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
|
||||
) {
|
||||
return this.service.adminDonations(q);
|
||||
}
|
||||
@Get('donations/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donation(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.adminDonation(id, actor, req);
|
||||
}
|
||||
@Post('donations/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE)
|
||||
transition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemDonationDto)) input: TransitionOldItemDonationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transitionDonation(id, input, actor, req);
|
||||
}
|
||||
@Get('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) needs(
|
||||
@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto,
|
||||
) {
|
||||
return this.service.listNeeds(q, true);
|
||||
}
|
||||
@Get('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) need(
|
||||
@Param('publicId') id: string,
|
||||
) {
|
||||
return this.service.getNeed(id, true);
|
||||
}
|
||||
@Post('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) createNeed(
|
||||
@Body(strict(CreateOldItemNeedDto)) input: CreateOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createNeed(input, actor, req);
|
||||
}
|
||||
@Patch('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) updateNeed(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(UpdateOldItemNeedDto)) input: UpdateOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.updateNeed(id, input, actor, req);
|
||||
}
|
||||
@Post('needs/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE)
|
||||
transitionNeed(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemNeedDto)) input: TransitionOldItemNeedDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transitionNeed(id, input, actor, req);
|
||||
}
|
||||
@Post('drop-off-points') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE) createPoint(
|
||||
@Body(strict(CreateOldItemDropOffPointDto)) input: CreateOldItemDropOffPointDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.createDropOffPoint(input, actor, req);
|
||||
}
|
||||
|
||||
@Get('inventory')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
|
||||
inventoryList(@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto) {
|
||||
return this.inventory.list(q);
|
||||
}
|
||||
|
||||
@Get('inventory/:publicId')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
|
||||
inventoryOne(
|
||||
@Param('publicId') id: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.get(id, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
|
||||
inventoryCreate(
|
||||
@Body(strict(CreateOldItemInventoryDto)) input: CreateOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.create(input, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory/import')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_IMPORT)
|
||||
inventoryImport(
|
||||
@Body(strict(ImportOldItemInventoryDto)) input: ImportOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.import(input, actor, req);
|
||||
}
|
||||
|
||||
@Post('inventory/:publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
|
||||
inventoryTransition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionOldItemInventoryDto)) input: TransitionOldItemInventoryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.transition(id, input, actor, req);
|
||||
}
|
||||
|
||||
@Get('inventory-export/file')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_EXPORT)
|
||||
inventoryExport(
|
||||
@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.inventory.export(q, actor, req);
|
||||
}
|
||||
@Get('reward-config')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
rewardConfig() {
|
||||
return this.rewards.config();
|
||||
}
|
||||
@Post('reward-config/activate')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
activateRewardConfig(
|
||||
@Body(strict(ActivateOldItemRewardConfigDto)) input: ActivateOldItemRewardConfigDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.activateConfig(input, actor, req);
|
||||
}
|
||||
@Get('reward-accounts')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
rewardAccounts(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
|
||||
return this.rewards.accounts(q);
|
||||
}
|
||||
@Post('reward-accounts/:userPublicId/adjust')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
adjustRewardPoints(
|
||||
@Param('userPublicId') userPublicId: string,
|
||||
@Body(strict(AdjustOldItemPointsDto)) input: AdjustOldItemPointsDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.adjust(userPublicId, input, actor, req);
|
||||
}
|
||||
@Get('certificates')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
|
||||
certificates(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
|
||||
return this.rewards.certificates(q);
|
||||
}
|
||||
@Post('donations/:publicId/certificate')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
issueCertificate(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(strict(IssueOldItemCertificateDto)) input: IssueOldItemCertificateDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.issue(publicId, input, actor, req);
|
||||
}
|
||||
@Post('certificates/:publicId/revoke')
|
||||
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
|
||||
revokeCertificate(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(strict(RevokeOldItemCertificateDto)) input: RevokeOldItemCertificateDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.rewards.revoke(publicId, input, actor, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { ParticipationModule } from '../participation/participation.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { AdminOldItemsController, OldItemsController } from './old-items.controller';
|
||||
import { OldItemsService } from './old-items.service';
|
||||
import { OldItemInventoryService } from './old-item-inventory.service';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
@Module({
|
||||
imports: [AuditModule, ParticipationModule, FilesModule],
|
||||
controllers: [OldItemsController, AdminOldItemsController],
|
||||
providers: [OldItemsService, OldItemInventoryService, OldItemRewardsService],
|
||||
})
|
||||
export class OldItemsModule {}
|
||||
@@ -0,0 +1,763 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type OldItemDonationItem,
|
||||
type OldItemNeedItem,
|
||||
type OldItemStats,
|
||||
} from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
|
||||
import type { OldItemNeed, Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
CreateOldItemDonationDto,
|
||||
CreateOldItemDropOffPointDto,
|
||||
CreateOldItemNeedDto,
|
||||
OldItemDonationQueryDto,
|
||||
OldItemNeedQueryDto,
|
||||
TransitionOldItemDonationDto,
|
||||
TransitionOldItemNeedDto,
|
||||
UpdateOldItemNeedDto,
|
||||
} from './dto/old-items.dto';
|
||||
import { OldItemRewardsService } from './old-item-rewards.service';
|
||||
|
||||
const DONATION_TRANSITIONS: Record<string, string[]> = {
|
||||
SUBMITTED: ['REJECTED', 'WAITING_PICKUP', 'WAITING_DROP_OFF', 'CANCELLED'],
|
||||
WAITING_PICKUP: ['RECEIVED', 'CANCELLED'],
|
||||
WAITING_DROP_OFF: ['RECEIVED', 'CANCELLED'],
|
||||
RECEIVED: ['PROCESSING'],
|
||||
PROCESSING: ['COMPLETED'],
|
||||
REJECTED: [],
|
||||
COMPLETED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
const NEED_TRANSITIONS: Record<string, string[]> = {
|
||||
DRAFT: ['PUBLISHED', 'CLOSED'],
|
||||
PUBLISHED: ['FULFILLED', 'CLOSED'],
|
||||
FULFILLED: ['CLOSED'],
|
||||
CLOSED: [],
|
||||
};
|
||||
const DONATION_INCLUDE = {
|
||||
history: { orderBy: { createdAt: 'asc' as const } },
|
||||
targetNeed: true,
|
||||
dropOffPoint: true,
|
||||
assignee: true,
|
||||
donor: true,
|
||||
files: { include: { file: true }, orderBy: { sortOrder: 'asc' as const } },
|
||||
inventoryItems: {
|
||||
include: { events: { orderBy: { eventAt: 'asc' as const } } },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.OldItemDonationInclude;
|
||||
type DonationWithRelations = Prisma.OldItemDonationGetPayload<{
|
||||
include: typeof DONATION_INCLUDE;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class OldItemsService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
|
||||
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
|
||||
) {}
|
||||
|
||||
async assignees() {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { userType: 'STAFF', status: 'ACTIVE' },
|
||||
include: { staffProfile: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
items: users.map((user) => ({
|
||||
publicId: user.publicId,
|
||||
displayName: user.staffProfile?.displayName ?? user.nickname ?? '工作人员',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async imageDownload(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
admin = false,
|
||||
) {
|
||||
if (!this.storage.configured)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.STORAGE_UNAVAILABLE,
|
||||
'图片服务尚未配置或暂时不可用',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
const file = await this.prisma.file.findFirst({
|
||||
where: { publicId, status: 'AVAILABLE', businessType: 'OLD_ITEM_DONATION_IMAGE' },
|
||||
include: { oldItemDonations: { include: { donation: true } } },
|
||||
});
|
||||
const donation = file?.oldItemDonations[0]?.donation;
|
||||
if (!file || !donation || (!admin && donation.donorUserId !== actor.userId))
|
||||
throw this.notFound('未找到该捐赠图片');
|
||||
const expiresIn = 300;
|
||||
let downloadUrl: string;
|
||||
try {
|
||||
downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
|
||||
} catch {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.STORAGE_UNAVAILABLE,
|
||||
'图片服务暂时不可用',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
await this.audit.record(request, {
|
||||
action: 'OLD_ITEM_PRIVATE_IMAGE_URL_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'FILE',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { downloadUrl, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() };
|
||||
}
|
||||
|
||||
async stats(): Promise<OldItemStats> {
|
||||
const month = new Date();
|
||||
month.setDate(1);
|
||||
month.setHours(0, 0, 0, 0);
|
||||
const [items, people, acts] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemDonation.aggregate({
|
||||
_sum: { quantity: true },
|
||||
where: { status: { in: ['RECEIVED', 'PROCESSING', 'COMPLETED'] } },
|
||||
}),
|
||||
this.prisma.oldItemDonation.aggregate({
|
||||
_sum: { assistedPeople: true },
|
||||
where: { status: 'COMPLETED' },
|
||||
}),
|
||||
this.prisma.oldItemDonation.count({ where: { receivedAt: { gte: month } } }),
|
||||
]);
|
||||
return {
|
||||
totalItems: items._sum.quantity ?? 0,
|
||||
assistedPeople: people._sum.assistedPeople ?? 0,
|
||||
monthlyKindActs: acts,
|
||||
};
|
||||
}
|
||||
|
||||
async dropOffPoints() {
|
||||
return {
|
||||
items: await this.prisma.oldItemDropOffPoint.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
regionText: true,
|
||||
addressText: true,
|
||||
openingHours: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listNeeds(q: OldItemNeedQueryDto, admin = false) {
|
||||
const where: Prisma.OldItemNeedWhereInput = {
|
||||
...(admin ? {} : { status: 'PUBLISHED' }),
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.category ? { category: q.category as never } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemNeed.count({ where }),
|
||||
this.prisma.oldItemNeed.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: 'desc' }, { sortOrder: 'desc' }, { publishedAt: 'desc' }],
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items: items.map((x) => this.need(x)), page: q.page, pageSize: q.pageSize, total };
|
||||
}
|
||||
async getNeed(publicId: string, admin = false) {
|
||||
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
|
||||
if (!x || (!admin && x.status !== 'PUBLISHED')) throw this.notFound('未找到该物资需求');
|
||||
return { need: this.need(x) };
|
||||
}
|
||||
|
||||
async createDonation(input: CreateOldItemDonationDto, actor: AuthContext, req: RequestContext) {
|
||||
this.validateMethod(input);
|
||||
const fp = this.fingerprint('old-item-donation-create', input);
|
||||
const replay = await this.prisma.oldItemDonation.findUnique({
|
||||
where: {
|
||||
donorUserId_clientRequestId: {
|
||||
donorUserId: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { donation: this.mapDonation(replay, true), idempotentReplay: true };
|
||||
}
|
||||
const [point, need, files] = await Promise.all([
|
||||
input.dropOffPointPublicId
|
||||
? this.prisma.oldItemDropOffPoint.findFirst({
|
||||
where: { publicId: input.dropOffPointPublicId, isActive: true },
|
||||
})
|
||||
: null,
|
||||
input.targetNeedPublicId
|
||||
? this.prisma.oldItemNeed.findFirst({
|
||||
where: { publicId: input.targetNeedPublicId, status: 'PUBLISHED' },
|
||||
})
|
||||
: null,
|
||||
input.imagePublicIds?.length
|
||||
? this.prisma.file.findMany({
|
||||
where: {
|
||||
publicId: { in: input.imagePublicIds },
|
||||
uploaderUserId: actor.userId,
|
||||
status: 'AVAILABLE',
|
||||
businessType: 'OLD_ITEM_DONATION_IMAGE',
|
||||
caseId: null,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
if (input.method === 'DROP_OFF' && !point) throw this.validation('请选择有效的定点投递地址');
|
||||
if (input.targetNeedPublicId && !need) throw this.validation('定向需求已下架或不存在');
|
||||
if ((input.imagePublicIds?.length ?? 0) !== files.length || files.length > 6)
|
||||
throw this.validation('捐赠图片无效、未确认或超过 6 张');
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const donation = await tx.oldItemDonation.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
donationNo: this.donationNo(),
|
||||
donorUserId: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
category: input.category as never,
|
||||
itemCondition: input.itemCondition as never,
|
||||
quantity: input.quantity,
|
||||
descriptionEncrypted: this.sensitive.encrypt(input.description.trim()),
|
||||
method: input.method,
|
||||
contactNameEncrypted: this.sensitive.encrypt(input.contactName.trim()),
|
||||
mobileEncrypted: this.sensitive.encrypt(input.mobile),
|
||||
mobileHash: this.sensitive.lookupHash('old-item-mobile', input.mobile),
|
||||
addressEncrypted: input.address ? this.sensitive.encrypt(input.address.trim()) : null,
|
||||
appointmentAt: input.appointmentAt ? new Date(input.appointmentAt) : null,
|
||||
dropOffPointId: point?.id,
|
||||
targetNeedId: need?.id,
|
||||
files: { create: files.map((f, i) => ({ fileId: f.id, sortOrder: i })) },
|
||||
},
|
||||
});
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: donation.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
toStatus: 'SUBMITTED',
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DONATION_SUBMITTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: donation.publicId,
|
||||
metadata: { category: input.category, method: input.method, quantity: input.quantity },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: donation.id },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { donation: this.mapDonation(created, true), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async myDonations(q: OldItemDonationQueryDto, actor: AuthContext) {
|
||||
return this.listDonations({ ...q }, { donorUserId: actor.userId }, true);
|
||||
}
|
||||
async myDonation(publicId: string, actor: AuthContext) {
|
||||
const x = await this.findDonation(publicId);
|
||||
if (x.donorUserId !== actor.userId) throw this.notFound('未找到该捐赠记录');
|
||||
return { donation: this.mapDonation(x, true) };
|
||||
}
|
||||
async adminDonations(q: OldItemDonationQueryDto) {
|
||||
const where: Prisma.OldItemDonationWhereInput = {
|
||||
...(q.status ? { status: q.status as never } : {}),
|
||||
...(q.method ? { method: q.method as never } : {}),
|
||||
...(q.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ donationNo: { contains: q.keyword } },
|
||||
{ donor: { nickname: { contains: q.keyword } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return this.listDonations(q, where, false);
|
||||
}
|
||||
async adminDonation(publicId: string, actor: AuthContext, req: RequestContext) {
|
||||
const x = await this.findDonation(publicId);
|
||||
await this.audit.record(req, {
|
||||
action: 'OLD_ITEM_DONATION_SENSITIVE_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { donation: this.mapDonation(x, true) };
|
||||
}
|
||||
|
||||
async transitionDonation(
|
||||
publicId: string,
|
||||
input: TransitionOldItemDonationDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const current = await this.findDonation(publicId),
|
||||
fp = this.fingerprint('old-item-donation-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemDonationStatusHistory.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { donation: { include: DONATION_INCLUDE } },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { donation: this.mapDonation(replay.donation, true), idempotentReplay: true };
|
||||
}
|
||||
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||
if (!DONATION_TRANSITIONS[current.status]?.includes(input.targetStatus))
|
||||
throw this.invalid('当前捐赠状态不能执行该操作');
|
||||
if (input.targetStatus === 'WAITING_PICKUP' && current.method !== 'PICKUP')
|
||||
throw this.invalid('投递订单不能进入待取件');
|
||||
if (input.targetStatus === 'WAITING_DROP_OFF' && current.method !== 'DROP_OFF')
|
||||
throw this.invalid('取件订单不能进入待投递');
|
||||
if (input.targetStatus === 'REJECTED' && !input.note)
|
||||
throw this.validation('驳回时必须填写原因');
|
||||
const assignee = input.assignedUserPublicId
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { publicId: input.assignedUserPublicId, userType: 'STAFF', status: 'ACTIVE' },
|
||||
})
|
||||
: null;
|
||||
if (input.assignedUserPublicId && !assignee) throw this.validation('分配人员无效');
|
||||
if (input.targetStatus === 'WAITING_PICKUP' && !assignee && !current.assignedUserId)
|
||||
throw this.validation('上门取件审核通过时必须分配工作人员');
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const transitionedAt = new Date();
|
||||
const result = await tx.oldItemDonation.updateMany({
|
||||
where: { id: current.id, revision: input.expectedRevision, status: current.status },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
revision: { increment: 1 },
|
||||
...(assignee ? { assignedUserId: assignee.id } : {}),
|
||||
...(input.targetStatus === 'REJECTED'
|
||||
? { rejectionReasonEncrypted: this.sensitive.encrypt(input.note!) }
|
||||
: {}),
|
||||
...(input.targetStatus === 'RECEIVED' ? { receivedAt: transitionedAt } : {}),
|
||||
...(input.targetStatus === 'COMPLETED'
|
||||
? { completedAt: transitionedAt, assistedPeople: input.assistedPeople ?? 0 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (result.count !== 1) throw this.conflict();
|
||||
await tx.oldItemDonationStatusHistory.create({
|
||||
data: {
|
||||
donationId: current.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: current.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
noteEncrypted: input.note ? this.sensitive.encrypt(input.note) : null,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
if (input.targetStatus === 'COMPLETED' && current.targetNeedId) {
|
||||
const need = await tx.oldItemNeed.findUniqueOrThrow({
|
||||
where: { id: current.targetNeedId },
|
||||
});
|
||||
const fulfilled = Math.min(
|
||||
need.requestedQuantity,
|
||||
need.fulfilledQuantity + current.quantity,
|
||||
);
|
||||
await tx.oldItemNeed.update({
|
||||
where: { id: need.id },
|
||||
data: {
|
||||
fulfilledQuantity: fulfilled,
|
||||
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
|
||||
? { status: 'FULFILLED', revision: { increment: 1 } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.targetStatus === 'COMPLETED') {
|
||||
await this.rewards.grantOnCompletion(
|
||||
tx,
|
||||
current.id,
|
||||
input.clientRequestId,
|
||||
transitionedAt,
|
||||
actor,
|
||||
req,
|
||||
);
|
||||
}
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DONATION_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DONATION',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemDonation.findUniqueOrThrow({
|
||||
where: { id: current.id },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
});
|
||||
return { donation: this.mapDonation(updated, true), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async createNeed(input: CreateOldItemNeedDto, actor: AuthContext, req: RequestContext) {
|
||||
const fp = this.fingerprint('old-item-need-create', input);
|
||||
const replay = await this.prisma.oldItemNeed.findUnique({
|
||||
where: {
|
||||
createdById_clientRequestId: {
|
||||
createdById: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { need: this.need(replay), idempotentReplay: true };
|
||||
}
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const n = await tx.oldItemNeed.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
title: input.title.trim(),
|
||||
category: input.category as never,
|
||||
regionText: input.regionText.trim(),
|
||||
beneficiaryGroup: input.beneficiaryGroup.trim(),
|
||||
description: input.description.trim(),
|
||||
requestedQuantity: input.requestedQuantity,
|
||||
priority: input.priority ?? 0,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.oldItemNeedStatusHistory.create({
|
||||
data: {
|
||||
needId: n.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
toStatus: 'DRAFT',
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: n.publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return n;
|
||||
});
|
||||
return { need: this.need(x), idempotentReplay: false };
|
||||
}
|
||||
async updateNeed(
|
||||
publicId: string,
|
||||
input: UpdateOldItemNeedDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const n = await this.findNeed(publicId);
|
||||
if (n.status !== 'DRAFT' || n.revision !== input.expectedRevision) throw this.conflict();
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const r = await tx.oldItemNeed.updateMany({
|
||||
where: { id: n.id, status: 'DRAFT', revision: input.expectedRevision },
|
||||
data: {
|
||||
title: input.title.trim(),
|
||||
category: input.category as never,
|
||||
regionText: input.regionText.trim(),
|
||||
beneficiaryGroup: input.beneficiaryGroup.trim(),
|
||||
description: input.description.trim(),
|
||||
requestedQuantity: input.requestedQuantity,
|
||||
priority: input.priority ?? 0,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (r.count !== 1) throw this.conflict();
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_UPDATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
|
||||
});
|
||||
return { need: this.need(x) };
|
||||
}
|
||||
async transitionNeed(
|
||||
publicId: string,
|
||||
input: TransitionOldItemNeedDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const n = await this.findNeed(publicId),
|
||||
fp = this.fingerprint('old-item-need-transition', { publicId, ...input });
|
||||
const replay = await this.prisma.oldItemNeedStatusHistory.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { need: true },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { need: this.need(replay.need), idempotentReplay: true };
|
||||
}
|
||||
if (
|
||||
n.revision !== input.expectedRevision ||
|
||||
!NEED_TRANSITIONS[n.status]?.includes(input.targetStatus)
|
||||
)
|
||||
throw this.invalid('当前需求状态不能执行该操作');
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const r = await tx.oldItemNeed.updateMany({
|
||||
where: { id: n.id, status: n.status, revision: n.revision },
|
||||
data: {
|
||||
status: input.targetStatus as never,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
if (r.count !== 1) throw this.conflict();
|
||||
await tx.oldItemNeedStatusHistory.create({
|
||||
data: {
|
||||
needId: n.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: n.status,
|
||||
toStatus: input.targetStatus as never,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_NEED_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_NEED',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: n.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
|
||||
});
|
||||
return { need: this.need(x), idempotentReplay: false };
|
||||
}
|
||||
async createDropOffPoint(
|
||||
input: CreateOldItemDropOffPointDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const x = await this.prisma.$transaction(async (tx) => {
|
||||
const p = await tx.oldItemDropOffPoint.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
...input,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'OLD_ITEM_DROP_OFF_POINT_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'OLD_ITEM_DROP_OFF_POINT',
|
||||
resourcePublicId: p.publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return p;
|
||||
});
|
||||
return {
|
||||
point: {
|
||||
publicId: x.publicId,
|
||||
name: x.name,
|
||||
regionText: x.regionText,
|
||||
addressText: x.addressText,
|
||||
openingHours: x.openingHours,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async listDonations(
|
||||
q: OldItemDonationQueryDto,
|
||||
where: Prisma.OldItemDonationWhereInput,
|
||||
sensitive: boolean,
|
||||
) {
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.oldItemDonation.count({ where }),
|
||||
this.prisma.oldItemDonation.findMany({
|
||||
where,
|
||||
include: DONATION_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (q.page - 1) * q.pageSize,
|
||||
take: q.pageSize,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: items.map((x) => this.mapDonation(x, sensitive)),
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
private async findDonation(publicId: string) {
|
||||
const x = await this.prisma.oldItemDonation.findUnique({
|
||||
where: { publicId },
|
||||
include: DONATION_INCLUDE,
|
||||
});
|
||||
if (!x) throw this.notFound('未找到该捐赠记录');
|
||||
return x;
|
||||
}
|
||||
private async findNeed(publicId: string) {
|
||||
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
|
||||
if (!x) throw this.notFound('未找到该物资需求');
|
||||
return x;
|
||||
}
|
||||
private need(n: OldItemNeed): OldItemNeedItem {
|
||||
return {
|
||||
publicId: n.publicId,
|
||||
title: n.title,
|
||||
category: n.category,
|
||||
regionText: n.regionText,
|
||||
beneficiaryGroup: n.beneficiaryGroup,
|
||||
description: n.description,
|
||||
requestedQuantity: n.requestedQuantity,
|
||||
fulfilledQuantity: n.fulfilledQuantity,
|
||||
remainingQuantity: Math.max(0, n.requestedQuantity - n.fulfilledQuantity),
|
||||
priority: n.priority,
|
||||
status: n.status,
|
||||
revision: n.revision,
|
||||
publishedAt: n.publishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
private mapDonation(d: DonationWithRelations, sensitive: boolean): OldItemDonationItem {
|
||||
return {
|
||||
publicId: d.publicId,
|
||||
donationNo: d.donationNo,
|
||||
category: d.category,
|
||||
itemCondition: d.itemCondition,
|
||||
quantity: d.quantity,
|
||||
description: sensitive ? this.sensitive.decrypt(d.descriptionEncrypted) : '物品捐赠',
|
||||
method: d.method,
|
||||
status: d.status,
|
||||
targetNeed: d.targetNeed
|
||||
? { publicId: d.targetNeed.publicId, title: d.targetNeed.title }
|
||||
: null,
|
||||
dropOffPoint: d.dropOffPoint
|
||||
? {
|
||||
publicId: d.dropOffPoint.publicId,
|
||||
name: d.dropOffPoint.name,
|
||||
regionText: d.dropOffPoint.regionText,
|
||||
addressText: d.dropOffPoint.addressText,
|
||||
openingHours: d.dropOffPoint.openingHours,
|
||||
}
|
||||
: null,
|
||||
appointmentAt: d.appointmentAt?.toISOString() ?? null,
|
||||
contactName: sensitive ? this.sensitive.decrypt(d.contactNameEncrypted) : '爱心用户',
|
||||
mobileMasked: this.maskMobile(this.sensitive.decrypt(d.mobileEncrypted)),
|
||||
address: sensitive && d.addressEncrypted ? this.sensitive.decrypt(d.addressEncrypted) : null,
|
||||
assigneeName: d.assignee?.nickname ?? null,
|
||||
rejectionReason:
|
||||
sensitive && d.rejectionReasonEncrypted
|
||||
? this.sensitive.decrypt(d.rejectionReasonEncrypted)
|
||||
: null,
|
||||
assistedPeople: d.assistedPeople,
|
||||
revision: d.revision,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
history: d.history.map((h) => ({
|
||||
fromStatus: h.fromStatus,
|
||||
toStatus: h.toStatus,
|
||||
note: sensitive && h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
|
||||
createdAt: h.createdAt.toISOString(),
|
||||
})),
|
||||
imagePublicIds: d.files.map((item) => item.file.publicId),
|
||||
inventoryTrace: d.inventoryItems
|
||||
.flatMap((item) =>
|
||||
item.events.map((event) => ({
|
||||
status: event.toStatus,
|
||||
locationText: ['OUTBOUND', 'DELIVERED'].includes(event.toStatus)
|
||||
? event.locationText
|
||||
: null,
|
||||
eventAt: event.eventAt.toISOString(),
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => a.eventAt.localeCompare(b.eventAt)),
|
||||
};
|
||||
}
|
||||
private validateMethod(i: CreateOldItemDonationDto) {
|
||||
if (i.method === 'PICKUP' && (!i.address || !i.appointmentAt))
|
||||
throw this.validation('上门取件需填写地址和预约时间');
|
||||
if (i.method === 'DROP_OFF' && !i.dropOffPointPublicId)
|
||||
throw this.validation('定点投递需选择投递点');
|
||||
if (i.appointmentAt && new Date(i.appointmentAt) <= new Date())
|
||||
throw this.validation('预约时间必须晚于当前时间');
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private donationNo() {
|
||||
return `OI-${new Date().toISOString().slice(0, 7).replace('-', '')}-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
private maskMobile(v: string) {
|
||||
return v.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
private validation(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, m, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
private notFound(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, m, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
private invalid(m: string) {
|
||||
return new ApplicationError(API_ERROR_CODES.INVALID_STATE_TRANSITION, m, HttpStatus.CONFLICT);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'数据已更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,8 @@ export class CreateMediaUploadIntentDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
|
||||
@IsString() @Length(1, 255) filename!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
|
||||
'image/jpeg' | 'image/png' | 'image/webp';
|
||||
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
|
||||
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
API_ERROR_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
type CreateMediaUploadIntentRequest,
|
||||
type ServiceRecordAttachment,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
@@ -14,7 +15,6 @@ import { FilesRepository } from '../files/files.repository';
|
||||
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
|
||||
import type { File, FileBusinessType } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -31,7 +31,7 @@ export class MediaService {
|
||||
) {}
|
||||
|
||||
async createIntent(
|
||||
input: CreateMediaUploadIntentDto,
|
||||
input: CreateMediaUploadIntentRequest,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
@@ -56,7 +56,9 @@ export class MediaService {
|
||||
return this.uploadResponse(previous, true);
|
||||
}
|
||||
const now = new Date();
|
||||
const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
|
||||
const prefix =
|
||||
input.businessType === 'OLD_ITEM_DONATION_IMAGE' ? 'private-old-items' : 'public-covers';
|
||||
const objectKey = `${prefix}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
|
||||
const file = await this.files.createPending(
|
||||
{
|
||||
publicId: randomUUID(),
|
||||
@@ -86,7 +88,11 @@ export class MediaService {
|
||||
const file = await this.files.findByPublicId(publicId);
|
||||
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
|
||||
throw this.notFound();
|
||||
if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
|
||||
if (
|
||||
file.businessType !== 'PROJECT_COVER' &&
|
||||
file.businessType !== 'ACTIVITY_COVER' &&
|
||||
file.businessType !== 'OLD_ITEM_DONATION_IMAGE'
|
||||
) {
|
||||
throw this.notFound();
|
||||
}
|
||||
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
|
||||
|
||||
@@ -19,5 +19,6 @@ import { VolunteersService } from './volunteers.service';
|
||||
AdminParticipationController,
|
||||
],
|
||||
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class ParticipationModule {}
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ApiSuccessResponse,
|
||||
OldItemDonationItem,
|
||||
OldItemNeedItem,
|
||||
OldItemStats,
|
||||
OldItemInventoryItem,
|
||||
OldItemPointAccountItem,
|
||||
OldItemRewardsSummary,
|
||||
WechatLoginResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PasswordService } from '../src/auth/password.service';
|
||||
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
|
||||
import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from '../src/auth/wechat-auth.adapter';
|
||||
import { PrismaService } from '../src/database/prisma.service';
|
||||
|
||||
class OldItemsWechatAdapter implements WechatAuthAdapter {
|
||||
exchangeCode(code: string) {
|
||||
return Promise.resolve({
|
||||
openid: `old-items-openid-${code}`,
|
||||
unionid: `old-items-unionid-${code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const data = <T>(r: { body: unknown }) => (r.body as ApiSuccessResponse<T>).data;
|
||||
function cookie(r: { headers: Record<string, unknown> }) {
|
||||
const raw = r.headers['set-cookie'];
|
||||
const values = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
|
||||
const item = values.find((x) => x.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!item) throw new Error('Admin cookie missing');
|
||||
return item.split(';')[0]!;
|
||||
}
|
||||
|
||||
describe('old item reuse Phases 1-3', () => {
|
||||
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(WECHAT_AUTH_ADAPTER)
|
||||
.useValue(new OldItemsWechatAdapter())
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
|
||||
);
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.oldItemPointEntry.deleteMany();
|
||||
await prisma.oldItemPointAccount.deleteMany();
|
||||
await prisma.oldItemCertificate.deleteMany();
|
||||
await prisma.oldItemRewardConfig.deleteMany();
|
||||
await prisma.oldItemInventoryEvent.deleteMany();
|
||||
await prisma.oldItemInventoryItem.deleteMany();
|
||||
await prisma.oldItemInventoryImport.deleteMany();
|
||||
await prisma.oldItemDonationFile.deleteMany();
|
||||
await prisma.oldItemDonationStatusHistory.deleteMany();
|
||||
await prisma.oldItemDonation.deleteMany();
|
||||
await prisma.oldItemNeedStatusHistory.deleteMany();
|
||||
await prisma.oldItemNeed.deleteMany();
|
||||
await prisma.oldItemDropOffPoint.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
await prisma.wechatIdentity.deleteMany();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await prisma.oldItemPointEntry.deleteMany();
|
||||
await prisma.oldItemPointAccount.deleteMany();
|
||||
await prisma.oldItemCertificate.deleteMany();
|
||||
await prisma.oldItemRewardConfig.deleteMany();
|
||||
await prisma.oldItemInventoryEvent.deleteMany();
|
||||
await prisma.oldItemInventoryItem.deleteMany();
|
||||
await prisma.oldItemInventoryImport.deleteMany();
|
||||
await prisma.oldItemDonationFile.deleteMany();
|
||||
await prisma.oldItemDonationStatusHistory.deleteMany();
|
||||
await prisma.oldItemDonation.deleteMany();
|
||||
await prisma.oldItemNeedStatusHistory.deleteMany();
|
||||
await prisma.oldItemNeed.deleteMany();
|
||||
await prisma.oldItemDropOffPoint.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
await prisma.wechatIdentity.deleteMany();
|
||||
await app.close();
|
||||
});
|
||||
async function admin() {
|
||||
const username = `old.${crypto.randomUUID()}`,
|
||||
password = 'correct horse battery staple',
|
||||
role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } }),
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
userType: 'STAFF',
|
||||
nickname: '旧物管理员',
|
||||
staffProfile: { create: { displayName: '旧物管理员' } },
|
||||
adminCredential: { create: { username, passwordHash: await passwords.hash(password) } },
|
||||
},
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||
return {
|
||||
publicId: user.publicId,
|
||||
cookie: cookie(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/auth/login')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.send({ username, password })
|
||||
.expect(201),
|
||||
),
|
||||
};
|
||||
}
|
||||
async function wechat(code: string) {
|
||||
const r = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/auth/wechat/login')
|
||||
.send({ code })
|
||||
.expect(201);
|
||||
return data<WechatLoginResponse>(r).accessToken;
|
||||
}
|
||||
|
||||
it('publishes a need and completes an auditable directional pickup donation', async () => {
|
||||
const adminSession = await admin(),
|
||||
adminCookie = adminSession.cookie;
|
||||
const rewardConfigRequestId = crypto.randomUUID();
|
||||
const rewardConfigPayload = {
|
||||
clientRequestId: rewardConfigRequestId,
|
||||
certificateTitle: '旧物善用爱心证书',
|
||||
certificateMessage:
|
||||
'感谢 {recipient} 捐赠 {quantity} 件{category}物资,公益编号 {donationNo}。',
|
||||
issuerName: '乐活公益',
|
||||
legalNotice: '本证书仅记录公益参与,不作为捐赠票据、税务凭证或财产权利证明。',
|
||||
clothingPoints: 1,
|
||||
booksPoints: 3,
|
||||
householdPoints: 1,
|
||||
sportsPoints: 1,
|
||||
maxPointsPerDonation: 10,
|
||||
};
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/reward-config/activate')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(rewardConfigPayload)
|
||||
.expect(201);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/reward-config/activate')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(rewardConfigPayload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
const createNeedResponse = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/needs')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
title: '社区儿童学习用品需求',
|
||||
category: 'BOOKS',
|
||||
regionText: '丹阳市云阳街道',
|
||||
beneficiaryGroup: '困境儿童',
|
||||
description: '需要内容健康、无严重破损的课外读物',
|
||||
requestedQuantity: 20,
|
||||
priority: 80,
|
||||
sortOrder: 10,
|
||||
});
|
||||
if (createNeedResponse.status !== 201)
|
||||
throw new Error(
|
||||
`create need failed: ${createNeedResponse.status} ${JSON.stringify(createNeedResponse.body)}`,
|
||||
);
|
||||
const createdNeed = data<{ need: OldItemNeedItem }>(createNeedResponse).need;
|
||||
const published = data<{ need: OldItemNeedItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/needs/${createdNeed.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: createdNeed.revision,
|
||||
targetStatus: 'PUBLISHED',
|
||||
})
|
||||
.expect(201),
|
||||
).need;
|
||||
expect(
|
||||
data<{ items: OldItemNeedItem[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/needs?page=1&pageSize=20')
|
||||
.expect(200),
|
||||
).items,
|
||||
).toHaveLength(1);
|
||||
const token = await wechat('donor-one'),
|
||||
requestId = crypto.randomUUID(),
|
||||
appointmentAt = new Date(Date.now() + 86_400_000).toISOString();
|
||||
const payload = {
|
||||
clientRequestId: requestId,
|
||||
category: 'BOOKS',
|
||||
itemCondition: 'GOOD',
|
||||
quantity: 6,
|
||||
description: '六本适龄儿童读物,页面完整',
|
||||
method: 'PICKUP',
|
||||
contactName: '爱心用户',
|
||||
mobile: '13812345678',
|
||||
address: '丹阳市测试路 1 号,仅供集成测试',
|
||||
appointmentAt,
|
||||
targetNeedPublicId: published.publicId,
|
||||
imagePublicIds: [],
|
||||
};
|
||||
const created = data<{ donation: OldItemDonationItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/old-items/donations')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
).donation;
|
||||
const replay = data<{ donation: OldItemDonationItem; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/old-items/donations')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
);
|
||||
expect(created.donationNo).toMatch(/^OI-/);
|
||||
expect(replay.idempotentReplay).toBe(true);
|
||||
let current = created;
|
||||
for (const step of [
|
||||
{ targetStatus: 'WAITING_PICKUP', assignedUserPublicId: adminSession.publicId },
|
||||
{ targetStatus: 'RECEIVED' },
|
||||
{ targetStatus: 'PROCESSING' },
|
||||
{ targetStatus: 'COMPLETED', assistedPeople: 3 },
|
||||
]) {
|
||||
current = data<{ donation: OldItemDonationItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/donations/${created.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
...step,
|
||||
})
|
||||
.expect(201),
|
||||
).donation;
|
||||
}
|
||||
expect(current.status).toBe('COMPLETED');
|
||||
expect(current.history).toHaveLength(5);
|
||||
const stats = data<OldItemStats>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/stats')
|
||||
.expect(200),
|
||||
);
|
||||
expect(stats.totalItems).toBe(6);
|
||||
expect(stats.assistedPeople).toBe(3);
|
||||
const rewards = data<OldItemRewardsSummary>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/me/rewards')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200),
|
||||
);
|
||||
expect(rewards.balance).toBe(10);
|
||||
expect(rewards.certificates).toHaveLength(1);
|
||||
expect(rewards.certificates[0]?.title).toBe('旧物善用爱心证书');
|
||||
expect(rewards.entries[0]?.pointsDelta).toBe(10);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
const otherToken = await wechat('other-donor');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/old-items/me/certificates/${rewards.certificates[0]!.publicId}`)
|
||||
.set('authorization', `Bearer ${otherToken}`)
|
||||
.expect(404);
|
||||
|
||||
const accountPage = data<{ items: OldItemPointAccountItem[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/admin/old-items/reward-accounts?page=1&pageSize=20')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.expect(200),
|
||||
);
|
||||
expect(accountPage.items).toHaveLength(1);
|
||||
const account = accountPage.items[0]!;
|
||||
const adjustment = {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: account.revision,
|
||||
pointsDelta: 5,
|
||||
reason: '集成测试人工校正',
|
||||
};
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(adjustment)
|
||||
.expect(201);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(adjustment)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
expect(
|
||||
data<OldItemRewardsSummary>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/old-items/me/rewards')
|
||||
.set('authorization', `Bearer ${token}`)
|
||||
.expect(200),
|
||||
).balance,
|
||||
).toBe(15);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/reward-accounts/${account.userPublicId}/adjust`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: account.revision + 1,
|
||||
pointsDelta: -16,
|
||||
reason: '不允许出现负积分',
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const reissued = data<{ certificate: { publicId: string }; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/donations/${created.publicId}/certificate`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({ clientRequestId: crypto.randomUUID() })
|
||||
.expect(201),
|
||||
);
|
||||
expect(reissued.idempotentReplay).toBe(false);
|
||||
expect(await prisma.oldItemCertificate.count({ where: { status: 'REVOKED' } })).toBe(1);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/certificates/${reissued.certificate.publicId}/revoke`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), reason: '集成测试撤销' })
|
||||
.expect(201);
|
||||
expect(await prisma.oldItemCertificate.count({ where: { status: 'ISSUED' } })).toBe(0);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: {
|
||||
resourceType: {
|
||||
in: [
|
||||
'OLD_ITEM_DONATION',
|
||||
'OLD_ITEM_NEED',
|
||||
'OLD_ITEM_REWARD_CONFIG',
|
||||
'OLD_ITEM_CERTIFICATE',
|
||||
'OLD_ITEM_POINT_ENTRY',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('runs the inventory ledger state machine and atomically imports and exports rows', async () => {
|
||||
const adminCookie = (await admin()).cookie;
|
||||
const requestId = crypto.randomUUID();
|
||||
const payload = {
|
||||
clientRequestId: requestId,
|
||||
category: 'CLOTHING',
|
||||
itemName: '冬季外套',
|
||||
quantity: 12,
|
||||
unit: '件',
|
||||
storageLocation: '测试仓 A-01',
|
||||
note: '集成测试入库',
|
||||
};
|
||||
const createdResponse = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(payload)
|
||||
.expect(201);
|
||||
let current = data<{ item: OldItemInventoryItem }>(createdResponse).item;
|
||||
expect(current.inventoryNo).toMatch(/^INV-/);
|
||||
expect(current.events).toHaveLength(1);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(payload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
targetStatus: 'DISINFECTED',
|
||||
})
|
||||
.expect(409);
|
||||
for (const step of [
|
||||
{ targetStatus: 'SORTED' },
|
||||
{ targetStatus: 'DISINFECTED' },
|
||||
{ targetStatus: 'OUTBOUND', destinationRegion: '丹阳市云阳街道' },
|
||||
{ targetStatus: 'DELIVERED', assistedPeople: 5 },
|
||||
]) {
|
||||
current = data<{ item: OldItemInventoryItem }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/old-items/inventory/${current.publicId}/transition`)
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
expectedRevision: current.revision,
|
||||
...step,
|
||||
})
|
||||
.expect(201),
|
||||
).item;
|
||||
}
|
||||
expect(current.status).toBe('DELIVERED');
|
||||
expect(current.events.map((x) => x.status)).toEqual([
|
||||
'IN_STOCK',
|
||||
'SORTED',
|
||||
'DISINFECTED',
|
||||
'OUTBOUND',
|
||||
'DELIVERED',
|
||||
]);
|
||||
|
||||
const importId = crypto.randomUUID();
|
||||
const importPayload = {
|
||||
clientRequestId: importId,
|
||||
originalFilename: '物资入库.xlsx',
|
||||
rows: [
|
||||
{
|
||||
category: 'BOOKS',
|
||||
itemName: '儿童读物',
|
||||
quantity: 20,
|
||||
unit: '本',
|
||||
storageLocation: '测试仓 B-01',
|
||||
},
|
||||
{
|
||||
category: 'SPORTS',
|
||||
itemName: '跳绳',
|
||||
quantity: 8,
|
||||
unit: '根',
|
||||
storageLocation: '测试仓 B-02',
|
||||
},
|
||||
],
|
||||
};
|
||||
const imported = data<{ items: OldItemInventoryItem[]; idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory/import')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(importPayload)
|
||||
.expect(201),
|
||||
);
|
||||
expect(imported.items).toHaveLength(2);
|
||||
expect(
|
||||
data<{ idempotentReplay: boolean }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/old-items/inventory/import')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.send(importPayload)
|
||||
.expect(201),
|
||||
).idempotentReplay,
|
||||
).toBe(true);
|
||||
const exported = data<{ rows: unknown[] }>(
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/admin/old-items/inventory-export/file?page=1&pageSize=100')
|
||||
.set('origin', 'http://localhost:5173')
|
||||
.set('cookie', adminCookie)
|
||||
.expect(200),
|
||||
);
|
||||
expect(exported.rows).toHaveLength(3);
|
||||
expect(await prisma.oldItemInventoryEvent.count()).toBe(7);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { resourceType: { startsWith: 'OLD_ITEM_INVENTORY' } },
|
||||
}),
|
||||
).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,15 @@
|
||||
"pages/content-detail/content-detail",
|
||||
"pages/notifications/notifications",
|
||||
"pages/financial-disclosures/financial-disclosures",
|
||||
"pages/financial-disclosure-detail/financial-disclosure-detail"
|
||||
"pages/financial-disclosure-detail/financial-disclosure-detail",
|
||||
"pages/old-item-home/old-item-home",
|
||||
"pages/old-item-donate/old-item-donate",
|
||||
"pages/old-item-records/old-item-records",
|
||||
"pages/old-item-detail/old-item-detail",
|
||||
"pages/old-item-rewards/old-item-rewards",
|
||||
"pages/old-item-certificate/old-item-certificate",
|
||||
"pages/old-item-needs/old-item-needs",
|
||||
"pages/old-item-guide/old-item-guide"
|
||||
],
|
||||
"window": {
|
||||
"backgroundColor": "#F7F5F2",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,208 @@
|
||||
.old-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
background: #f7f5f2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.old-hero {
|
||||
padding: 38rpx 32rpx;
|
||||
border-radius: 28rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff8c4a, #f05a28);
|
||||
box-shadow: 0 16rpx 36rpx rgba(240, 90, 40, 0.2);
|
||||
}
|
||||
.old-hero__title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.old-hero__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 25rpx;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.old-card {
|
||||
margin-top: 22rpx;
|
||||
padding: 24rpx;
|
||||
border: 2rpx solid #eee7df;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 10rpx 24rpx rgba(67, 50, 36, 0.05);
|
||||
}
|
||||
.old-title {
|
||||
display: block;
|
||||
font-size: 31rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.old-muted {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: #77716b;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.old-primary,
|
||||
.old-secondary {
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
margin-top: 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.old-primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #ff702f, #f05a28);
|
||||
}
|
||||
.old-secondary {
|
||||
color: #e95220;
|
||||
background: #fff2ec;
|
||||
border: 2rpx solid #ffd3c2;
|
||||
}
|
||||
.old-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
.old-stat {
|
||||
padding: 22rpx 8rpx;
|
||||
text-align: center;
|
||||
border-radius: 20rpx;
|
||||
background: #fff;
|
||||
}
|
||||
.old-stat strong,
|
||||
.old-stat text {
|
||||
display: block;
|
||||
}
|
||||
.old-stat strong {
|
||||
color: #2eaa4f;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
.old-stat text {
|
||||
color: #77716b;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.old-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.old-grid button {
|
||||
min-height: 140rpx;
|
||||
margin: 0;
|
||||
padding: 22rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
.old-grid strong,
|
||||
.old-grid text {
|
||||
display: block;
|
||||
}
|
||||
.old-grid strong {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.old-grid text {
|
||||
margin-top: 8rpx;
|
||||
color: #77716b;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.old-field {
|
||||
display: block;
|
||||
margin-top: 22rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.old-field input,
|
||||
.old-field textarea,
|
||||
.old-field picker {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #e8e1da;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
font-weight: 400;
|
||||
}
|
||||
.old-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.old-options button {
|
||||
margin: 0;
|
||||
padding: 12rpx 20rpx;
|
||||
border: 2rpx solid #e6dfd8;
|
||||
border-radius: 16rpx;
|
||||
background: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.old-options button.active {
|
||||
color: #f05a28;
|
||||
border-color: #f05a28;
|
||||
background: #fff4ef;
|
||||
}
|
||||
.old-record {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 18rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
.old-record__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.old-record__status {
|
||||
color: #f05a28;
|
||||
}
|
||||
.old-trace {
|
||||
margin-top: 22rpx;
|
||||
padding-left: 28rpx;
|
||||
border-left: 4rpx solid #ffd4c3;
|
||||
}
|
||||
.old-trace view {
|
||||
position: relative;
|
||||
margin: 0 0 26rpx;
|
||||
}
|
||||
.old-trace view::before {
|
||||
position: absolute;
|
||||
left: -38rpx;
|
||||
top: 8rpx;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #f05a28;
|
||||
content: '';
|
||||
}
|
||||
.old-empty {
|
||||
padding: 80rpx 20rpx;
|
||||
text-align: center;
|
||||
color: #77716b;
|
||||
}
|
||||
.old-photo-grid {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.old-photo {
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
.old-remove {
|
||||
margin: 0;
|
||||
color: #b33;
|
||||
background: #fff;
|
||||
}
|
||||
.old-guide text {
|
||||
display: block;
|
||||
margin: 14rpx 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ Page({
|
||||
|
||||
async loadApplication() {
|
||||
if (!this.data.publicId) {
|
||||
this.setData({ status: 'error', errorMessage: '申请信息不完整,请返回重试' });
|
||||
this.setData({ status: 'error', errorMessage: '帮扶信息不完整,请返回重试' });
|
||||
return;
|
||||
}
|
||||
this.setData({ status: 'loading', errorMessage: '' });
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<view wx:elif="{{status === 'error'}}" class="state-card"><text class="state-title">暂时无法读取服务进度</text><text class="state-text">{{errorMessage}}</text><button class="secondary-button" bindtap="retryLoad">重新加载</button></view>
|
||||
<block wx:else>
|
||||
<view class="detail-summary">
|
||||
<view><text class="eyebrow">{{application.serviceCategoryLabel}}</text><text class="detail-title">{{application.beneficiaryName}}的服务申请</text></view>
|
||||
<view><text class="eyebrow">{{application.serviceCategoryLabel}}</text><text class="detail-title">{{application.beneficiaryName}}的帮扶服务</text></view>
|
||||
<text class="status-pill">{{application.statusLabel}}</text>
|
||||
<text class="application-meta">服务编号:{{application.applicationNo}}</text>
|
||||
</view>
|
||||
<view class="detail-card">
|
||||
<text class="form-title">申请信息</text>
|
||||
<text class="form-title">帮扶信息</text>
|
||||
<view class="summary-row"><text>申请关系</text><text>{{application.applyModeLabel}}</text></view>
|
||||
<view class="summary-row"><text>联系电话</text><text>{{application.contactMobileMasked}}</text></view>
|
||||
<view class="summary-row"><text>所在地区</text><text>{{application.regionCode}}</text></view>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "服务申请"
|
||||
"navigationBarTitleText": "帮扶服务"
|
||||
}
|
||||
|
||||
@@ -20,14 +20,29 @@ import { createClientRequestId } from '../../utils/request-id';
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
const LOCAL_SERVICE_CATEGORIES: ServiceCategoryOption[] = [
|
||||
const CATEGORY_IMAGES: Record<ServiceCategory, string> = {
|
||||
OLD_AGE: '/assets/images/brand-elder-care.jpg',
|
||||
WOMEN: '/assets/images/service-women-support-v2.jpg',
|
||||
CHILD: '/assets/images/service-child-care-v2.jpg',
|
||||
DISABILITY: '/assets/images/brand-disability-support.jpg',
|
||||
LIFE_DIFFICULTY: '/assets/images/service-life-support-v2.jpg',
|
||||
OTHER: '/assets/images/service-other-support-v2.jpg',
|
||||
};
|
||||
|
||||
type ServiceCategoryView = ServiceCategoryOption & { image: string };
|
||||
|
||||
const LOCAL_SERVICE_CATEGORY_OPTIONS = [
|
||||
{ code: 'OLD_AGE', label: '关爱老人', description: '生活照料、陪伴关怀与健康支持' },
|
||||
{ code: 'WOMEN', label: '困境妇女', description: '困境支持、权益关爱与资源链接' },
|
||||
{ code: 'CHILD', label: '儿童关爱', description: '成长陪伴、家庭支持与儿童关爱' },
|
||||
{ code: 'DISABILITY', label: '残障帮扶', description: '生活协助、康复支持与无障碍关怀' },
|
||||
{ code: 'LIFE_DIFFICULTY', label: '生活困难', description: '生活支持、资源链接与持续关爱' },
|
||||
{ code: 'OTHER', label: '其他服务', description: '由工作人员进一步了解并协助判断' },
|
||||
];
|
||||
] satisfies ServiceCategoryOption[];
|
||||
|
||||
const LOCAL_SERVICE_CATEGORIES: ServiceCategoryView[] = LOCAL_SERVICE_CATEGORY_OPTIONS.map(
|
||||
(item) => ({ ...item, image: CATEGORY_IMAGES[item.code] }),
|
||||
);
|
||||
|
||||
function isServiceCategory(value: string): value is ServiceCategory {
|
||||
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
|
||||
@@ -83,7 +98,13 @@ Page({
|
||||
this.setData({ loading: true, errorMessage: '' });
|
||||
try {
|
||||
const result = await fetchServiceCategories();
|
||||
this.setData({ categories: result.items, loading: false });
|
||||
this.setData({
|
||||
categories: result.items.map((item) => ({
|
||||
...item,
|
||||
image: CATEGORY_IMAGES[item.code],
|
||||
})),
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({
|
||||
loading: false,
|
||||
|
||||
@@ -29,10 +29,7 @@
|
||||
bindtap="selectCategory"
|
||||
aria-label="{{item.label}}:{{item.description}}"
|
||||
>
|
||||
<image wx:if="{{item.code === 'OLD_AGE'}}" class="selection-photo" src="/assets/images/brand-elder-care.jpg" mode="aspectFill" />
|
||||
<image wx:elif="{{item.code === 'WOMEN_CHILDREN'}}" class="selection-photo" src="/assets/images/brand-women-support.jpg" mode="aspectFill" />
|
||||
<image wx:elif="{{item.code === 'DISABILITY'}}" class="selection-photo" src="/assets/images/brand-disability-support.jpg" mode="aspectFill" />
|
||||
<view wx:else class="selection-photo selection-photo--fallback">公益服务</view>
|
||||
<image class="selection-photo" src="{{item.image}}" mode="aspectFill" />
|
||||
<view class="selection-content">
|
||||
<text class="selection-title">{{item.label}}</text>
|
||||
<text class="selection-description">{{item.description}}</text>
|
||||
@@ -86,7 +83,7 @@
|
||||
</view>
|
||||
|
||||
<view wx:else class="form-card">
|
||||
<text class="form-title">确认申请信息</text>
|
||||
<text class="form-title">确认帮扶信息</text>
|
||||
<text class="form-hint">提交前请确认,联系方式仅供授权工作人员提供服务使用。</text>
|
||||
<view class="summary-list">
|
||||
<view class="summary-row"><text>服务类型</text><text>{{serviceCategoryLabel}}</text></view>
|
||||
@@ -99,7 +96,7 @@
|
||||
<checkbox-group bindchange="updatePrivacyConsent">
|
||||
<label class="consent-row">
|
||||
<checkbox value="accepted" checked="{{privacyConsent}}" color="#FF6B35" />
|
||||
<text>我已阅读并同意本次服务申请的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
|
||||
<text>我已阅读并同意本次帮扶服务的隐私授权,确认所填信息用于公益服务联系、评估和必要留痕。</text>
|
||||
</label>
|
||||
</checkbox-group>
|
||||
</view>
|
||||
@@ -108,7 +105,7 @@
|
||||
<view class="form-actions">
|
||||
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
|
||||
<button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button>
|
||||
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交申请'}}</button>
|
||||
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交帮扶需求'}}</button>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -21,7 +21,7 @@ Page({
|
||||
|
||||
async loadApplication() {
|
||||
if (!this.data.publicId) {
|
||||
this.setData({ loading: false, errorMessage: '申请信息不完整,请前往“我的申请”查看' });
|
||||
this.setData({ loading: false, errorMessage: '帮扶信息不完整,请前往“我的帮扶服务”查看' });
|
||||
return;
|
||||
}
|
||||
this.setData({ loading: true, errorMessage: '' });
|
||||
@@ -40,7 +40,7 @@ Page({
|
||||
? `${error.message}(编号:${error.requestId})`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: '暂时无法读取申请信息';
|
||||
: '暂时无法读取帮扶信息';
|
||||
this.setData({ loading: false, errorMessage: message });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<view class="page success-page">
|
||||
<view wx:if="{{loading}}" class="state-card"><view class="skeleton-card"></view><text class="state-text">正在确认提交结果…</text></view>
|
||||
<view wx:elif="{{errorMessage}}" class="state-card">
|
||||
<text class="state-title">暂时无法读取申请结果</text><text class="state-text">{{errorMessage}}</text>
|
||||
<text class="state-title">暂时无法读取帮扶服务结果</text><text class="state-text">{{errorMessage}}</text>
|
||||
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
|
||||
</view>
|
||||
<view wx:else class="success-card">
|
||||
<view class="success-mark" aria-hidden="true">✓</view>
|
||||
<text class="success-title">申请已提交</text>
|
||||
<text class="success-title">帮扶需求已提交</text>
|
||||
<text class="success-message">乐活公益工作人员将根据实际情况与您联系,请留意来电。</text>
|
||||
<view class="result-list">
|
||||
<view><text>服务编号</text><text class="result-value">{{application.applicationNo}}</text></view>
|
||||
|
||||
@@ -8,12 +8,6 @@ Page({
|
||||
message: '正在安全登录,请稍候…',
|
||||
title: MINI_PROGRAM_TITLE,
|
||||
canCollaborate: false,
|
||||
serviceCategories: [
|
||||
{ code: 'OLD_AGE', label: '关爱老人', symbol: '老', tone: 'orange' },
|
||||
{ code: 'WOMEN_CHILDREN', label: '困境儿童', symbol: '童', tone: 'green' },
|
||||
{ code: 'WOMEN_CHILDREN', label: '妇女帮扶', symbol: '女', tone: 'rose' },
|
||||
{ code: 'DISABILITY', label: '残障救助', symbol: '助', tone: 'blue' },
|
||||
],
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -53,13 +47,6 @@ Page({
|
||||
void wx.navigateTo({ url: '/pages/application-form/application-form?mode=FAMILY' });
|
||||
},
|
||||
|
||||
applyForCategory(event: WechatMiniprogram.BaseEvent) {
|
||||
const category = event.currentTarget.dataset['code'] as string;
|
||||
void wx.navigateTo({
|
||||
url: `/pages/application-form/application-form?mode=SELF&category=${category}`,
|
||||
});
|
||||
},
|
||||
|
||||
viewMyApplications() {
|
||||
void wx.navigateTo({ url: '/pages/my-applications/my-applications' });
|
||||
},
|
||||
@@ -82,6 +69,12 @@ Page({
|
||||
viewContent() {
|
||||
void wx.navigateTo({ url: '/pages/content/content' });
|
||||
},
|
||||
viewFinancialDisclosures() {
|
||||
void wx.navigateTo({ url: '/pages/financial-disclosures/financial-disclosures' });
|
||||
},
|
||||
viewOldItems() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-home/old-item-home' });
|
||||
},
|
||||
viewOrganization() {
|
||||
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
|
||||
},
|
||||
|
||||
@@ -8,19 +8,23 @@
|
||||
<view class="hero-glow hero-glow--one"></view><view class="hero-glow hero-glow--two"></view>
|
||||
<text class="hero-title">用爱连接 让爱传递</text>
|
||||
<text class="hero-copy">乐于助人 · 助人为乐 · 服务社会</text>
|
||||
<button class="hero-action" bindtap="apply">立即申请服务 <text>›</text></button>
|
||||
<button class="hero-action" bindtap="apply">申请帮扶服务 <text>›</text></button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{authStatus === 'loading'}}" class="state-card"><view class="skeleton-line skeleton-line--wide"></view><text class="state-text">正在安全登录,请稍候…</text></view>
|
||||
<view wx:elif="{{authStatus === 'error'}}" class="state-card"><text class="state-title">暂时无法登录</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="retryLogin">重新登录</button></view>
|
||||
|
||||
<block wx:else>
|
||||
<view class="quick-grid">
|
||||
<button wx:for="{{serviceCategories}}" wx:key="label" class="quick-item" data-code="{{item.code}}" bindtap="applyForCategory"><view class="quick-icon quick-icon--{{item.tone}}"><text>{{item.symbol}}</text></view><text>{{item.label}}</text></button>
|
||||
<button class="quick-item" bindtap="apply"><view class="quick-icon quick-icon--blue"><text>申</text></view><text>服务申请</text></button>
|
||||
<button class="quick-item" bindtap="viewVolunteerCenter"><view class="quick-icon quick-icon--gold"><text>愿</text></view><text>志愿服务</text></button>
|
||||
<button class="quick-item" bindtap="viewProjects"><view class="quick-icon quick-icon--green"><text>益</text></view><text>公益项目</text></button>
|
||||
<button class="quick-item" bindtap="viewContent"><view class="quick-icon quick-icon--rose"><text>讯</text></view><text>公益动态</text></button>
|
||||
<view class="home-services">
|
||||
<button class="help-entry" bindtap="apply"><view class="help-entry__icon"><text>帮</text></view><view class="help-entry__content"><text class="help-entry__title">帮扶服务</text><text class="help-entry__copy">为自己或家人提交需求,工作人员将与您联系</text></view><text class="help-entry__arrow">›</text></button>
|
||||
<view class="quick-grid">
|
||||
<button class="quick-item" bindtap="viewVolunteerCenter"><view class="quick-icon quick-icon--gold"><text>愿</text></view><text>志愿服务</text></button>
|
||||
<button class="quick-item" bindtap="viewProjects"><view class="quick-icon quick-icon--green"><text>益</text></view><text>公益项目</text></button>
|
||||
<button class="quick-item" bindtap="viewContent"><view class="quick-icon quick-icon--rose"><text>讯</text></view><text>公益动态</text></button>
|
||||
<button class="quick-item" bindtap="viewOldItems"><view class="quick-icon quick-icon--gold"><text>物</text></view><text>旧物善用</text></button>
|
||||
<button class="quick-item" bindtap="viewFinancialDisclosures"><view class="quick-icon quick-icon--green"><text>财</text></view><text>财务公开</text></button>
|
||||
<button class="quick-item" bindtap="viewMyApplications"><view class="quick-icon quick-icon--blue"><text>进</text></view><text>服务进度</text></button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="notice-strip" bindtap="viewContent"><text class="notice-badge">公告</text><text class="notice-text">查看乐活公益最新服务动态与温馨提示</text><text>›</text></button>
|
||||
@@ -33,7 +37,6 @@
|
||||
|
||||
<view class="section-heading"><text class="section-title">便捷入口</text></view>
|
||||
<view class="service-list">
|
||||
<button bindtap="viewMyApplications"><view class="list-mark list-mark--orange">进</view><view><text class="list-title">服务进度</text><text class="list-copy">查看申请受理与跟进情况</text></view><text>›</text></button>
|
||||
<button wx:if="{{canCollaborate}}" bindtap="viewAssignedCases"><view class="list-mark list-mark--blue">协</view><view><text class="list-title">协作任务</text><text class="list-copy">查看已授权的服务工单</text></view><text>›</text></button>
|
||||
<button bindtap="viewActivities"><view class="list-mark list-mark--green">活</view><view><text class="list-title">公益活动</text><text class="list-copy">浏览活动并提交报名</text></view><text>›</text></button>
|
||||
</view>
|
||||
|
||||
@@ -101,24 +101,74 @@
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
.home-services {
|
||||
margin-top: 24rpx;
|
||||
padding: 26rpx 8rpx 18rpx;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 28rpx 10rpx;
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid var(--color-border);
|
||||
border-radius: 28rpx;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.help-entry {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 128rpx;
|
||||
margin: 0;
|
||||
padding: 22rpx 24rpx;
|
||||
grid-template-columns: 76rpx 1fr auto;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
color: #7a3219;
|
||||
background: linear-gradient(120deg, #fff0e7, #fff8f3);
|
||||
text-align: left;
|
||||
}
|
||||
.help-entry__icon {
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 24rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #ff7c43, #f05a28);
|
||||
font-weight: 800;
|
||||
}
|
||||
.help-entry__content,
|
||||
.help-entry__title,
|
||||
.help-entry__copy {
|
||||
display: block;
|
||||
}
|
||||
.help-entry__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.help-entry__copy {
|
||||
margin-top: 5rpx;
|
||||
color: #795d50;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.help-entry__arrow {
|
||||
color: var(--color-primary);
|
||||
font-size: 42rpx;
|
||||
}
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
margin-top: 24rpx;
|
||||
padding: 0 4rpx 4rpx;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20rpx 16rpx;
|
||||
}
|
||||
.quick-item {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 8rpx 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的申请"
|
||||
"navigationBarTitleText": "我的帮扶服务"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<view class="page list-page">
|
||||
<view class="page-heading"><text class="page-title">我的服务申请</text><text class="page-subtitle">查看已提交的需求和工作人员处理进度</text></view>
|
||||
<view class="page-heading"><text class="page-title">我的帮扶服务</text><text class="page-subtitle">查看已提交的帮扶需求和工作人员处理进度</text></view>
|
||||
<view wx:if="{{status === 'loading'}}" class="application-list"><view wx:for="{{[1, 2, 3]}}" wx:key="*this" class="skeleton-card"></view></view>
|
||||
<view wx:elif="{{status === 'empty'}}" class="state-card">
|
||||
<text class="state-title">您还没有提交过服务申请</text><text class="state-text">如果您或家人需要帮助,可以从这里开始。</text>
|
||||
<text class="state-title">您还没有提交过帮扶需求</text><text class="state-text">如果您或家人需要帮助,可以从这里开始。</text>
|
||||
<button class="primary-button" bindtap="startApplication">开始申请</button>
|
||||
</view>
|
||||
<view wx:elif="{{status === 'error'}}" class="state-card">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "电子荣誉证书"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OldItemCertificateItem } from '@lehuo/contracts';
|
||||
import { getMyOldItemCertificate } from '../../services/old-items';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
certificate: null as (OldItemCertificateItem & { issuedDate: string }) | null,
|
||||
},
|
||||
onLoad(options) {
|
||||
const id = options['id'];
|
||||
if (!id) {
|
||||
this.setData({ loading: false, error: '缺少证书参数' });
|
||||
return;
|
||||
}
|
||||
void this.load(id);
|
||||
},
|
||||
async load(id: string) {
|
||||
try {
|
||||
const certificate = (await getMyOldItemCertificate(id)).certificate;
|
||||
this.setData({
|
||||
certificate: {
|
||||
...certificate,
|
||||
issuedDate: new Date(certificate.issuedAt).toLocaleDateString('zh-CN'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<view class="old-page">
|
||||
<view wx:if="{{loading}}" class="old-card"><text class="old-muted">正在加载证书…</text></view>
|
||||
<view wx:elif="{{error}}" class="old-card">{{error}}</view>
|
||||
<block wx:elif="{{certificate}}">
|
||||
<view wx:if="{{certificate.status==='REVOKED'}}" class="certificate-revoked">该证书已撤销{{certificate.revokeReason?':'+certificate.revokeReason:''}}</view>
|
||||
<view class="old-card certificate"><text class="certificate-title">{{certificate.title}}</text><text class="certificate-name">{{certificate.recipientName}}</text><text class="certificate-message">{{certificate.message}}</text><text class="certificate-issuer">{{certificate.issuerName}}</text><text class="certificate-date">{{certificate.issuedDate}}</text><text class="certificate-no">证书编号:{{certificate.certificateNo}}</text></view>
|
||||
<view class="old-card"><text class="old-title">使用说明</text><text class="old-muted">{{certificate.legalNotice}}</text></view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../../old-items.wxss';
|
||||
|
||||
.certificate {
|
||||
position: relative;
|
||||
padding: 64rpx 40rpx;
|
||||
overflow: hidden;
|
||||
border: 6rpx double #ef9c72;
|
||||
text-align: center;
|
||||
}
|
||||
.certificate::before {
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -30rpx;
|
||||
color: rgba(240, 90, 40, 0.08);
|
||||
content: '♥';
|
||||
font-size: 260rpx;
|
||||
}
|
||||
.certificate-title {
|
||||
display: block;
|
||||
color: #b8421d;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 6rpx;
|
||||
}
|
||||
.certificate-name {
|
||||
display: block;
|
||||
margin-top: 46rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.certificate-message {
|
||||
display: block;
|
||||
margin-top: 28rpx;
|
||||
color: #554b44;
|
||||
line-height: 1.9;
|
||||
text-align: left;
|
||||
}
|
||||
.certificate-issuer {
|
||||
display: block;
|
||||
margin-top: 50rpx;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.certificate-date {
|
||||
display: block;
|
||||
color: #687078;
|
||||
font-size: 26rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.certificate-no {
|
||||
display: block;
|
||||
margin-top: 40rpx;
|
||||
color: #918a83;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.certificate-revoked {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 20rpx;
|
||||
color: #b42318;
|
||||
background: #fff0ef;
|
||||
border-radius: 18rpx;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "捐赠溯源详情" }
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { OldItemDonationItem } from '@lehuo/contracts';
|
||||
import { getMyOldItemDonation, getMyOldItemImageUrl } from '../../services/old-items';
|
||||
const labels: Record<string, string> = {
|
||||
SUBMITTED: '已提交,等待审核',
|
||||
REJECTED: '审核未通过',
|
||||
WAITING_PICKUP: '审核通过,等待取件',
|
||||
WAITING_DROP_OFF: '审核通过,等待投递',
|
||||
RECEIVED: '物资已接收',
|
||||
PROCESSING: '物资正在分拣消毒',
|
||||
COMPLETED: '帮扶已完成',
|
||||
CANCELLED: '捐赠已取消',
|
||||
};
|
||||
const inventoryLabels: Record<string, string> = {
|
||||
IN_STOCK: '物资已入库',
|
||||
SORTED: '物资已完成分拣',
|
||||
DISINFECTED: '物资已完成消毒',
|
||||
OUTBOUND: '物资已出库',
|
||||
DELIVERED: '物资已送达帮扶地',
|
||||
};
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
donation: null as
|
||||
| (OldItemDonationItem & {
|
||||
statusLabel: string;
|
||||
historyView: Array<{ label: string; time: string; note: string | null }>;
|
||||
inventoryView: Array<{ label: string; time: string; location: string | null }>;
|
||||
})
|
||||
| null,
|
||||
imageUrls: [] as string[],
|
||||
},
|
||||
onLoad(o) {
|
||||
if (!o['id']) {
|
||||
this.setData({ loading: false, error: '缺少捐赠记录参数' });
|
||||
return;
|
||||
}
|
||||
void this.load(o['id']);
|
||||
},
|
||||
async load(id: string) {
|
||||
try {
|
||||
const d = (await getMyOldItemDonation(id)).donation;
|
||||
const imageUrls = (
|
||||
await Promise.all(
|
||||
d.imagePublicIds.map(async (fileId) => {
|
||||
try {
|
||||
return (await getMyOldItemImageUrl(fileId)).downloadUrl;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
this.setData({
|
||||
imageUrls,
|
||||
donation: {
|
||||
...d,
|
||||
statusLabel: labels[d.status] ?? d.status,
|
||||
historyView: d.history.map((h) => ({
|
||||
label: labels[h.toStatus] ?? h.toStatus,
|
||||
time: new Date(h.createdAt).toLocaleString('zh-CN'),
|
||||
note: h.note,
|
||||
})),
|
||||
inventoryView: d.inventoryTrace.map((event) => ({
|
||||
label: inventoryLabels[event.status] ?? event.status,
|
||||
time: new Date(event.eventAt).toLocaleString('zh-CN'),
|
||||
location: event.locationText,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
rewards() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-rewards/old-item-rewards' });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="old-page"><view wx:if="{{error}}" class="old-card">{{error}}</view><block wx:elif="{{donation}}"><view class="old-hero"><text class="old-hero__copy">公益编号 {{donation.donationNo}}</text><text class="old-hero__title">{{donation.statusLabel}}</text></view><view class="old-card"><text class="old-title">捐赠信息</text><text class="old-muted">数量:{{donation.quantity}} 件</text><text class="old-muted">说明:{{donation.description}}</text><text class="old-muted">方式:{{donation.method==='PICKUP'?'上门取件':'定点投递'}}</text><text wx:if="{{donation.targetNeed}}" class="old-muted">定向需求:{{donation.targetNeed.title}}</text><text wx:if="{{donation.rejectionReason}}" class="old-muted">驳回原因:{{donation.rejectionReason}}</text><view wx:if="{{imageUrls.length}}" class="old-photo-grid"><image wx:for="{{imageUrls}}" wx:key="*this" class="old-photo" src="{{item}}" mode="aspectFill"></image></view></view><view class="old-card"><text class="old-title">捐赠处理记录</text><view class="old-trace"><view wx:for="{{donation.historyView}}" wx:key="time"><text class="old-title">{{item.label}}</text><text class="old-muted">{{item.time}}</text><text wx:if="{{item.note}}" class="old-muted">{{item.note}}</text></view></view></view><view wx:if="{{donation.inventoryView.length}}" class="old-card"><text class="old-title">物资全流程溯源</text><view class="old-trace"><view wx:for="{{donation.inventoryView}}" wx:key="time"><text class="old-title">{{item.label}}</text><text class="old-muted">{{item.time}}</text><text wx:if="{{item.location}}" class="old-muted">帮扶地区:{{item.location}}</text></view></view></view><view class="old-card"><text class="old-title">公益荣誉凭证</text><text class="old-muted">捐赠完成后会生成电子荣誉证书并记入公益荣誉积分。证书不作为捐赠票据、税务凭证或财产权利证明。</text><button class="old-secondary" bindtap="rewards">查看证书与积分</button></view></block></view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "捐赠旧物" }
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
OldItemCategory,
|
||||
OldItemCondition,
|
||||
OldItemDonationMethod,
|
||||
OldItemDropOffPoint,
|
||||
} from '@lehuo/contracts';
|
||||
import {
|
||||
createOldItemDonation,
|
||||
cryptoId,
|
||||
getOldItemDropOffPoints,
|
||||
uploadOldItemImage,
|
||||
} from '../../services/old-items';
|
||||
const categories = [
|
||||
{ code: 'CLOTHING', label: '服饰' },
|
||||
{ code: 'BOOKS', label: '书籍' },
|
||||
{ code: 'HOUSEHOLD', label: '生活用品' },
|
||||
{ code: 'SPORTS', label: '文体用品' },
|
||||
];
|
||||
const conditions = [
|
||||
{ code: 'NEW', label: '全新' },
|
||||
{ code: 'LIKE_NEW', label: '九成新' },
|
||||
{ code: 'GOOD', label: '使用良好' },
|
||||
{ code: 'FAIR', label: '可正常使用' },
|
||||
];
|
||||
const isCategory = (value: string): value is OldItemCategory =>
|
||||
categories.some((item) => item.code === value);
|
||||
const isCondition = (value: string): value is OldItemCondition =>
|
||||
conditions.some((item) => item.code === value);
|
||||
const isMethod = (value: string): value is OldItemDonationMethod =>
|
||||
value === 'PICKUP' || value === 'DROP_OFF';
|
||||
Page({
|
||||
data: {
|
||||
categories,
|
||||
conditions,
|
||||
category: 'CLOTHING',
|
||||
itemCondition: 'GOOD',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
method: 'PICKUP',
|
||||
contactName: '',
|
||||
mobile: '',
|
||||
address: '',
|
||||
appointmentAt: '',
|
||||
points: [] as OldItemDropOffPoint[],
|
||||
pointIndex: -1,
|
||||
localImages: [] as string[],
|
||||
imagePublicIds: [] as string[],
|
||||
targetNeedPublicId: '',
|
||||
submitting: false,
|
||||
error: '',
|
||||
},
|
||||
onLoad(options) {
|
||||
this.setData({
|
||||
targetNeedPublicId: options['need'] || '',
|
||||
category: isCategory(options['category'] || '') ? options['category'] : 'CLOTHING',
|
||||
});
|
||||
void this.loadPoints();
|
||||
},
|
||||
async loadPoints() {
|
||||
try {
|
||||
this.setData({ points: (await getOldItemDropOffPoints()).items });
|
||||
} catch {
|
||||
this.setData({ points: [] });
|
||||
}
|
||||
},
|
||||
setCategory(e: WechatMiniprogram.BaseEvent) {
|
||||
const value = String(e.currentTarget.dataset['value']);
|
||||
if (isCategory(value)) this.setData({ category: value });
|
||||
},
|
||||
setCondition(e: WechatMiniprogram.BaseEvent) {
|
||||
const value = String(e.currentTarget.dataset['value']);
|
||||
if (isCondition(value)) this.setData({ itemCondition: value });
|
||||
},
|
||||
setMethod(e: WechatMiniprogram.BaseEvent) {
|
||||
const value = String(e.currentTarget.dataset['value']);
|
||||
if (isMethod(value)) this.setData({ method: value });
|
||||
},
|
||||
input(e: WechatMiniprogram.Input) {
|
||||
const field = String(e.currentTarget.dataset['field']);
|
||||
this.setData({ [field]: e.detail.value });
|
||||
},
|
||||
pointChange(e: WechatMiniprogram.CustomEvent) {
|
||||
this.setData({ pointIndex: Number(e.detail.value) });
|
||||
},
|
||||
async chooseImages() {
|
||||
const remain = 6 - this.data.localImages.length;
|
||||
if (remain <= 0) return;
|
||||
try {
|
||||
const picked = await wx.chooseMedia({
|
||||
count: remain,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
sizeType: ['compressed'],
|
||||
});
|
||||
const paths = picked.tempFiles.map((x) => x.tempFilePath);
|
||||
this.setData({ submitting: true, error: '正在安全上传图片…' });
|
||||
const ids = [] as string[];
|
||||
for (const path of paths) ids.push(await uploadOldItemImage(path));
|
||||
this.setData({
|
||||
localImages: [...this.data.localImages, ...paths],
|
||||
imagePublicIds: [...this.data.imagePublicIds, ...ids],
|
||||
error: '',
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ error: e instanceof Error ? e.message : '图片上传失败' });
|
||||
} finally {
|
||||
this.setData({ submitting: false });
|
||||
}
|
||||
},
|
||||
removeImage(e: WechatMiniprogram.BaseEvent) {
|
||||
const index = Number(e.currentTarget.dataset['index']);
|
||||
this.setData({
|
||||
localImages: this.data.localImages.filter((_, i) => i !== index),
|
||||
imagePublicIds: this.data.imagePublicIds.filter((_, i) => i !== index),
|
||||
});
|
||||
},
|
||||
async submit() {
|
||||
if (this.data.submitting) return;
|
||||
const point = this.data.points[this.data.pointIndex];
|
||||
this.setData({ submitting: true, error: '' });
|
||||
try {
|
||||
const result = await createOldItemDonation({
|
||||
clientRequestId: cryptoId(),
|
||||
category: this.data.category as OldItemCategory,
|
||||
itemCondition: this.data.itemCondition as OldItemCondition,
|
||||
quantity: Number(this.data.quantity),
|
||||
description: this.data.description,
|
||||
method: this.data.method as OldItemDonationMethod,
|
||||
contactName: this.data.contactName,
|
||||
mobile: this.data.mobile,
|
||||
address: this.data.method === 'PICKUP' ? this.data.address : undefined,
|
||||
appointmentAt:
|
||||
this.data.method === 'PICKUP'
|
||||
? new Date(this.data.appointmentAt.replace(' ', 'T')).toISOString()
|
||||
: undefined,
|
||||
dropOffPointPublicId: this.data.method === 'DROP_OFF' ? point?.publicId : undefined,
|
||||
targetNeedPublicId: this.data.targetNeedPublicId || undefined,
|
||||
imagePublicIds: this.data.imagePublicIds,
|
||||
});
|
||||
await wx.showModal({
|
||||
title: '提交成功',
|
||||
content: `公益编号:${result.donation.donationNo}`,
|
||||
showCancel: false,
|
||||
});
|
||||
void wx.redirectTo({
|
||||
url: `/pages/old-item-detail/old-item-detail?id=${result.donation.publicId}`,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ error: e instanceof Error ? e.message : '提交失败,请检查填写内容' });
|
||||
} finally {
|
||||
this.setData({ submitting: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="old-page"><view class="old-card old-guide"><text class="old-title">捐赠须知</text><text>可捐:干净完好、功能正常的服饰、书籍、生活及文体用品。</text><text>禁捐:贴身衣物、破损霉变物品、药品、食品及存在安全风险的用品。</text></view><view class="old-card"><text class="old-title">1. 选择品类</text><view class="old-options"><button wx:for="{{categories}}" wx:key="code" class="{{category===item.code?'active':''}}" data-value="{{item.code}}" bindtap="setCategory">{{item.label}}</button></view><text class="old-field">新旧程度</text><view class="old-options"><button wx:for="{{conditions}}" wx:key="code" class="{{itemCondition===item.code?'active':''}}" data-value="{{item.code}}" bindtap="setCondition">{{item.label}}</button></view><label class="old-field">数量<input type="number" value="{{quantity}}" data-field="quantity" bindinput="input"/></label><label class="old-field">物品描述<textarea value="{{description}}" maxlength="1000" placeholder="请说明尺寸、使用情况等" data-field="description" bindinput="input"></textarea></label><text class="old-field">物品图片(最多 6 张)</text><view class="old-photo-grid"><view wx:for="{{localImages}}" wx:key="*this"><image class="old-photo" src="{{item}}" mode="aspectFill"></image><button class="old-remove" data-index="{{index}}" bindtap="removeImage">移除</button></view></view><button class="old-secondary" bindtap="chooseImages" disabled="{{submitting||localImages.length>=6}}">选择图片</button></view><view class="old-card"><text class="old-title">2. 捐赠方式</text><view class="old-options"><button class="{{method==='PICKUP'?'active':''}}" data-value="PICKUP" bindtap="setMethod">上门取件</button><button class="{{method==='DROP_OFF'?'active':''}}" data-value="DROP_OFF" bindtap="setMethod">定点投递</button></view><label class="old-field">联系人<input value="{{contactName}}" data-field="contactName" bindinput="input"/></label><label class="old-field">手机号<input type="number" maxlength="11" value="{{mobile}}" data-field="mobile" bindinput="input"/></label><block wx:if="{{method==='PICKUP'}}"><label class="old-field">取件地址<textarea value="{{address}}" data-field="address" bindinput="input"></textarea></label><label class="old-field">预约时间<input value="{{appointmentAt}}" placeholder="如 2026-08-20 14:00" data-field="appointmentAt" bindinput="input"/></label></block><block wx:else><label class="old-field">投递点<picker range="{{points}}" range-key="name" value="{{pointIndex}}" bindchange="pointChange"><view>{{pointIndex>=0?points[pointIndex].name:'请选择投递点'}}</view></picker></label><text wx:if="{{!points.length}}" class="old-muted">暂无启用投递点,请改用上门取件或稍后再试。</text></block></view><text wx:if="{{error}}" class="old-muted">{{error}}</text><button class="old-primary" disabled="{{submitting}}" bindtap="submit">{{submitting?'处理中…':'提交审核'}}</button></view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "捐赠规范指南" }
|
||||
@@ -0,0 +1 @@
|
||||
Page({});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="old-page"><view class="old-hero"><text class="old-hero__title">让善意安全抵达</text><text class="old-hero__copy">提交前请确认物品符合以下规范</text></view><view class="old-card old-guide"><text class="old-title">可以捐赠</text><text>✓ 清洗干净、无明显污渍破损的四季衣物</text><text>✓ 内容健康、无严重缺页涂画的书籍</text><text>✓ 功能正常、配件齐全的生活用品</text><text>✓ 安全完好、可继续使用的文体用品</text></view><view class="old-card old-guide"><text class="old-title">禁止捐赠</text><text>× 贴身衣物、药品、食品、化妆品</text><text>× 霉变、虫蛀、严重破损或无法清洁的物品</text><text>× 易燃易爆、尖锐危险及来源不明物品</text><text>× 涉及盗版、违法或不适合公益流转的内容</text></view><view class="old-card old-guide"><text class="old-title">整理与交接</text><text>请分类装袋或装箱,易碎品做好保护并在描述中注明。上门取件以后台确认的预约安排为准;定点投递请在开放时间内送达并出示公益编号。</text></view></view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "旧物善用" }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getOldItemNeeds, getOldItemStats } from '../../services/old-items';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
stats: { totalItems: 0, assistedPeople: 0, monthlyKindActs: 0 },
|
||||
needs: [] as Array<{
|
||||
publicId: string;
|
||||
title: string;
|
||||
regionText: string;
|
||||
remainingQuantity: number;
|
||||
}>,
|
||||
},
|
||||
onShow() {
|
||||
void this.load();
|
||||
},
|
||||
async load() {
|
||||
this.setData({ loading: true, error: '' });
|
||||
try {
|
||||
const [stats, needs] = await Promise.all([getOldItemStats(), getOldItemNeeds()]);
|
||||
this.setData({ stats, needs: needs.items.slice(0, 3) });
|
||||
} catch (error) {
|
||||
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
donate() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-donate/old-item-donate' });
|
||||
},
|
||||
records() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-records/old-item-records' });
|
||||
},
|
||||
rewards() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-rewards/old-item-rewards' });
|
||||
},
|
||||
needs() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-needs/old-item-needs' });
|
||||
},
|
||||
news() {
|
||||
void wx.navigateTo({ url: '/pages/content/content?type=PUBLIC_WELFARE_NEWS' });
|
||||
},
|
||||
guide() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-guide/old-item-guide' });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<view class="old-page">
|
||||
<view class="old-hero"><text class="old-hero__title">闲置不浪费,旧物有温度</text><text class="old-hero__copy">让每一件闲置物品,抵达真正需要的人身边</text></view>
|
||||
<view wx:if="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view>
|
||||
<block wx:else>
|
||||
<view class="old-card"><button class="old-primary" bindtap="donate">我要捐赠旧物</button><button class="old-secondary" bindtap="records">我的捐赠记录</button><button class="old-secondary" bindtap="rewards">我的证书与荣誉积分</button></view>
|
||||
<view class="old-card old-stats"><view class="old-stat"><text>{{stats.totalItems}}</text><text>累计捐赠件数</text></view><view class="old-stat"><text>{{stats.assistedPeople}}</text><text>累计帮扶人数</text></view><view class="old-stat"><text>{{stats.monthlyKindActs}}</text><text>本月爱心人次</text></view></view>
|
||||
<view class="old-grid"><button bindtap="needs"><text>物资需求公示</text><text>精准帮扶</text></button><button bindtap="records"><text>捐赠进度溯源</text><text>全程透明</text></button><button bindtap="news"><text>公益动态资讯</text><text>项目进展</text></button><button bindtap="guide"><text>捐赠规范指南</text><text>可捐 / 禁捐说明</text></button></view>
|
||||
<view class="old-card"><text class="old-title">最新物资需求</text><view wx:for="{{needs}}" wx:key="publicId"><text class="old-muted">{{item.title}} · {{item.regionText}} · 尚需 {{item.remainingQuantity}} 件</text></view><text wx:if="{{!needs.length}}" class="old-muted">当前暂无已发布需求,请稍后查看。</text></view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "物资需求公示" }
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OldItemNeedItem } from '@lehuo/contracts';
|
||||
import { getOldItemNeeds } from '../../services/old-items';
|
||||
const labels: Record<string, string> = {
|
||||
CLOTHING: '过冬衣物',
|
||||
BOOKS: '学习用品',
|
||||
HOUSEHOLD: '生活用品',
|
||||
SPORTS: '文体用品',
|
||||
};
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
category: '',
|
||||
tabs: [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'CLOTHING', label: '过冬衣物' },
|
||||
{ value: 'BOOKS', label: '学习用品' },
|
||||
{ value: 'HOUSEHOLD', label: '生活用品' },
|
||||
{ value: 'SPORTS', label: '文体用品' },
|
||||
],
|
||||
items: [] as Array<OldItemNeedItem & { categoryLabel: string }>,
|
||||
},
|
||||
onLoad() {
|
||||
void this.load();
|
||||
},
|
||||
async load() {
|
||||
this.setData({ loading: true, error: '' });
|
||||
try {
|
||||
const r = await getOldItemNeeds(this.data.category);
|
||||
this.setData({
|
||||
items: r.items.map((x) => ({ ...x, categoryLabel: labels[x.category] ?? x.category })),
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
filter(e: WechatMiniprogram.BaseEvent) {
|
||||
this.setData({ category: String(e.currentTarget.dataset['category'] ?? '') });
|
||||
void this.load();
|
||||
},
|
||||
donate(e: WechatMiniprogram.BaseEvent) {
|
||||
void wx.navigateTo({
|
||||
url: `/pages/old-item-donate/old-item-donate?need=${String(e.currentTarget.dataset['id'])}&category=${String(e.currentTarget.dataset['category'])}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="old-page"><view class="old-hero"><text class="old-hero__title">精准物资需求</text><text class="old-hero__copy">需求经后台发布,捐赠可直接定向关联</text></view><view class="old-options"><button wx:for="{{tabs}}" wx:key="value" class="{{category===item.value?'active':''}}" data-category="{{item.value}}" bindtap="filter">{{item.label}}</button></view><view wx:if="{{error}}" class="old-card">{{error}}</view><view wx:for="{{items}}" wx:key="publicId" class="old-card"><text class="old-title">{{item.title}}</text><text class="old-muted">{{item.regionText}} · {{item.beneficiaryGroup}}</text><text class="old-muted">{{item.description}}</text><text class="old-muted">{{item.categoryLabel}} · 尚需 {{item.remainingQuantity}} / {{item.requestedQuantity}} 件</text><button class="old-primary" data-id="{{item.publicId}}" data-category="{{item.category}}" bindtap="donate">定向捐赠</button></view><view wx:if="{{!loading&&!error&&!items.length}}" class="old-empty">当前暂无已发布需求</view></view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "我的捐赠记录" }
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { OldItemDonationItem } from '@lehuo/contracts';
|
||||
import { getMyOldItemDonations } from '../../services/old-items';
|
||||
const labels: Record<string, string> = {
|
||||
SUBMITTED: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
WAITING_PICKUP: '待取件',
|
||||
WAITING_DROP_OFF: '待投递',
|
||||
RECEIVED: '已接收',
|
||||
PROCESSING: '分拣中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
const categoryLabels: Record<string, string> = {
|
||||
CLOTHING: '服饰',
|
||||
BOOKS: '书籍',
|
||||
HOUSEHOLD: '生活用品',
|
||||
SPORTS: '文体用品',
|
||||
};
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
status: '',
|
||||
tabs: [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'SUBMITTED', label: '待审核' },
|
||||
{ value: 'WAITING_PICKUP', label: '待取件' },
|
||||
{ value: 'COMPLETED', label: '已完成' },
|
||||
{ value: 'REJECTED', label: '已驳回' },
|
||||
],
|
||||
items: [] as Array<
|
||||
OldItemDonationItem & { statusLabel: string; categoryLabel: string; dateText: string }
|
||||
>,
|
||||
},
|
||||
onShow() {
|
||||
void this.load();
|
||||
},
|
||||
async load() {
|
||||
this.setData({ loading: true, error: '' });
|
||||
try {
|
||||
const r = await getMyOldItemDonations(this.data.status);
|
||||
this.setData({
|
||||
items: r.items.map((x) => ({
|
||||
...x,
|
||||
statusLabel: labels[x.status] ?? x.status,
|
||||
categoryLabel: categoryLabels[x.category] ?? x.category,
|
||||
dateText: new Date(x.createdAt).toLocaleDateString('zh-CN'),
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
this.setData({ error: e instanceof Error ? e.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
filter(e: WechatMiniprogram.BaseEvent) {
|
||||
this.setData({ status: String(e.currentTarget.dataset['status'] ?? '') });
|
||||
void this.load();
|
||||
},
|
||||
open(e: WechatMiniprogram.BaseEvent) {
|
||||
void wx.navigateTo({
|
||||
url: `/pages/old-item-detail/old-item-detail?id=${String(e.currentTarget.dataset['id'])}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="old-page"><view class="old-options"><button wx:for="{{tabs}}" wx:key="value" class="{{status===item.value?'active':''}}" data-status="{{item.value}}" bindtap="filter">{{item.label}}</button></view><view wx:if="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view><button wx:for="{{items}}" wx:key="publicId" class="old-record" data-id="{{item.publicId}}" bindtap="open"><view class="old-record__head"><text class="old-title">{{item.categoryLabel}} · {{item.quantity}}件</text><text class="old-record__status">{{item.statusLabel}}</text></view><text class="old-muted">{{item.donationNo}}</text><text class="old-muted">{{item.dateText}} · {{item.method==='PICKUP'?'上门取件':'定点投递'}}</text></button><view wx:if="{{!loading&&!error&&!items.length}}" class="old-empty">暂无符合条件的捐赠记录</view></view>
|
||||
@@ -0,0 +1 @@
|
||||
@import '../../old-items.wxss';
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "证书与荣誉积分"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { OldItemRewardsSummary } from '@lehuo/contracts';
|
||||
import { getMyOldItemRewards } from '../../services/old-items';
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
error: '',
|
||||
summary: null as
|
||||
| (OldItemRewardsSummary & {
|
||||
certificateView: Array<
|
||||
OldItemRewardsSummary['certificates'][number] & { issuedDate: string }
|
||||
>;
|
||||
entryView: Array<OldItemRewardsSummary['entries'][number] & { createdDate: string }>;
|
||||
})
|
||||
| null,
|
||||
},
|
||||
onShow() {
|
||||
void this.load();
|
||||
},
|
||||
async load() {
|
||||
this.setData({ loading: true, error: '' });
|
||||
try {
|
||||
const summary = await getMyOldItemRewards();
|
||||
this.setData({
|
||||
summary: {
|
||||
...summary,
|
||||
certificateView: summary.certificates.map((item) => ({
|
||||
...item,
|
||||
issuedDate: new Date(item.issuedAt).toLocaleDateString('zh-CN'),
|
||||
})),
|
||||
entryView: summary.entries.map((item) => ({
|
||||
...item,
|
||||
createdDate: new Date(item.createdAt).toLocaleString('zh-CN'),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.setData({ error: error instanceof Error ? error.message : '加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
openCertificate(event: WechatMiniprogram.BaseEvent) {
|
||||
const id = event.currentTarget.dataset['id'] as string;
|
||||
void wx.navigateTo({ url: `/pages/old-item-certificate/old-item-certificate?id=${id}` });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<view class="old-page">
|
||||
<view class="old-hero"><text class="old-hero__copy">旧物善用 · 公益荣誉</text><text class="old-hero__title">每一份善意,都值得被记住</text></view>
|
||||
<view wx:if="{{loading}}" class="old-card"><text class="old-muted">正在加载荣誉记录…</text></view>
|
||||
<view wx:elif="{{error}}" class="old-card"><text>{{error}}</text><button class="old-secondary" bindtap="load">重试</button></view>
|
||||
<block wx:elif="{{summary}}">
|
||||
<view class="old-card reward-balance"><text class="reward-number">{{summary.balance}}</text><text class="reward-label">公益荣誉积分</text><text class="old-muted">仅用于记录公益参与,不可兑换、转让、提现或购买商品</text></view>
|
||||
<view class="old-card"><text class="old-title">我的电子证书</text><view wx:for="{{summary.certificateView}}" wx:key="publicId" class="reward-row" data-id="{{item.publicId}}" bindtap="openCertificate"><view class="reward-content"><text class="old-title">{{item.title}}</text><text class="old-muted">{{item.certificateNo}} · {{item.issuedDate}}</text><text wx:if="{{item.status==='REVOKED'}}" class="reward-status">已撤销</text></view><text>查看 ›</text></view><text wx:if="{{!summary.certificates.length}}" class="old-muted">完成旧物捐赠后,将在这里看到电子荣誉证书。</text></view>
|
||||
<view class="old-card"><text class="old-title">积分记录</text><view wx:for="{{summary.entryView}}" wx:key="publicId" class="reward-row"><view class="reward-content"><text>{{item.entryType==='EARNED'?'完成旧物捐赠':'后台校正'}}</text><text class="old-muted">{{item.donationNo || item.note}} · {{item.createdDate}}</text><text class="old-muted">结余 {{item.balanceAfter}}</text></view><text class="reward-delta {{item.pointsDelta<0?'reward-delta--negative':''}}">{{item.pointsDelta>0?'+':''}}{{item.pointsDelta}}</text></view><text wx:if="{{!summary.entries.length}}" class="old-muted">暂无积分记录。</text></view>
|
||||
</block>
|
||||
</view>
|
||||
@@ -0,0 +1,43 @@
|
||||
@import '../../old-items.wxss';
|
||||
|
||||
.reward-balance {
|
||||
text-align: center;
|
||||
}
|
||||
.reward-number {
|
||||
display: block;
|
||||
color: #e85220;
|
||||
font-size: 72rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.reward-label {
|
||||
color: #687078;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.reward-row {
|
||||
display: flex;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 2rpx solid #f0f1f2;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.reward-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.reward-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.reward-delta {
|
||||
color: #2f9b58;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.reward-delta--negative {
|
||||
color: #b42318;
|
||||
}
|
||||
.reward-status {
|
||||
color: #b42318;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
@@ -46,4 +46,7 @@ Page({
|
||||
organization() {
|
||||
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
|
||||
},
|
||||
oldItems() {
|
||||
void wx.navigateTo({ url: '/pages/old-item-home/old-item-home' });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<view wx:elif="{{error}}" class="state-card"><text class="state-title">暂时无法打开个人中心</text><text class="state-text">{{error}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
|
||||
<block wx:else>
|
||||
<view class="profile-hero"><view class="avatar">乐</view><view><text class="profile-name">{{user.displayName || '乐活用户'}}</text><text class="profile-id">账号已安全登录</text></view><text class="profile-badge">{{isVolunteer ? '认证志愿者' : '公益伙伴'}}</text><view class="profile-trust"><view><text class="profile-trust__title">隐私保护</text><text>最少采集</text></view><view><text class="profile-trust__title">服务可查</text><text>进度留痕</text></view><view><text class="profile-trust__title">消息可达</text><text>及时通知</text></view></view></view>
|
||||
<view class="summary-card"><button bindtap="applications"><text class="summary-card__icon orange">服</text><text class="summary-card__label">我的申请</text></button><button bindtap="volunteer"><text class="summary-card__icon green">愿</text><text class="summary-card__label">志愿服务</text></button><button bindtap="notifications"><text class="summary-card__icon blue">信</text><text class="summary-card__label">我的消息</text></button></view>
|
||||
<view class="summary-card"><button bindtap="applications"><text class="summary-card__icon orange">帮</text><text class="summary-card__label">帮扶服务</text></button><button bindtap="volunteer"><text class="summary-card__icon green">愿</text><text class="summary-card__label">志愿服务</text></button><button bindtap="notifications"><text class="summary-card__icon blue">信</text><text class="summary-card__label">我的消息</text></button></view>
|
||||
<view class="section-heading"><text class="section-title">我的服务</text></view>
|
||||
<view class="menu-card"><button bindtap="applications"><text class="menu-icon orange">申</text><view><text class="menu-title">我的服务申请</text><text class="menu-copy">查看需求受理与服务进度</text></view><text class="menu-arrow">›</text></button><button bindtap="volunteer"><text class="menu-icon green">愿</text><view><text class="menu-title">志愿者中心</text><text class="menu-copy">资料认证、活动报名与服务时长</text></view><text class="menu-arrow">›</text></button><button wx:if="{{canCollaborate}}" bindtap="assignedCases"><text class="menu-icon blue">协</text><view><text class="menu-title">我的协作任务</text><text class="menu-copy">查看被指派的必要服务信息</text></view><text class="menu-arrow">›</text></button></view>
|
||||
<view class="menu-card"><button bindtap="applications"><text class="menu-icon orange">帮</text><view><text class="menu-title">我的帮扶服务</text><text class="menu-copy">查看需求受理与服务进度</text></view><text class="menu-arrow">›</text></button><button bindtap="oldItems"><text class="menu-icon orange">物</text><view><text class="menu-title">我的旧物善用</text><text class="menu-copy">捐赠闲置物品并查看全程溯源</text></view><text class="menu-arrow">›</text></button><button bindtap="volunteer"><text class="menu-icon green">愿</text><view><text class="menu-title">志愿者中心</text><text class="menu-copy">资料认证、活动报名与服务时长</text></view><text class="menu-arrow">›</text></button><button wx:if="{{canCollaborate}}" bindtap="assignedCases"><text class="menu-icon blue">协</text><view><text class="menu-title">我的协作任务</text><text class="menu-copy">查看被指派的必要服务信息</text></view><text class="menu-arrow">›</text></button></view>
|
||||
<view class="section-heading"><text class="section-title">消息与帮助</text></view>
|
||||
<view class="menu-card"><button bindtap="notifications"><text class="menu-icon blue">信</text><view><text class="menu-title">我的消息</text><text class="menu-copy">服务进度、报名结果与通知设置</text></view><text class="menu-arrow">›</text></button><button bindtap="content"><text class="menu-icon purple">问</text><view><text class="menu-title">公益资讯与常见问题</text><text class="menu-copy">查看经审核发布的帮助内容</text></view><text class="menu-arrow">›</text></button><button bindtap="organization"><text class="menu-icon orange">乐</text><view><text class="menu-title">关于乐活公益</text><text class="menu-copy">了解机构与服务说明</text></view><text class="menu-arrow">›</text></button></view>
|
||||
<view class="privacy-footnote">您的服务资料仅用于提供公益服务,并按最小权限原则保护。</view>
|
||||
|
||||
@@ -9,12 +9,19 @@ Page({
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
code: 'WOMEN_CHILDREN',
|
||||
label: '妇女儿童帮扶',
|
||||
description: '困境支持、成长陪伴与权益关爱',
|
||||
icon: '爱',
|
||||
code: 'WOMEN',
|
||||
label: '困境妇女',
|
||||
description: '困境支持、权益关爱与资源链接',
|
||||
icon: '女',
|
||||
tone: 'rose',
|
||||
},
|
||||
{
|
||||
code: 'CHILD',
|
||||
label: '儿童关爱',
|
||||
description: '成长陪伴、家庭支持与儿童关爱',
|
||||
icon: '童',
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
code: 'DISABILITY',
|
||||
label: '残障帮扶',
|
||||
@@ -23,9 +30,9 @@ Page({
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
code: 'DIFFICULT_FAMILY',
|
||||
label: '困难家庭服务',
|
||||
description: '生活困难、资源链接与持续关爱',
|
||||
code: 'LIFE_DIFFICULTY',
|
||||
label: '生活困难',
|
||||
description: '生活支持、资源链接与持续关爱',
|
||||
icon: '家',
|
||||
tone: 'green',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<view class="page">
|
||||
<view class="service-hero"><text class="service-hero__eyebrow">乐活暖心服务</text><text class="service-hero__title">有困难,您可以告诉我们</text><text class="service-hero__copy">填写基本情况后,工作人员会根据实际服务能力与您联系。</text></view>
|
||||
<view class="quick-actions"><button bindtap="applyForSelf"><text class="quick-actions__icon">♡</text><text class="quick-actions__title">我需要帮助</text><text class="quick-actions__copy">为自己申请</text></button><button bindtap="applyForFamily"><text class="quick-actions__icon green">♧</text><text class="quick-actions__title">帮家人申请</text><text class="quick-actions__copy">为他人代办</text></button></view>
|
||||
<button class="progress-entry" bindtap="applications"><view><text class="progress-entry__title">我的服务申请</text><text class="progress-entry__copy">查看受理进度和历史记录</text></view><text>›</text></button>
|
||||
<button class="progress-entry" bindtap="applications"><view><text class="progress-entry__title">我的帮扶服务</text><text class="progress-entry__copy">查看需求受理进度和历史记录</text></view><text>›</text></button>
|
||||
<view class="section-heading"><text class="section-title">选择服务类型</text><text class="section-note">不知道选什么也没关系</text></view>
|
||||
<button wx:for="{{categories}}" wx:key="code" class="category-card" data-code="{{item.code}}" bindtap="applyCategory"><text class="category-card__icon {{item.tone}}">{{item.icon}}</text><view><text class="category-card__title">{{item.label}}</text><text class="category-card__copy">{{item.description}}</text></view><text class="category-card__arrow">›</text></button>
|
||||
<view class="safety-note"><text class="safety-note__title">温馨提示</text><text>平台不承诺即时救援。如遇危及生命安全的紧急情况,请及时联系 110 或 120。</text></view>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
CreateOldItemDonationRequest,
|
||||
CreateUploadIntentResponse,
|
||||
OldItemDonationItem,
|
||||
OldItemDropOffPoint,
|
||||
OldItemNeedItem,
|
||||
OldItemCertificateItem,
|
||||
OldItemRewardsSummary,
|
||||
OldItemStats,
|
||||
DownloadUrlResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
export const getOldItemStats = () => apiRequest<OldItemStats>('/api/v1/old-items/stats');
|
||||
export const getOldItemNeeds = (category = '') =>
|
||||
apiRequest<Page<OldItemNeedItem>>(
|
||||
`/api/v1/old-items/needs?page=1&pageSize=50${category ? `&category=${category}` : ''}`,
|
||||
);
|
||||
export const getOldItemDropOffPoints = () =>
|
||||
apiRequest<{ items: OldItemDropOffPoint[] }>('/api/v1/old-items/drop-off-points');
|
||||
export const createOldItemDonation = (input: CreateOldItemDonationRequest) =>
|
||||
apiRequest<{ donation: OldItemDonationItem }>('/api/v1/old-items/donations', {
|
||||
method: 'POST',
|
||||
data: input,
|
||||
});
|
||||
export const getMyOldItemDonations = (status = '') =>
|
||||
apiRequest<Page<OldItemDonationItem>>(
|
||||
`/api/v1/old-items/me/donations?page=1&pageSize=50${status ? `&status=${status}` : ''}`,
|
||||
);
|
||||
export const getMyOldItemDonation = (id: string) =>
|
||||
apiRequest<{ donation: OldItemDonationItem }>(`/api/v1/old-items/me/donations/${id}`);
|
||||
export const getMyOldItemRewards = () =>
|
||||
apiRequest<OldItemRewardsSummary>('/api/v1/old-items/me/rewards');
|
||||
export const getMyOldItemCertificate = (id: string) =>
|
||||
apiRequest<{ certificate: OldItemCertificateItem }>(`/api/v1/old-items/me/certificates/${id}`);
|
||||
export const getMyOldItemImageUrl = (id: string) =>
|
||||
apiRequest<DownloadUrlResponse>(`/api/v1/old-items/me/media/${id}/download`);
|
||||
function fileInfo(path: string): Promise<{ size: number }> {
|
||||
return new Promise((resolve, reject) =>
|
||||
wx.getFileInfo({ filePath: path, success: resolve, fail: reject }),
|
||||
);
|
||||
}
|
||||
function read(path: string): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) =>
|
||||
wx
|
||||
.getFileSystemManager()
|
||||
.readFile({ filePath: path, success: (r) => resolve(r.data as ArrayBuffer), fail: reject }),
|
||||
);
|
||||
}
|
||||
function put(url: string, data: ArrayBuffer, headers: Record<string, string>): Promise<void> {
|
||||
return new Promise((resolve, reject) =>
|
||||
wx.request({
|
||||
url,
|
||||
method: 'PUT',
|
||||
data,
|
||||
header: headers,
|
||||
success: (r) =>
|
||||
r.statusCode >= 200 && r.statusCode < 300 ? resolve() : reject(new Error('图片上传失败')),
|
||||
fail: reject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function uploadOldItemImage(path: string) {
|
||||
const info = await fileInfo(path);
|
||||
const ext = (path.split('.').pop() || 'jpg').toLowerCase();
|
||||
const mimeType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
||||
const intent = await apiRequest<CreateUploadIntentResponse>(
|
||||
'/api/v1/old-items/media/upload-intents',
|
||||
{
|
||||
method: 'POST',
|
||||
data: {
|
||||
clientRequestId: cryptoId(),
|
||||
businessType: 'OLD_ITEM_DONATION_IMAGE',
|
||||
filename: `donation.${ext === 'jpeg' ? 'jpg' : ext}`,
|
||||
mimeType,
|
||||
size: info.size,
|
||||
},
|
||||
},
|
||||
);
|
||||
await put(intent.uploadUrl, await read(path), intent.uploadHeaders);
|
||||
await apiRequest(`/api/v1/old-items/media/${intent.file.publicId}/confirm`, { method: 'POST' });
|
||||
return intent.file.publicId;
|
||||
}
|
||||
export function cryptoId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c === 'x' ? r : (r & 3) | 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
@@ -73,8 +73,8 @@
|
||||
### 服务
|
||||
|
||||
- 服务分类
|
||||
- 服务申请
|
||||
- 我的申请
|
||||
- 帮扶服务(原“服务申请”的公众端名称)
|
||||
- 我的帮扶服务
|
||||
- 服务进度
|
||||
- 我的反馈
|
||||
- 联系乐活
|
||||
@@ -106,6 +106,8 @@
|
||||
|
||||
## 4. 服务申请
|
||||
|
||||
> 公众端统一使用“帮扶服务/帮扶需求”文案;本节“服务申请”是内部业务模型名称,不修改既有 API、状态机和数据口径。
|
||||
|
||||
### 4.1 申请类型
|
||||
|
||||
- OLD_AGE:老年关爱/为老服务
|
||||
|
||||
@@ -485,3 +485,37 @@ Phase 7 不建立可人工修改的统计快照;九项 V1 指标直接从申
|
||||
- created_by / updated_by / timestamps
|
||||
|
||||
`financial_disclosure_status_history` 只追加保存创建、发布和归档事实,请求 UUID 全局唯一。已发布文档禁止覆盖编辑,只能归档;草稿编辑使用 revision 乐观锁。
|
||||
|
||||
---
|
||||
|
||||
## 24. 旧物善用 Phase 1
|
||||
|
||||
- `old_item_donations`:随机 `public_id`、非连续公益编号、捐赠人、幂等键、品类、新旧程度、数量、方式、状态、revision、定向需求、投递点、人工分配人员和真实帮扶人数。
|
||||
- `old_item_donation_status_history`:只追加保存初始提交和每次状态变化,`request_id` 全局唯一。
|
||||
- `old_item_donation_files`:关联最多六张已确认的 `OLD_ITEM_DONATION_IMAGE` 私有文件。
|
||||
- `old_item_needs`:随机公开 ID、品类、区域、受助群体、需求量、已满足量、优先级、排序、发布状态和 revision。
|
||||
- `old_item_need_status_history`:只追加保存需求发布与下架事实。
|
||||
- `old_item_drop_off_points`:后台维护的公开投递名称、区域、地址、开放时间、启停和排序。
|
||||
|
||||
联系人、手机号、上门取件地址、物品描述、驳回原因和内部轨迹备注加密保存;手机号另存域隔离 HMAC。列表不公开敏感明文,管理端详情读取写审计。全部外键使用 `RESTRICT`,公开资源不暴露自增主键。
|
||||
|
||||
---
|
||||
|
||||
## 25. 旧物善用 Phase 2 物资台账
|
||||
|
||||
- `old_item_inventory_imports`:记录随机公开 ID、创建人幂等键、请求指纹、加密原文件名和导入行数;不保存原 Excel 文件。
|
||||
- `old_item_inventory_items`:随机公开 ID、非连续台账编号、可选来源捐赠/导入批次、品类、名称、数量、单位、仓位、状态、定向需求、可选受助对象、帮扶区域、加密备注、落地时间和 revision。每个来源捐赠最多绑定一个物资批次,避免重复入库。
|
||||
- `old_item_inventory_events`:只追加保存入库、分拣、消毒、出库、落地节点,请求 UUID 全局唯一,记录前后状态、时间、位置、加密备注和操作人。
|
||||
|
||||
台账状态严格为 `IN_STOCK → SORTED → DISINFECTED → OUTBOUND → DELIVERED`,不允许跳级、回退或覆盖事件。Excel 批量入库在一个事务中创建导入批次、所有物资行、入库事件和审计,任一行无效时整批回滚。受助对象只保存既有内部外键;小程序仅返回流转状态、时间和出库/落地的粗粒度地区。
|
||||
|
||||
---
|
||||
|
||||
## 26. 旧物善用 Phase 3 荣誉证书与积分
|
||||
|
||||
- `old_item_reward_configs`:不可变规则版本,保存证书标题/正文/颁发方/法律声明、四类物资每件积分、单笔积分上限和当前激活标识;新版本不覆盖历史版本。
|
||||
- `old_item_certificates`:随机公开 ID、非连续证书编号、来源捐赠、规则版本、加密收件人称呼、完整模板快照、签发/撤销幂等指纹、签发与撤销操作者及时间。每笔捐赠最多一张当前有效证书,补发会撤销旧证书但不删除历史。
|
||||
- `old_item_point_accounts`:每个用户一个荣誉积分账户,保存余额和 revision;余额不得小于零。
|
||||
- `old_item_point_entries`:只追加积分流水,记录获得/人工校正、变更值、变更后余额、来源捐赠/规则版本、幂等指纹、加密原因和操作人;来源捐赠唯一,防止重复计分。
|
||||
|
||||
捐赠完成、证书签发、积分账户更新、积分流水和审计在同一事务提交。积分没有货币价值,不建立兑换订单、商品、转让、提现、到期或排行榜数据结构。
|
||||
|
||||
@@ -306,3 +306,73 @@ Phase 0 提供:
|
||||
- `POST /api/v1/admin/financial-disclosures/:publicId/transition`
|
||||
|
||||
创建、发布和归档使用客户端 UUID 幂等;编辑只允许草稿且要求 expectedRevision。状态为 `DRAFT → PUBLISHED → ARCHIVED`,状态变化与历史、审计事务一致。DOCX 在管理端浏览器转换为富文本,API 只接受并白名单清洗富文本,不执行 Office 文件。
|
||||
|
||||
---
|
||||
|
||||
## 13. 旧物善用 Phase 1 API
|
||||
|
||||
公众只读接口:
|
||||
|
||||
- `GET /api/v1/old-items/stats`
|
||||
- `GET /api/v1/old-items/needs`、`GET /api/v1/old-items/needs/:publicId`
|
||||
- `GET /api/v1/old-items/drop-off-points`
|
||||
|
||||
微信会话接口:
|
||||
|
||||
- `POST /api/v1/old-items/media/upload-intents`、`POST /api/v1/old-items/media/:publicId/confirm`
|
||||
- `GET /api/v1/old-items/me/media/:publicId/download`(本人私有图片短期 URL)
|
||||
- `POST /api/v1/old-items/donations`
|
||||
- `GET /api/v1/old-items/me/donations`、`GET /api/v1/old-items/me/donations/:publicId`
|
||||
|
||||
管理端接口:
|
||||
|
||||
- `GET /api/v1/admin/old-items/donations`、`GET /api/v1/admin/old-items/donations/:publicId`
|
||||
- `GET /api/v1/admin/old-items/assignees`
|
||||
- `GET /api/v1/admin/old-items/media/:publicId/download`(授权管理人员短期 URL)
|
||||
- `POST /api/v1/admin/old-items/donations/:publicId/transition`
|
||||
- `GET/POST /api/v1/admin/old-items/needs`、`GET/PATCH /api/v1/admin/old-items/needs/:publicId`
|
||||
- `POST /api/v1/admin/old-items/needs/:publicId/transition`
|
||||
- `POST /api/v1/admin/old-items/drop-off-points`
|
||||
|
||||
捐赠创建、需求创建及全部状态变化使用客户端 UUID 幂等。状态变化要求 `expectedRevision`,通过条件更新、只追加历史和审计在一个数据库事务中提交。本人接口按 donor user 强制隔离;管理端敏感详情查看单独审计。图片最多六张,使用私有对象前缀、短期预签名上传和对象元数据确认,不返回永久公开地址。
|
||||
|
||||
捐赠主流程为 `SUBMITTED → WAITING_PICKUP/WAITING_DROP_OFF → RECEIVED → PROCESSING → COMPLETED`;审核可进入 `REJECTED`,接收前可进入 `CANCELLED`。完成定向捐赠时在同一事务增加需求已满足数量。统计直接从已接收或已完成捐赠事实计算,不提供人工覆盖接口。
|
||||
|
||||
---
|
||||
|
||||
## 14. 旧物善用 Phase 2 台账 API
|
||||
|
||||
管理端接口:
|
||||
|
||||
- `GET/POST /api/v1/admin/old-items/inventory`
|
||||
- `GET /api/v1/admin/old-items/inventory/:publicId`
|
||||
- `POST /api/v1/admin/old-items/inventory/import`
|
||||
- `POST /api/v1/admin/old-items/inventory/:publicId/transition`
|
||||
- `GET /api/v1/admin/old-items/inventory-export/file`
|
||||
|
||||
权限拆分为 `old_item_inventory:read/manage/import/export`。手工入库和批量导入都创建 `IN_STOCK` 主记录与 `INBOUND` 事件;批量导入最多 500 行、全有或全无。状态只允许 `IN_STOCK → SORTED → DISINFECTED → OUTBOUND → DELIVERED`。出库必须绑定定向需求、有效受助对象或帮扶区域之一,落地必须填写实际帮扶人数。所有创建/导入/状态变更使用客户端 UUID、请求指纹、revision、条件更新、只追加事件和事务审计。
|
||||
|
||||
导出最多 5000 条,返回用于浏览器生成 `.xlsx` 的最小化结构化行,不包含联系人、手机号、详细地址、受助对象姓名和内部备注,并记录导出审计。小程序既有捐赠详情响应增加 `inventoryTrace`,仅包含状态、时间以及出库/落地的粗粒度地区。
|
||||
|
||||
---
|
||||
|
||||
## 15. 旧物善用 Phase 3 荣誉 API
|
||||
|
||||
微信会话接口:
|
||||
|
||||
- `GET /api/v1/old-items/me/rewards`
|
||||
- `GET /api/v1/old-items/me/certificates/:publicId`
|
||||
|
||||
管理端接口:
|
||||
|
||||
- `GET /api/v1/admin/old-items/reward-config`
|
||||
- `POST /api/v1/admin/old-items/reward-config/activate`
|
||||
- `GET /api/v1/admin/old-items/reward-accounts`
|
||||
- `POST /api/v1/admin/old-items/reward-accounts/:userPublicId/adjust`
|
||||
- `GET /api/v1/admin/old-items/certificates`
|
||||
- `POST /api/v1/admin/old-items/donations/:publicId/certificate`
|
||||
- `POST /api/v1/admin/old-items/certificates/:publicId/revoke`
|
||||
|
||||
管理端权限拆分为 `old_item_rewards:read/manage`。规则激活、补发、撤销和积分调整使用客户端 UUID、请求指纹及事务审计;积分调整还要求账户 `expectedRevision`,不允许调整为负余额。证书模板仅允许固定纯文本占位符,由服务端渲染并作为快照保存,不接受 HTML。本人接口按用户资源归属隔离;不存在公众证书查询接口。
|
||||
|
||||
捐赠直接完成或关联物资台账落地时,证书与积分随 `COMPLETED` 状态在同一事务生成。证书补发不会重复计分;规则变更不追溯修改既有证书和流水。
|
||||
|
||||
@@ -290,3 +290,17 @@
|
||||
- 管理端导入和预览先经 DOMPurify 清洗,API 保存前再次使用独立白名单清洗;禁止脚本、事件属性、iframe、非 HTTPS 链接和非 HTTPS 图片。
|
||||
- 只有管理员与超级管理员默认获得财务公开查看、编辑和发布权限;公众只能访问已发布文档。
|
||||
- 已发布文档禁止原地覆盖编辑,归档后不可公开;发布和归档写入不可变历史与审计。
|
||||
|
||||
## 旧物善用隐私与文件安全(2026-08-15)
|
||||
|
||||
- 联系人、手机号、上门取件地址、物品描述、驳回原因和内部轨迹备注加密保存;手机号查询索引使用独立域 HMAC。
|
||||
- 捐赠图片存入私有对象前缀,上传前校验 MIME、扩展名、大小,确认时校验对象元数据;不返回永久公开 URL。
|
||||
- 本人捐赠与图片下载执行资源归属校验;管理端详情和图片 URL 获取要求独立权限并写审计。列表只显示脱敏手机号,不记录地址或描述到日志/审计 metadata。
|
||||
- 公益编号含随机片段,所有外部资源使用随机 UUID,不暴露数据库自增 ID。
|
||||
- 物资台账的内部备注继续加密;关联受助对象只返回随机公开 ID,不在导出、打印或小程序溯源中返回姓名、手机号、地址或服务描述。
|
||||
- Excel 只在管理端浏览器解析和生成,API 不执行宏、不保存原工作簿;公式单元格拒绝导入。固定模板最多 500 行,服务端再次执行白名单、类型和状态校验,整批事务失败回滚。
|
||||
- 台账导出使用独立权限并写审计,最多 5000 条且默认最小化;小程序不公开内部仓位和操作人,仅公开出库/落地地区。
|
||||
- 电子证书收件人称呼、撤销原因和积分人工校正原因加密保存;管理端证书列表统一显示“爱心用户”,本人登录后才可查看自己的证书正文。
|
||||
- 证书公开标识和证书编号均不可预测,且不提供无需登录的证书核验接口;本人证书详情执行资源归属校验,防止 IDOR/BOLA。
|
||||
- 证书模板是服务端校验并转义展示的纯文本,仅允许批准占位符,不执行 HTML;每张证书保存不可变快照,避免模板修改篡改历史凭证。
|
||||
- 规则激活、自动签发、补发、撤销、积分获得和人工校正均审计;积分账户使用行锁/revision,流水只追加,余额不得为负。
|
||||
|
||||
@@ -175,3 +175,18 @@ P2:5. 公益项目 6. 公益活动 7. 我要做公益
|
||||
- 管理端新增独立“财务公开”导航,支持空白新建、10MB 以内 DOCX 导入、标题/摘要、基础标题与段落、加粗、下划线、列表、对齐、撤销/重做和接近小程序宽度的预览。
|
||||
- Word 的复杂浮动对象、页眉页脚、分页和高级样式允许在导入时简化,发布前必须人工预览确认;首版重点保障段落、标题、列表和表格。
|
||||
- 小程序“公益 → 了解乐活”新增财务公开入口;列表展示置顶、标题、摘要和发布时间,详情使用 rich-text 展示,宽表可横向滚动,加载失败可重试。
|
||||
|
||||
---
|
||||
|
||||
## 14. 首页帮扶服务入口调整(2026-08-15)
|
||||
|
||||
- 首页取消关爱老人、困境儿童、妇女帮扶和残障救助四个群体快捷按钮,避免与帮扶服务中的六类真实服务分类重复。
|
||||
- 功能区改为一个高权重“帮扶服务”横向主入口,下方四列展示志愿服务、公益项目、公益动态和旧物善用;主入口包含用途说明,保持适老化点击区域和清晰层级。
|
||||
- 小程序公众文案将“服务申请”统一改为“帮扶服务”,将提交动作描述为“提交帮扶需求”;内部数据库、API、权限及状态机仍保留 `ServiceApplication` 语义,不做破坏性改名。
|
||||
- 帮扶服务分类页六个类别均使用本地品牌场景图。新增图片中的人物和场景均为 AI 生成的虚构视觉素材,仅作类别识别,不作为真实服务对象、服务过程或项目成果证据。
|
||||
|
||||
## 15. 首页主功能区与便捷入口调整(2026-08-15)
|
||||
|
||||
- “帮扶服务”下方主功能区使用 3×2 六宫格,依次提供志愿服务、公益项目、公益动态、旧物善用、财务公开和服务进度,单项保持清晰文字标签及适老化点击区域。
|
||||
- 财务公开直接进入已发布文档列表,服务进度直接进入本人帮扶服务列表;两项均复用现有真实页面和权限边界,不新增空白占位页面。
|
||||
- 服务进度从便捷入口上移至主功能区,避免同页重复;便捷入口取消旧物善用,仅保留公益活动,以及有权限用户可见的协作任务。
|
||||
|
||||
@@ -165,3 +165,35 @@ UAT 不只让开发人员自测。
|
||||
- 未登录或无权限人员不能访问管理接口;草稿和归档文档不能从公众接口获取。
|
||||
- 草稿可编辑并使用 revision 防并发覆盖;已发布文档不能覆盖编辑,只能归档。
|
||||
- 创建、发布和归档幂等,状态历史和审计完整;小程序列表、详情、空状态和失败状态可用。
|
||||
|
||||
---
|
||||
|
||||
## 9. 旧物善用 Phase 1 验收
|
||||
|
||||
- 首页和个人中心入口均可到达模块主页;模块主页统计只显示真实业务事实。
|
||||
- 上门取件必须填写联系人、合法手机号、地址、未来预约时间并由后台选择工作人员;定点投递必须选择已启用点位。
|
||||
- 最多六张图片通过私有对象存储预签名上传并确认元数据;只有捐赠本人和具备旧物查看权限的后台人员可获取五分钟短期下载 URL,访问需审计。
|
||||
- 捐赠创建幂等,其他用户不能读取猜测的公开 ID;审核、接收、分拣和完成严格校验 revision 与状态机,每次变化都有不可变历史和审计。
|
||||
- 需求仅在发布后公开;定向捐赠完成时原子增加已满足数量;统计从接收/完成事实计算,不允许后台人工覆盖。
|
||||
- 管理端可处理订单、人工分配、发布/下架需求和新增投递点;Phase 2 台账、Excel、证书和积分不得以空按钮冒充完成。
|
||||
|
||||
---
|
||||
|
||||
## 10. 旧物善用 Phase 2 验收
|
||||
|
||||
- 手工入库和标准 Excel 批量入库都生成不可预测台账编号、在库主记录、入库事件和审计;同一请求安全重放,不同内容复用请求 ID 被拒绝。
|
||||
- Excel 模板具有固定表头、品类下拉、整数数量、日期格式、冻结表头与合理列宽;公式、错误表头、非法品类、非正整数数量、重复来源捐赠和超过 500 行必须拒绝,任一行失败时整批不落库。
|
||||
- 状态只能按在库、分拣、消毒、出库、落地推进,跳级/回退和过期 revision 被拒绝;出库缺少需求/对象/区域或落地缺少实际帮扶人数时拒绝。
|
||||
- 详情包含完整只追加事件;导出和打印不包含手机号、地址、受助对象姓名或内部备注,导出有独立权限和审计。
|
||||
- 关联捐赠的小程序详情只展示脱敏节点、时间和粗粒度帮扶地区,不展示内部仓位、操作人或受助对象个人信息。
|
||||
|
||||
---
|
||||
|
||||
## 11. 旧物善用 Phase 3 验收
|
||||
|
||||
- 捐赠完成时证书、积分账户、积分流水和审计与业务状态同事务提交;同一捐赠不能重复计分或同时存在多张有效证书。
|
||||
- 规则按不可变版本激活,非法占位符必须拒绝;新规则只影响未来签发,既有证书保留当时标题、正文、颁发方和法律声明快照。
|
||||
- 证书只能由捐赠本人查看,其他微信用户读取相同随机公开 ID 返回不存在;管理端读取/管理要求独立权限。
|
||||
- 补发会撤销旧证书并保留历史,显式撤销必须填写原因;重复请求安全重放,竞态状态变化被拒绝。
|
||||
- 自动积分受品类规则和单笔上限约束;人工调整必须填写原因、校验 revision、生成只追加流水且不能产生负余额。
|
||||
- 小程序明确展示积分仅为公益荣誉、不可兑换/购买/转让/提现,证书明确不是捐赠收据、税务凭证或财产权利证明。
|
||||
|
||||
+73
-3
@@ -4,6 +4,76 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
2026-08-15
|
||||
|
||||
任务:小程序首页主功能区与便捷入口调整。
|
||||
|
||||
完成:在首页“帮扶服务”下方将次级功能区调整为 3×2 六宫格,保留志愿服务、公益项目、公益动态和旧物善用,新增财务公开与服务进度;财务公开连接既有公开文档列表,服务进度连接本人帮扶服务列表。服务进度从便捷入口上移以避免重复,便捷入口取消旧物善用,当前仅保留公益活动及按权限显示的协作任务。未删除旧物善用模块或其首页主功能入口。
|
||||
|
||||
范围:仅调整小程序首页入口信息架构、WXML/WXSS 与既有页面导航;未新增空白占位页面、API、数据库、权限、状态机或 migration。财务公开、服务进度和旧物善用继续沿用既有真实业务与访问控制。
|
||||
|
||||
验证:全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过;测试库 12 个 migration 无待执行项。小程序页面完整性门禁通过 28 个页面,运行时 workspace 导入门禁通过。新增 D-023。
|
||||
|
||||
遗留:需要用户在微信开发者工具清缓存重新编译,并真机确认 3×2 六宫格在常见屏幕宽度下的间距、文字与点击效果。真实微信、HTTPS 域名和生产环境联调仍按生产就绪计划后期提供。
|
||||
|
||||
---
|
||||
|
||||
2026-08-15
|
||||
|
||||
任务:小程序首页栏目收敛与“帮扶服务”公众端改名。
|
||||
|
||||
完成:首页移除关爱老人、困境儿童、妇女帮扶和残障救助四个重复分类按钮,将栏目重排为一个带说明的高权重“帮扶服务”主入口与志愿服务、公益项目、公益动态、旧物善用四个次级入口;首页 Hero、帮扶表单、提交成功、我的帮扶服务、详情、服务聚合页和个人中心公众文案完成统一。服务聚合页分类代码修正为六个共享契约真实值,避免妇女、儿童和生活困难入口无法预选。为困境妇女、儿童关爱、生活困难和其他服务分别生成并压缩四张 900×675 本地 JPG 场景图,申请选择页按六个分类逐一显示图片,不再出现文字占位图。
|
||||
|
||||
范围:仅调整小程序公众信息架构、文案、样式和本地视觉素材。内部 `ServiceApplication` API、数据库、状态机、权限和管理端业务名称保持不变;AI 生成人物和场景仅作分类识别,不作为真实服务对象、服务过程或公益成果证据。未新增接口、migration 或业务范围。
|
||||
|
||||
验证:全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过;测试库 12 个 migration 无待执行项。小程序页面完整性门禁通过 28 个页面,运行时 workspace 导入门禁通过;四张新增图片均压缩至约 61–74 kB。新增 D-022。
|
||||
|
||||
遗留:需要用户在微信开发者工具清缓存重新编译,并真机确认首页新栏目在常见屏幕宽度下的视觉层级、四张分类图裁切和公众文案。真实微信、HTTPS 域名和生产环境联调仍按生产就绪计划后期提供。
|
||||
|
||||
---
|
||||
|
||||
2026-08-15
|
||||
|
||||
任务:旧物善用独立模块 Phase 3 电子荣誉证书与公益荣誉积分。
|
||||
|
||||
完成:新增不可变证书/积分规则版本、电子证书快照、积分账户和只追加积分流水;捐赠直接完成或关联台账落地时,在同一事务自动生成证书、按品类和单笔上限计分并写审计。默认每件 1 分、单笔最多 500 分。管理端新增规则激活与证书预览、完成捐赠补发、证书历史/撤销、积分账户和有理由人工校正;规则变更不改历史证书,补发撤销但保留旧证书,校正使用 revision 且不允许负余额。小程序新增证书与荣誉积分总览、只追加流水和本人证书详情,并从模块主页与捐赠详情接入。
|
||||
|
||||
范围:积分仅为公益荣誉记录,不具有现金价值,不支持兑换、购买、转让、提现、到期或排行;证书不是捐赠收据、税务凭证或财产权利证明。不新增现金捐赠、支付、自动扣款或第三方物流。既有已完成捐赠不自动批量回填,避免未经核实改写历史;授权人员可依据真实完成记录补发证书,积分补录只能通过有理由审计流水。
|
||||
|
||||
验证:Phase 3 migration 已部署到专用 `lehuo_test`,共 12 个 migration,重复 deploy 无待执行项;全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过。新增集成覆盖规则激活幂等、品类计分和单笔上限、完成时自动发证计分、本人资源隔离、他人证书读取拒绝、积分校正幂等、负余额拒绝、证书补发/撤销和审计;小程序页面完整性门禁通过 28 个页面,运行时 workspace 导入门禁通过。npm 官方 Low 级别依赖审计无已知漏洞。新增 D-021。
|
||||
|
||||
遗留:仍需在微信开发者工具和真机验收证书排版、长文本及不同屏幕尺寸;正式证书法律文案、机构签章/品牌素材如需替换,后续通过新规则版本发布。真实微信 AppID/AppSecret、合法 HTTPS 域名、生产对象存储和真实环境联调仍按生产就绪计划后期提供。
|
||||
|
||||
---
|
||||
|
||||
2026-08-15
|
||||
|
||||
任务:旧物善用独立模块 Phase 2 物资全流程台账。
|
||||
|
||||
完成:新增独立物资导入批次、物资台账和只追加流转事件模型,完成手工入库、最多 500 行原子批量入库、在库/分拣/消毒/出库/帮扶落地严格状态机、物资需求/受助对象/帮扶区域绑定、完整详情、revision 乐观锁、幂等重放、加密备注、事务审计和需求/捐赠完成同步。管理端新增台账页、固定格式 `.xlsx` 模板下载、本地解析与强校验、Excel 导出、打印专用版式和详情推进;小程序捐赠详情新增脱敏物资溯源。新增四项独立权限,导出不含个人字段或内部备注。
|
||||
|
||||
范围:仅完成 D-019 已批准的 Phase 2。未实现证书、积分、现金捐赠、支付、自动扣款或第三方物流。原始 Excel 不上传或持久化;异常修正不允许覆盖历史,法务数据保留期限和正式修正流程继续列为生产人工确认项。
|
||||
|
||||
验证:Phase 2 migration 已部署到专用 `lehuo_test`,共 11 个 migration,最终重复 deploy 无待执行项;全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过。新增用例覆盖手工入库幂等、禁止跳级、完整五节点状态机、原子批量导入重放、最小化导出和审计;小程序页面完整性门禁通过 26 个页面,运行时 workspace 导入门禁通过。ExcelJS 改为按需加载,管理端首屏 JS 为 241.79 kB;npm 官方 Low 级别依赖审计最终无已知漏洞。
|
||||
|
||||
遗留:仍需用微信开发者工具/真机和实际机构 Excel 样本做视觉及兼容验收;真实微信、HTTPS 域名、生产对象存储和法务数据保留期限仍按生产就绪计划后期提供。Phase 3 证书积分等待下一步明确指令。
|
||||
|
||||
---
|
||||
|
||||
2026-08-15
|
||||
|
||||
任务:旧物善用独立模块 Phase 1 核心捐赠闭环。
|
||||
|
||||
完成:依据用户四项明确批准新增 D-019 与独立旧物善用域,不包含现金捐赠或任何支付。小程序首页及个人中心增加入口,完成模块主页、真实统计、物资需求、定向捐赠、最多六图、上门取件/定点投递、我的记录、详情溯源和规范指南;公益动态复用既有审核发布内容。管理端新增一级入口及订单审核、人工取件人员选择、状态处理、私有图片短期预览、需求草稿/发布/下架、投递点维护和后续阶段预留说明。后端新增三项权限、随机公开 ID/非连续公益编号、敏感字段加密、域隔离手机号 HMAC、私有对象存储、本人资源隔离、管理端敏感读取审计、创建/状态幂等、revision 乐观锁、事务状态历史和真实事实统计。
|
||||
|
||||
范围:本次只完成已批准的第一阶段。物资入库/分拣/消毒/出库全流程台账与 Excel 导入导出打印留到 Phase 2;证书和积分留到 Phase 3;不接第三方物流,取件由后台人工分配。未加入现金捐赠、微信/支付宝/银行卡支付或自动扣款,也未用模拟数据冒充业务事实。
|
||||
|
||||
验证:新增 migration 已部署到专用 `lehuo_test`,共 10 个 migration,重复 deploy 无待执行项;Lint、全仓类型检查、28 个单元测试、37 个真实 MySQL 集成测试、格式检查和全仓生产构建通过。新增旧物闭环集成测试覆盖需求发布、定向捐赠幂等、人工分配、接收、分拣、完成、需求数量更新、事实统计、不可变历史和审计。npm 官方高危依赖审计无已知漏洞。
|
||||
|
||||
遗留:真实微信 AppID/AppSecret、合法 HTTPS 域名、生产私有对象存储 Bucket/CORS 和微信开发者工具真机视觉/上传验收仍按既有计划后期提供。Phase 2/3 必须等待下一步指令,不得提前实施。
|
||||
|
||||
---
|
||||
|
||||
2026-08-13
|
||||
|
||||
任务:财务公开文档发布模块。
|
||||
@@ -140,7 +210,7 @@
|
||||
|
||||
## 当前正在处理
|
||||
|
||||
Phase 6 及 Phase 7 可自动化开发与本地生产工程验证均已完成。Phase 7 的 staging、UAT 和 production deployment 仍需真实环境、微信配置、合规材料及人工审批,保持未完成;当前不得宣称已生产上线。
|
||||
小程序首页主功能区与便捷入口调整已完成代码和自动化验证;当前等待用户执行微信开发者工具/真机视觉验收。原 Phase 7 的 staging、UAT 和 production deployment 仍需真实环境、微信配置、合规材料及人工审批,保持未完成;当前不得宣称已生产上线。
|
||||
|
||||
---
|
||||
|
||||
@@ -286,10 +356,10 @@ YYYY-MM-DD
|
||||
|
||||
## 已知问题
|
||||
|
||||
无已知 Phase 5 代码问题。生产阻断项和人工配置见 `17_PRODUCTION_READINESS_PLAN.md`。
|
||||
无已知本次首页入口调整代码问题。首页 3×2 六宫格、四张帮扶分类图片的缩略裁切及公众文案仍需微信开发者工具和真机做最终视觉验收;生产阻断项和人工配置见 `17_PRODUCTION_READINESS_PLAN.md`。
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
等待用户提供或确认外部生产资源后,依次执行 staging 全闭环、非开发人员 UAT、真实微信联调和 production deployment;不得跳过发布清单或审批。
|
||||
等待用户在微信开发者工具清缓存重新编译并真机验收首页 3×2 六宫格、财务公开与服务进度入口、便捷入口精简、帮扶服务六分类图片及相关公众文案。生产发布仍需依次执行 staging 全闭环、非开发人员 UAT、真实微信联调和 production deployment,不得跳过发布清单或审批。
|
||||
|
||||
@@ -223,6 +223,68 @@ DOCX 仅在管理端浏览器转换,API 保存前对富文本执行服务端
|
||||
|
||||
---
|
||||
|
||||
## D-019 旧物善用采用独立域并分三阶段交付
|
||||
|
||||
状态:ACCEPTED
|
||||
|
||||
决定:用户于 2026-08-15 明确批准在 D-017 原延期边界之外新增“旧物善用”独立模块,但继续排除现金捐赠、微信/支付宝/银行卡支付和自动扣款。模块沿用现有账号、登录态和安全基线,新增独立路由、后台菜单、权限、API 和数据表,不修改既有服务申请与公益项目状态机。
|
||||
|
||||
交付分三阶段:第一阶段实现真实旧物捐赠提交、最多六张私有图片、上门取件或定点投递、后台人工审核与人员分配边界、状态溯源、物资需求发布与定向捐赠、真实事实统计;第二阶段实现入库/分拣/消毒/出库/帮扶落地台账及 Excel 导入导出打印;第三阶段实现电子证书和公益积分。第一阶段不接第三方物流,取件人员由后台人工安排;未实施能力只保留模块边界,不显示可操作伪入口。
|
||||
|
||||
捐赠状态为 `SUBMITTED → WAITING_PICKUP/WAITING_DROP_OFF → RECEIVED → PROCESSING → COMPLETED`,并允许审核驳回与接收前取消。需求状态为 `DRAFT → PUBLISHED → FULFILLED/CLOSED`。所有创建和状态变更使用客户端 UUID 幂等、revision 乐观锁、不可变历史和事务审计。联系人、手机号、取件地址、物品描述、驳回原因加密保存;手机号另存域隔离 HMAC;公开资源使用随机 UUID 和非连续公益编号。统计只从已接收/已完成事实计算,不允许人工覆盖。
|
||||
|
||||
原因:满足用户新增的实物公益闭环,同时把物流、仓储台账、证书积分与支付等风险不同的能力拆开交付,避免以空页面、模拟数据或未审计状态冒充完成。
|
||||
|
||||
---
|
||||
|
||||
## D-020 旧物台账采用原子批量入库与只追加流转事件
|
||||
|
||||
状态:ACCEPTED
|
||||
|
||||
决定:旧物善用 Phase 2 使用一条物资批次主记录配合只追加流转事件,状态固定为 `IN_STOCK → SORTED → DISINFECTED → OUTBOUND → DELIVERED`,禁止跳级、回退和覆盖历史。来源捐赠最多绑定一个物资批次;关联台账在分拣和落地时同步捐赠处理/完成事实,定向需求满足数量只在落地事务中增加。
|
||||
|
||||
Excel 在管理端浏览器本地读取和生成,API 只接收固定结构、最多 500 行的已解析数据,不保存或执行原始工作簿。导入批次、全部物资行、入库事件和审计全有或全无;导出使用独立权限、最多 5000 条且排除个人字段和内部备注。小程序只公开状态、时间及出库/落地的粗粒度地区。
|
||||
|
||||
原因:防止部分导入、重复入库、越级完成、覆盖审计事实和表格携带宏/公式进入服务端,同时使物资去向可追溯但不扩大受助对象隐私暴露面。
|
||||
|
||||
---
|
||||
|
||||
## D-021 旧物善用积分仅作为公益荣誉记录
|
||||
|
||||
状态:ACCEPTED
|
||||
|
||||
决定:Phase 3 的积分只记录旧物捐赠参与,不具有现金价值,不支持兑换、购买、转让、提现、到期或排行。默认每件物资 1 分、单笔最多 500 分;管理端可激活不可变规则版本,按四类物资配置未来计分,但不得覆盖既有流水。自动计分只发生在捐赠真实进入 `COMPLETED` 时,并与证书、状态历史和审计在同一事务提交;证书补发不得重复计分。人工校正必须填写原因、校验 revision、写只追加流水且不得产生负余额。
|
||||
|
||||
证书使用纯文本模板和固定占位符,每次签发保存完整快照。新模板只影响未来证书;补发会撤销但不删除旧证书。证书收件人称呼加密保存,仅本人会话可查看,不提供匿名公开核验。法律声明固定明确证书仅记录公益参与,不是捐赠收据、税务凭证或财产权利证明。既有已完成捐赠不自动批量回填,必要时由授权人员依据真实记录补发并审计。
|
||||
|
||||
原因:在满足公益激励与纪念需求的同时,避免积分被误解为支付、资产或消费体系,防止规则变更重写历史,并保证完成事实、荣誉凭证和积分账目一致可追溯。
|
||||
|
||||
---
|
||||
|
||||
## D-022 公众端使用“帮扶服务”名称并收敛首页入口
|
||||
|
||||
状态:ACCEPTED
|
||||
|
||||
决定:按用户 2026-08-15 的明确要求,小程序公众端将“服务申请”统一展示为“帮扶服务”,提交内容称为“帮扶需求”;首页移除关爱老人、困境儿童、妇女帮扶和残障救助四个重复分类快捷按钮,改为一个高权重帮扶服务主入口与志愿服务、公益项目、公益动态、旧物善用四个次级入口。六类具体服务仍在帮扶服务选择页完整保留。
|
||||
|
||||
本次仅调整公众信息架构、视觉资源和用户文案。为保持已上线契约兼容,内部 `ServiceApplication` 数据模型、API 路径、共享枚举、后台业务名称和状态机不改名。新增四张虚构 AI 场景图只用于分类识别,不构成真实公益服务或成果证明。
|
||||
|
||||
原因:首页分类入口与下级选择重复且同级入口过多;“帮扶服务”更直接表达群众可以获得的服务,同时避免因纯展示改名引入数据库/API 迁移风险。
|
||||
|
||||
---
|
||||
|
||||
## D-023 首页主功能区补充财务公开与服务进度
|
||||
|
||||
状态:ACCEPTED
|
||||
|
||||
决定:按用户 2026-08-15 的明确要求,小程序首页“帮扶服务”下方主功能区由四项调整为 3×2 六宫格,保留志愿服务、公益项目、公益动态和旧物善用,并加入财务公开与服务进度。财务公开进入既有公开文档列表,服务进度进入本人帮扶服务列表,不新增占位页或新业务接口。
|
||||
|
||||
服务进度从下方便捷入口上移,避免同页重复;便捷入口取消旧物善用,仅保留公益活动,以及具备授权时显示的协作任务。旧物善用模块本身及首页主功能入口继续保留,不删除既有业务能力。
|
||||
|
||||
原因:提高财务透明内容和个人服务跟进的首页可达性,同时减少便捷入口重复和冗余;3×2 布局可保持适老化点击面积与清晰文字标签。
|
||||
|
||||
---
|
||||
|
||||
## 待确认建议
|
||||
|
||||
Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施:
|
||||
|
||||
@@ -379,32 +379,33 @@ CTA 不超过 1 个。
|
||||
|
||||
---
|
||||
|
||||
## 6.6 服务分类
|
||||
## 6.6 首页功能栏目
|
||||
|
||||
四个核心:
|
||||
一个高权重主入口:
|
||||
|
||||
```text
|
||||
关爱老人
|
||||
困境妇女
|
||||
儿童关爱
|
||||
残障帮扶
|
||||
帮扶服务
|
||||
```
|
||||
|
||||
补充:
|
||||
六个次级入口,使用 3×2 布局:
|
||||
|
||||
```text
|
||||
志愿服务
|
||||
公益项目
|
||||
爱心活动
|
||||
更多服务
|
||||
公益动态
|
||||
旧物善用
|
||||
财务公开
|
||||
服务进度
|
||||
```
|
||||
|
||||
每项:
|
||||
具体服务类别进入帮扶服务后选择,不在首页重复展示。每项:
|
||||
|
||||
- 图标;
|
||||
- 文字;
|
||||
- 点击区域 >= 48px。
|
||||
|
||||
“服务进度”在主功能区提供入口后,不在下方便捷入口重复展示;便捷入口不展示“旧物善用”。
|
||||
|
||||
---
|
||||
|
||||
## 6.7 服务进度
|
||||
@@ -446,7 +447,7 @@ CTA 不超过 1 个。
|
||||
|
||||
---
|
||||
|
||||
# 七、服务申请页面
|
||||
# 七、帮扶服务页面(内部模型:服务申请)
|
||||
|
||||
## 7.1 页面原则
|
||||
|
||||
@@ -802,7 +803,7 @@ V1 中证书/荣誉可先预留,不强做。
|
||||
V1 建议优先:
|
||||
|
||||
```text
|
||||
我的服务申请
|
||||
我的帮扶服务
|
||||
我的活动
|
||||
我的志愿服务
|
||||
我的消息
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 18 旧物善用分阶段实施计划
|
||||
|
||||
## 已批准边界
|
||||
|
||||
2026-08-15 用户批准新增独立“旧物善用”模块;不包含现金捐赠、微信/支付宝/银行卡支付或自动扣款。沿用现有登录、权限、安全、对象存储和审计架构。
|
||||
|
||||
## Phase 1:核心捐赠闭环(已完成)
|
||||
|
||||
- 小程序首页与个人中心入口、模块主页、真实统计、需求公示、指南和公益动态复用。
|
||||
- 捐赠提交:四类物品、新旧程度、数量、描述、最多六图、上门取件或定点投递、定向需求。
|
||||
- 我的记录:状态筛选、公益编号、详情与不可变时间线。
|
||||
- 管理端:订单列表/详情、审核通过/驳回、人工取件边界、接收、分拣、完成帮扶;需求草稿、发布和下架。
|
||||
- 安全:资源归属、独立权限、敏感字段加密、幂等、乐观锁、事务状态历史和审计。
|
||||
|
||||
## Phase 2:物资全流程台账(已完成)
|
||||
|
||||
已实现手工入库、标准 Excel 模板下载与最多 500 行原子批量入库、分拣、消毒、出库、绑定物资需求/受助对象/帮扶区域、帮扶落地、完整流转详情、最小化 Excel 导出和打印。小程序捐赠详情展示脱敏后的物资流转节点,不展示内部仓位、操作人员、受助对象姓名或联系方式。
|
||||
|
||||
台账模板采用固定中文表头,品类带下拉校验,数量为整数、日期使用真实日期单元格并冻结表头;浏览器本地读取 `.xlsx` 后将结构化数据提交 API,API 不持久化原始 Excel。导入批次、每条入库记录和所有状态变化均幂等、事务化并审计;任何一行失败时整批回滚,不产生部分成功数据。已落地节点不允许覆盖或回退,法务数据保留期限和异常修正流程仍作为生产人工确认项保留,不以删除历史或直接改库替代。
|
||||
|
||||
## Phase 3:证书与公益荣誉积分(已完成)
|
||||
|
||||
- 捐赠进入 `COMPLETED` 时,在同一数据库事务生成电子荣誉证书、积分账户及只追加积分流水;失败则捐赠完成状态一并回滚。
|
||||
- 管理端支持激活不可变版本的证书模板与按品类积分规则、证书补发/撤销、积分有理由人工校正;新模板只作用于未来证书,旧证书保存完整快照。
|
||||
- 小程序支持本人查看积分余额、只追加流水、证书列表与证书详情;其他用户不能读取猜测到的证书公开 ID。
|
||||
- 默认规则为每件 1 分、单笔最多 500 分;积分仅记录公益荣誉,不可兑换、购买、转让、提现或参与排行,不设现金价值。
|
||||
- 证书明确声明仅记录公益参与,不是捐赠收据、税务凭证或财产权利证明;收件人称呼加密保存,管理端列表不展示明文。
|
||||
- 既有已完成捐赠不自动批量回填;管理人员可按真实完成事实补发证书,历史积分如需补录只能通过有理由、可审计的校正流水处理。
|
||||
|
||||
## 外部配置
|
||||
|
||||
真实微信 AppID/AppSecret、合法 HTTPS 域名、小程序隐私声明、对象存储生产 Bucket/CORS 和真机联调仍按生产就绪计划后期提供。本阶段不使用测试配置冒充外部联调完成。
|
||||
@@ -54,6 +54,15 @@ export const PERMISSION_CODES = {
|
||||
NOTIFICATIONS_MANAGE: 'notifications:manage',
|
||||
STATISTICS_READ: 'statistics:read',
|
||||
EXPORTS_CREATE: 'exports:create',
|
||||
OLD_ITEMS_READ: 'old_items:read',
|
||||
OLD_ITEMS_MANAGE: 'old_items:manage',
|
||||
OLD_ITEM_NEEDS_MANAGE: 'old_item_needs:manage',
|
||||
OLD_ITEM_INVENTORY_READ: 'old_item_inventory:read',
|
||||
OLD_ITEM_INVENTORY_MANAGE: 'old_item_inventory:manage',
|
||||
OLD_ITEM_INVENTORY_IMPORT: 'old_item_inventory:import',
|
||||
OLD_ITEM_INVENTORY_EXPORT: 'old_item_inventory:export',
|
||||
OLD_ITEM_REWARDS_READ: 'old_item_rewards:read',
|
||||
OLD_ITEM_REWARDS_MANAGE: 'old_item_rewards:manage',
|
||||
} as const;
|
||||
export type PermissionCode = (typeof PERMISSION_CODES)[keyof typeof PERMISSION_CODES];
|
||||
|
||||
|
||||
@@ -26,6 +26,60 @@ export {
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
} from './service-applications.js';
|
||||
export {
|
||||
OLD_ITEM_CATEGORIES,
|
||||
OLD_ITEM_CATEGORY_LABELS,
|
||||
OLD_ITEM_CONDITIONS,
|
||||
OLD_ITEM_CONDITION_LABELS,
|
||||
OLD_ITEM_DONATION_METHODS,
|
||||
OLD_ITEM_DONATION_STATUSES,
|
||||
OLD_ITEM_DONATION_STATUS_LABELS,
|
||||
OLD_ITEM_NEED_STATUSES,
|
||||
OLD_ITEM_INVENTORY_STATUSES,
|
||||
OLD_ITEM_INVENTORY_STATUS_LABELS,
|
||||
OLD_ITEM_INVENTORY_EVENT_TYPES,
|
||||
OLD_ITEM_CERTIFICATE_STATUSES,
|
||||
OLD_ITEM_POINT_ENTRY_TYPES,
|
||||
} from './old-items.js';
|
||||
export type {
|
||||
OldItemCategory,
|
||||
OldItemCondition,
|
||||
OldItemDonationMethod,
|
||||
OldItemDonationStatus,
|
||||
OldItemNeedStatus,
|
||||
OldItemStats,
|
||||
OldItemDropOffPoint,
|
||||
OldItemNeedItem,
|
||||
OldItemDonationHistoryItem,
|
||||
OldItemDonationItem,
|
||||
OldItemAssigneeOption,
|
||||
CreateOldItemDonationRequest,
|
||||
TransitionOldItemDonationRequest,
|
||||
CreateOldItemNeedRequest,
|
||||
UpdateOldItemNeedRequest,
|
||||
TransitionOldItemNeedRequest,
|
||||
OldItemInventoryStatus,
|
||||
OldItemInventoryEventType,
|
||||
OldItemPublicInventoryEvent,
|
||||
OldItemInventoryEventItem,
|
||||
OldItemInventoryItem,
|
||||
CreateOldItemInventoryRequest,
|
||||
OldItemInventoryImportRow,
|
||||
ImportOldItemInventoryRequest,
|
||||
TransitionOldItemInventoryRequest,
|
||||
OldItemInventoryExportRow,
|
||||
OldItemCertificateStatus,
|
||||
OldItemPointEntryType,
|
||||
OldItemRewardConfig,
|
||||
OldItemCertificateItem,
|
||||
OldItemPointEntryItem,
|
||||
OldItemRewardsSummary,
|
||||
OldItemPointAccountItem,
|
||||
ActivateOldItemRewardConfigRequest,
|
||||
IssueOldItemCertificateRequest,
|
||||
RevokeOldItemCertificateRequest,
|
||||
AdjustOldItemPointsRequest,
|
||||
} from './old-items.js';
|
||||
export type { StatisticsOverview } from './statistics.js';
|
||||
export {
|
||||
FINANCIAL_DISCLOSURE_STATUSES,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
export const OLD_ITEM_CATEGORIES = ['CLOTHING', 'BOOKS', 'HOUSEHOLD', 'SPORTS'] as const;
|
||||
export type OldItemCategory = (typeof OLD_ITEM_CATEGORIES)[number];
|
||||
export const OLD_ITEM_CATEGORY_LABELS: Record<OldItemCategory, string> = {
|
||||
CLOTHING: '服饰',
|
||||
BOOKS: '书籍',
|
||||
HOUSEHOLD: '生活用品',
|
||||
SPORTS: '文体用品',
|
||||
};
|
||||
export const OLD_ITEM_CONDITIONS = ['NEW', 'LIKE_NEW', 'GOOD', 'FAIR'] as const;
|
||||
export type OldItemCondition = (typeof OLD_ITEM_CONDITIONS)[number];
|
||||
export const OLD_ITEM_CONDITION_LABELS: Record<OldItemCondition, string> = {
|
||||
NEW: '全新',
|
||||
LIKE_NEW: '九成新',
|
||||
GOOD: '使用良好',
|
||||
FAIR: '可正常使用',
|
||||
};
|
||||
export const OLD_ITEM_DONATION_METHODS = ['PICKUP', 'DROP_OFF'] as const;
|
||||
export type OldItemDonationMethod = (typeof OLD_ITEM_DONATION_METHODS)[number];
|
||||
export const OLD_ITEM_DONATION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'REJECTED',
|
||||
'WAITING_PICKUP',
|
||||
'WAITING_DROP_OFF',
|
||||
'RECEIVED',
|
||||
'PROCESSING',
|
||||
'COMPLETED',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type OldItemDonationStatus = (typeof OLD_ITEM_DONATION_STATUSES)[number];
|
||||
export const OLD_ITEM_DONATION_STATUS_LABELS: Record<OldItemDonationStatus, string> = {
|
||||
SUBMITTED: '待审核',
|
||||
REJECTED: '已驳回',
|
||||
WAITING_PICKUP: '待取件',
|
||||
WAITING_DROP_OFF: '待投递',
|
||||
RECEIVED: '已接收',
|
||||
PROCESSING: '分拣中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
export const OLD_ITEM_NEED_STATUSES = ['DRAFT', 'PUBLISHED', 'FULFILLED', 'CLOSED'] as const;
|
||||
export type OldItemNeedStatus = (typeof OLD_ITEM_NEED_STATUSES)[number];
|
||||
export const OLD_ITEM_INVENTORY_STATUSES = [
|
||||
'IN_STOCK',
|
||||
'SORTED',
|
||||
'DISINFECTED',
|
||||
'OUTBOUND',
|
||||
'DELIVERED',
|
||||
] as const;
|
||||
export type OldItemInventoryStatus = (typeof OLD_ITEM_INVENTORY_STATUSES)[number];
|
||||
export const OLD_ITEM_INVENTORY_STATUS_LABELS: Record<OldItemInventoryStatus, string> = {
|
||||
IN_STOCK: '已入库',
|
||||
SORTED: '已分拣',
|
||||
DISINFECTED: '已消毒',
|
||||
OUTBOUND: '已出库',
|
||||
DELIVERED: '已落地',
|
||||
};
|
||||
export const OLD_ITEM_INVENTORY_EVENT_TYPES = [
|
||||
'INBOUND',
|
||||
'SORTED',
|
||||
'DISINFECTED',
|
||||
'OUTBOUND',
|
||||
'DELIVERED',
|
||||
] as const;
|
||||
export type OldItemInventoryEventType = (typeof OLD_ITEM_INVENTORY_EVENT_TYPES)[number];
|
||||
export const OLD_ITEM_CERTIFICATE_STATUSES = ['ISSUED', 'REVOKED'] as const;
|
||||
export type OldItemCertificateStatus = (typeof OLD_ITEM_CERTIFICATE_STATUSES)[number];
|
||||
export const OLD_ITEM_POINT_ENTRY_TYPES = ['EARNED', 'ADJUSTMENT'] as const;
|
||||
export type OldItemPointEntryType = (typeof OLD_ITEM_POINT_ENTRY_TYPES)[number];
|
||||
|
||||
export interface OldItemStats {
|
||||
totalItems: number;
|
||||
assistedPeople: number;
|
||||
monthlyKindActs: number;
|
||||
}
|
||||
export interface OldItemDropOffPoint {
|
||||
publicId: string;
|
||||
name: string;
|
||||
regionText: string;
|
||||
addressText: string;
|
||||
openingHours: string;
|
||||
}
|
||||
export interface OldItemNeedItem {
|
||||
publicId: string;
|
||||
title: string;
|
||||
category: OldItemCategory;
|
||||
regionText: string;
|
||||
beneficiaryGroup: string;
|
||||
description: string;
|
||||
requestedQuantity: number;
|
||||
fulfilledQuantity: number;
|
||||
remainingQuantity: number;
|
||||
priority: number;
|
||||
status: OldItemNeedStatus;
|
||||
revision: number;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
export interface OldItemDonationHistoryItem {
|
||||
fromStatus: OldItemDonationStatus | null;
|
||||
toStatus: OldItemDonationStatus;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface OldItemDonationItem {
|
||||
publicId: string;
|
||||
donationNo: string;
|
||||
category: OldItemCategory;
|
||||
itemCondition: OldItemCondition;
|
||||
quantity: number;
|
||||
description: string;
|
||||
method: OldItemDonationMethod;
|
||||
status: OldItemDonationStatus;
|
||||
targetNeed: Pick<OldItemNeedItem, 'publicId' | 'title'> | null;
|
||||
dropOffPoint: OldItemDropOffPoint | null;
|
||||
appointmentAt: string | null;
|
||||
contactName: string;
|
||||
mobileMasked: string;
|
||||
address: string | null;
|
||||
assigneeName: string | null;
|
||||
rejectionReason: string | null;
|
||||
assistedPeople: number;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
history: OldItemDonationHistoryItem[];
|
||||
imagePublicIds: string[];
|
||||
inventoryTrace: OldItemPublicInventoryEvent[];
|
||||
}
|
||||
export interface OldItemAssigneeOption {
|
||||
publicId: string;
|
||||
displayName: string;
|
||||
}
|
||||
export interface CreateOldItemDonationRequest {
|
||||
clientRequestId: string;
|
||||
category: OldItemCategory;
|
||||
itemCondition: OldItemCondition;
|
||||
quantity: number;
|
||||
description: string;
|
||||
method: OldItemDonationMethod;
|
||||
contactName: string;
|
||||
mobile: string;
|
||||
address?: string;
|
||||
appointmentAt?: string;
|
||||
dropOffPointPublicId?: string;
|
||||
targetNeedPublicId?: string;
|
||||
imagePublicIds?: string[];
|
||||
}
|
||||
export interface TransitionOldItemDonationRequest {
|
||||
clientRequestId: string;
|
||||
expectedRevision: number;
|
||||
targetStatus: OldItemDonationStatus;
|
||||
note?: string;
|
||||
assignedUserPublicId?: string;
|
||||
assistedPeople?: number;
|
||||
}
|
||||
export interface CreateOldItemNeedRequest {
|
||||
clientRequestId: string;
|
||||
title: string;
|
||||
category: OldItemCategory;
|
||||
regionText: string;
|
||||
beneficiaryGroup: string;
|
||||
description: string;
|
||||
requestedQuantity: number;
|
||||
priority?: number;
|
||||
sortOrder?: number;
|
||||
}
|
||||
export interface UpdateOldItemNeedRequest extends Omit<
|
||||
CreateOldItemNeedRequest,
|
||||
'clientRequestId'
|
||||
> {
|
||||
expectedRevision: number;
|
||||
}
|
||||
export interface TransitionOldItemNeedRequest {
|
||||
clientRequestId: string;
|
||||
expectedRevision: number;
|
||||
targetStatus: OldItemNeedStatus;
|
||||
}
|
||||
|
||||
export interface OldItemPublicInventoryEvent {
|
||||
status: OldItemInventoryStatus;
|
||||
locationText: string | null;
|
||||
eventAt: string;
|
||||
}
|
||||
export interface OldItemInventoryEventItem extends OldItemPublicInventoryEvent {
|
||||
eventType: OldItemInventoryEventType;
|
||||
fromStatus: OldItemInventoryStatus | null;
|
||||
note: string | null;
|
||||
operatorName: string;
|
||||
}
|
||||
export interface OldItemInventoryItem {
|
||||
publicId: string;
|
||||
inventoryNo: string;
|
||||
sourceDonation: { publicId: string; donationNo: string } | null;
|
||||
category: OldItemCategory;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storageLocation: string;
|
||||
status: OldItemInventoryStatus;
|
||||
targetNeed: Pick<OldItemNeedItem, 'publicId' | 'title'> | null;
|
||||
beneficiaryPublicId: string | null;
|
||||
destinationRegion: string | null;
|
||||
note: string | null;
|
||||
landingAt: string | null;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
events: OldItemInventoryEventItem[];
|
||||
}
|
||||
export interface CreateOldItemInventoryRequest {
|
||||
clientRequestId: string;
|
||||
sourceDonationPublicId?: string;
|
||||
category: OldItemCategory;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storageLocation: string;
|
||||
targetNeedPublicId?: string;
|
||||
note?: string;
|
||||
inboundAt?: string;
|
||||
}
|
||||
export interface OldItemInventoryImportRow {
|
||||
sourceDonationNo?: string;
|
||||
category: OldItemCategory;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storageLocation: string;
|
||||
targetNeedPublicId?: string;
|
||||
note?: string;
|
||||
inboundAt?: string;
|
||||
}
|
||||
export interface ImportOldItemInventoryRequest {
|
||||
clientRequestId: string;
|
||||
originalFilename: string;
|
||||
rows: OldItemInventoryImportRow[];
|
||||
}
|
||||
export interface TransitionOldItemInventoryRequest {
|
||||
clientRequestId: string;
|
||||
expectedRevision: number;
|
||||
targetStatus: OldItemInventoryStatus;
|
||||
eventAt?: string;
|
||||
locationText?: string;
|
||||
note?: string;
|
||||
targetNeedPublicId?: string;
|
||||
beneficiaryPublicId?: string;
|
||||
destinationRegion?: string;
|
||||
assistedPeople?: number;
|
||||
}
|
||||
export interface OldItemInventoryExportRow {
|
||||
inventoryNo: string;
|
||||
sourceDonationNo: string;
|
||||
category: OldItemCategory;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
storageLocation: string;
|
||||
status: OldItemInventoryStatus;
|
||||
targetNeedTitle: string;
|
||||
beneficiaryPublicId: string;
|
||||
destinationRegion: string;
|
||||
landingAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OldItemRewardConfig {
|
||||
publicId: string | null;
|
||||
version: number;
|
||||
certificateTitle: string;
|
||||
certificateMessage: string;
|
||||
issuerName: string;
|
||||
legalNotice: string;
|
||||
clothingPoints: number;
|
||||
booksPoints: number;
|
||||
householdPoints: number;
|
||||
sportsPoints: number;
|
||||
maxPointsPerDonation: number;
|
||||
createdAt: string | null;
|
||||
}
|
||||
export interface OldItemCertificateItem {
|
||||
publicId: string;
|
||||
certificateNo: string;
|
||||
donationPublicId: string;
|
||||
donationNo: string;
|
||||
status: OldItemCertificateStatus;
|
||||
recipientName: string;
|
||||
title: string;
|
||||
message: string;
|
||||
issuerName: string;
|
||||
legalNotice: string;
|
||||
issuedAt: string;
|
||||
revokedAt: string | null;
|
||||
revokeReason: string | null;
|
||||
}
|
||||
export interface OldItemPointEntryItem {
|
||||
publicId: string;
|
||||
entryType: OldItemPointEntryType;
|
||||
pointsDelta: number;
|
||||
balanceAfter: number;
|
||||
donationNo: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface OldItemRewardsSummary {
|
||||
balance: number;
|
||||
certificates: OldItemCertificateItem[];
|
||||
entries: OldItemPointEntryItem[];
|
||||
}
|
||||
export interface OldItemPointAccountItem {
|
||||
publicId: string;
|
||||
userPublicId: string;
|
||||
displayName: string;
|
||||
balance: number;
|
||||
revision: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface ActivateOldItemRewardConfigRequest {
|
||||
clientRequestId: string;
|
||||
certificateTitle: string;
|
||||
certificateMessage: string;
|
||||
issuerName: string;
|
||||
legalNotice: string;
|
||||
clothingPoints: number;
|
||||
booksPoints: number;
|
||||
householdPoints: number;
|
||||
sportsPoints: number;
|
||||
maxPointsPerDonation: number;
|
||||
}
|
||||
export interface IssueOldItemCertificateRequest {
|
||||
clientRequestId: string;
|
||||
}
|
||||
export interface RevokeOldItemCertificateRequest {
|
||||
clientRequestId: string;
|
||||
reason: string;
|
||||
}
|
||||
export interface AdjustOldItemPointsRequest {
|
||||
clientRequestId: string;
|
||||
expectedRevision: number;
|
||||
pointsDelta: number;
|
||||
reason: string;
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export interface RecordServiceMinutesRequest {
|
||||
|
||||
export interface CreateMediaUploadIntentRequest {
|
||||
clientRequestId: string;
|
||||
businessType: 'PROJECT_COVER' | 'ACTIVITY_COVER';
|
||||
businessType: 'PROJECT_COVER' | 'ACTIVITY_COVER' | 'OLD_ITEM_DONATION_IMAGE';
|
||||
filename: string;
|
||||
mimeType: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||
size: number;
|
||||
|
||||
Generated
+426
@@ -5,8 +5,12 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@fast-csv/parse': 4.3.6
|
||||
bl: 4.0.3
|
||||
fast-csv: 4.3.6
|
||||
js-yaml: 5.2.2
|
||||
lodash: 4.18.1
|
||||
uuid: 11.1.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -39,6 +43,9 @@ importers:
|
||||
dompurify:
|
||||
specifier: ^3.4.13
|
||||
version: 3.4.13
|
||||
exceljs:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
mammoth:
|
||||
specifier: ^1.12.1
|
||||
version: 1.12.1
|
||||
@@ -496,6 +503,12 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -1033,6 +1046,9 @@ packages:
|
||||
'@types/methods@1.1.4':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/node@14.0.1':
|
||||
resolution: {integrity: sha512-FAYBGwC+W6F9+huFIDtn43cpy7+SzG+atzRiTfdp3inUKL2hXnd4rG8hylJLIh4+hqrQy1P17kvJByE/z825hA==}
|
||||
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
@@ -1300,6 +1316,14 @@ packages:
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
archiver@5.0.0:
|
||||
resolution: {integrity: sha512-AEWhJz6Yi6hWtN1Sqy/H4sZo/lLMJ/NftXxGaDy/TnOMmmjsRaZc/Ts+U4BsPoBQkuunTN6t8hk7iU9A+HBxLw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
argon2@0.45.1:
|
||||
resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==}
|
||||
engines: {node: '>=16.17.0'}
|
||||
@@ -1317,6 +1341,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -1337,9 +1364,19 @@ packages:
|
||||
better-result@2.10.0:
|
||||
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
binary@0.3.0:
|
||||
resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
|
||||
|
||||
birpc@2.3.0:
|
||||
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
|
||||
|
||||
bl@4.0.3:
|
||||
resolution: {integrity: sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==}
|
||||
|
||||
bluebird@3.4.7:
|
||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||
|
||||
@@ -1360,9 +1397,23 @@ packages:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer-indexof-polyfill@1.0.2:
|
||||
resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buffers@0.1.1:
|
||||
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
|
||||
engines: {node: '>=0.2.0'}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
@@ -1399,6 +1450,9 @@ packages:
|
||||
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
||||
|
||||
chalk@4.0.0:
|
||||
resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1434,6 +1488,10 @@ packages:
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
compress-commons@3.0.0:
|
||||
resolution: {integrity: sha512-FyDqr8TKX5/X0qo+aVfaZ+PVmNJHJeckFBlq8jZGSJOgnynhfifoyl24qaqdUdDIBe0EVTHByN6NAkqYvE/2Xg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -1489,6 +1547,13 @@ packages:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crc32-stream@3.0.1:
|
||||
resolution: {integrity: sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==}
|
||||
engines: {node: '>= 6.9.0'}
|
||||
|
||||
crc@3.8.0:
|
||||
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
||||
|
||||
cross-env@10.1.0:
|
||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -1650,6 +1715,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplexer2@0.1.4:
|
||||
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
@@ -1676,6 +1744,9 @@ packages:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
entities@4.2.0:
|
||||
resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -1779,6 +1850,10 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
exceljs@4.4.0:
|
||||
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1794,6 +1869,10 @@ packages:
|
||||
resolution: {integrity: sha512-u/MudsoQEgBUZgR5N1v87vEgybeVYus9VnDVaIkxkkGP2jt54naghQ3PCQHJiogS8U/GavZCUPFfx3Xkp+NaHw==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
fast-csv@4.3.6:
|
||||
resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
@@ -1875,11 +1954,22 @@ packages:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fstream@1.0.12:
|
||||
resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==}
|
||||
engines: {node: '>=0.6'}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -1910,6 +2000,10 @@ packages:
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.0:
|
||||
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1922,6 +2016,11 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@1.1.14:
|
||||
resolution: {integrity: sha512-JUrvoFoQbLZpOZilKTXZX2e1EV0DTnuG5vsRFNFv4mPf/mnYbwNAFw/5x0rxeyaJslIdObGSgTTsMnM/acRaVw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
deprecated: please upgrade to graceful-fs 4 for compatibility with current and future versions of Node.js
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@@ -1991,6 +2090,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -2074,6 +2177,10 @@ packages:
|
||||
launder@1.7.1:
|
||||
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2084,6 +2191,9 @@ packages:
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
listenercount@1.0.1:
|
||||
resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
|
||||
|
||||
load-esm@1.0.3:
|
||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||
engines: {node: '>=13.2.0'}
|
||||
@@ -2092,9 +2202,49 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.difference@4.5.0:
|
||||
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
||||
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
lodash.flatten@4.4.0:
|
||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||
|
||||
lodash.groupby@4.6.0:
|
||||
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
|
||||
lodash.isnil@4.0.0:
|
||||
resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isundefined@3.0.1:
|
||||
resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.union@4.6.0:
|
||||
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
|
||||
|
||||
lodash.uniq@4.5.0:
|
||||
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
@@ -2201,6 +2351,9 @@ packages:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -2208,6 +2361,10 @@ packages:
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2246,6 +2403,10 @@ packages:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2432,6 +2593,9 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readdir-glob@1.0.0:
|
||||
resolution: {integrity: sha512-km0DIcwQVZ1ZUhXhMWpF74/Wm5aFEd5/jDiVWF1Hkw2myPQovG8vCQ8+FQO2KXE9npQQvCnAMZhhWuUee4WcCQ==}
|
||||
|
||||
readdirp@5.1.1:
|
||||
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -2461,6 +2625,10 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rimraf@2.0.0:
|
||||
resolution: {integrity: sha512-ZBMvl3zW9cEVtEwA3Tqy7ALi0vL6+L8tr1asd3/b5qAZpaFdzgsB8U77itPuzF21f2QS2Ke3ChFo3ecdiMTnTg==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
|
||||
rimraf@5.0.5:
|
||||
resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2498,6 +2666,10 @@ packages:
|
||||
resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
saxes@5.0.1:
|
||||
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
@@ -2641,6 +2813,9 @@ packages:
|
||||
swagger-ui-dist@5.32.8:
|
||||
resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==}
|
||||
|
||||
tar-stream@2.1.2:
|
||||
resolution: {integrity: sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -2663,6 +2838,10 @@ packages:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -2671,6 +2850,9 @@ packages:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
traverse@0.3.9:
|
||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||
|
||||
ts-api-utils@2.5.0:
|
||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -2730,12 +2912,19 @@ packages:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
unzipper@0.10.11:
|
||||
resolution: {integrity: sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==}
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.4.2:
|
||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||
peerDependencies:
|
||||
@@ -2876,9 +3065,16 @@ packages:
|
||||
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
zeptomatch@2.1.0:
|
||||
resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==}
|
||||
|
||||
zip-stream@4.0.0:
|
||||
resolution: {integrity: sha512-JhB4OLtlalLz1jXW7o+HcpEZA0LMVL2sumEaCJKrbRyug2z3Ki1xuqLg1yGUsnFMFdLmOkbwT4a67UavcgcThw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@aws-sdk/checksums@3.1000.26':
|
||||
@@ -3206,6 +3402,25 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
dependencies:
|
||||
'@types/node': 14.0.1
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
dependencies:
|
||||
'@types/node': 14.0.1
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.groupby: 4.6.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
lodash.isundefined: 3.0.1
|
||||
lodash.uniq: 4.5.0
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -3686,6 +3901,8 @@ snapshots:
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
|
||||
'@types/node@14.0.1': {}
|
||||
|
||||
'@types/node@24.13.3':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
@@ -4087,6 +4304,29 @@ snapshots:
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
dependencies:
|
||||
glob: 7.2.0
|
||||
graceful-fs: 4.2.11
|
||||
lazystream: 1.0.1
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.difference: 4.5.0
|
||||
lodash.flatten: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.union: 4.6.0
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 2.3.8
|
||||
|
||||
archiver@5.0.0:
|
||||
dependencies:
|
||||
archiver-utils: 2.1.0
|
||||
async: 3.2.6
|
||||
buffer-crc32: 0.2.13
|
||||
readable-stream: 3.6.2
|
||||
readdir-glob: 1.0.0
|
||||
tar-stream: 2.1.2
|
||||
zip-stream: 4.0.0
|
||||
|
||||
argon2@0.45.1:
|
||||
dependencies:
|
||||
'@phc/format': 1.0.0
|
||||
@@ -4105,6 +4345,8 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
@@ -4117,8 +4359,21 @@ snapshots:
|
||||
|
||||
better-result@2.10.0: {}
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
binary@0.3.0:
|
||||
dependencies:
|
||||
buffers: 0.1.1
|
||||
chainsaw: 0.1.0
|
||||
|
||||
birpc@2.3.0: {}
|
||||
|
||||
bl@4.0.3:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
@@ -4150,8 +4405,19 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer-indexof-polyfill@1.0.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffers@0.1.1: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
@@ -4195,6 +4461,10 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
pathval: 2.0.1
|
||||
|
||||
chainsaw@0.1.0:
|
||||
dependencies:
|
||||
traverse: 0.3.9
|
||||
|
||||
chalk@4.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.1.0
|
||||
@@ -4228,6 +4498,13 @@ snapshots:
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
compress-commons@3.0.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
crc32-stream: 3.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 2.3.8
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
@@ -4271,6 +4548,15 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc32-stream@3.0.1:
|
||||
dependencies:
|
||||
crc: 3.8.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
crc@3.8.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
|
||||
cross-env@10.1.0:
|
||||
dependencies:
|
||||
'@epic-web/invariant': 1.0.0
|
||||
@@ -4419,6 +4705,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplexer2@0.1.4:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
@@ -4438,6 +4728,10 @@ snapshots:
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
entities@4.2.0: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
@@ -4574,6 +4868,18 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
exceljs@4.4.0:
|
||||
dependencies:
|
||||
archiver: 5.0.0
|
||||
dayjs: 1.11.21
|
||||
fast-csv: 4.3.6
|
||||
jszip: 3.10.1
|
||||
readable-stream: 3.6.2
|
||||
saxes: 5.0.1
|
||||
tmp: 0.2.7
|
||||
unzipper: 0.10.11
|
||||
uuid: 11.1.1
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express@5.2.1:
|
||||
@@ -4615,6 +4921,11 @@ snapshots:
|
||||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
fast-csv@4.3.6:
|
||||
dependencies:
|
||||
'@fast-csv/format': 4.3.5
|
||||
'@fast-csv/parse': 4.3.6
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -4706,9 +5017,20 @@ snapshots:
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
fstream@1.0.12:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
inherits: 2.0.4
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.0.0
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
generate-function@2.3.1:
|
||||
@@ -4750,12 +5072,24 @@ snapshots:
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
glob@7.2.0:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globals@16.3.0: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@1.1.14:
|
||||
optional: true
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
grammex@3.1.13: {}
|
||||
@@ -4821,6 +5155,11 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
wrappy: 1.0.2
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
@@ -4886,6 +5225,10 @@ snapshots:
|
||||
dependencies:
|
||||
dayjs: 1.11.21
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -4897,14 +5240,42 @@ snapshots:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
listenercount@1.0.1: {}
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.difference@4.5.0: {}
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.flatten@4.4.0: {}
|
||||
|
||||
lodash.groupby@4.6.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
|
||||
lodash.isnil@4.0.0: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isundefined@3.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.union@4.6.0: {}
|
||||
|
||||
lodash.uniq@4.5.0: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
long@5.3.2: {}
|
||||
@@ -5004,10 +5375,16 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
@@ -5045,6 +5422,8 @@ snapshots:
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -5220,6 +5599,10 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdir-glob@1.0.0:
|
||||
dependencies:
|
||||
minimatch: 3.1.5
|
||||
|
||||
readdirp@5.1.1: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
@@ -5236,6 +5619,10 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@2.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 1.1.14
|
||||
|
||||
rimraf@5.0.5:
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
@@ -5308,6 +5695,10 @@ snapshots:
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.26
|
||||
|
||||
saxes@5.0.1:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
@@ -5473,6 +5864,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@scarf/scarf': 1.4.0
|
||||
|
||||
tar-stream@2.1.2:
|
||||
dependencies:
|
||||
bl: 4.0.3
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -5488,6 +5887,8 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
tmp@0.2.7: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
token-types@6.1.2:
|
||||
@@ -5496,6 +5897,8 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
traverse@0.3.9: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@5.9.2):
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
@@ -5550,12 +5953,27 @@ snapshots:
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unzipper@0.10.11:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
binary: 0.3.0
|
||||
bluebird: 3.4.7
|
||||
buffer-indexof-polyfill: 1.0.2
|
||||
duplexer2: 0.1.4
|
||||
fstream: 1.0.12
|
||||
graceful-fs: 4.2.11
|
||||
listenercount: 1.0.1
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
valibot@1.4.2(typescript@5.9.2):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
@@ -5690,7 +6108,15 @@ snapshots:
|
||||
|
||||
xmlbuilder@10.1.1: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
zeptomatch@2.1.0:
|
||||
dependencies:
|
||||
grammex: 3.1.13
|
||||
graphmatch: 1.1.1
|
||||
|
||||
zip-stream@4.0.0:
|
||||
dependencies:
|
||||
archiver-utils: 2.1.0
|
||||
compress-commons: 3.0.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
@@ -3,8 +3,12 @@ packages:
|
||||
- packages/*
|
||||
|
||||
overrides:
|
||||
'@fast-csv/parse': 4.3.6
|
||||
bl: 4.0.3
|
||||
fast-csv: 4.3.6
|
||||
js-yaml: 5.2.2
|
||||
lodash: 4.18.1
|
||||
uuid: 11.1.1
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
|
||||
Reference in New Issue
Block a user