764 lines
27 KiB
TypeScript
764 lines
27 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|