feat(old-items): complete reuse workflow and refine miniapp home
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user