feat(admin): show HQ product list as SPU with expandable SKUs

Group catalog rows by product, add paid bottle sales, and keep multi-SKU fulfillment on SKU rows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-21 23:52:28 +08:00
parent 8a061f085f
commit f9161368e1
2 changed files with 327 additions and 60 deletions
@@ -86,7 +86,19 @@ export class AdminProductsService {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonProductItemWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.name?.trim()) {
const kw = query.name.trim();
where.OR = [
{ name: { contains: kw } },
{
skus: {
some: {
OR: [{ skuCode: { contains: kw } }, { barcode69: { contains: kw } }],
},
},
},
];
}
if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
@@ -99,7 +111,25 @@ export class AdminProductsService {
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
skus: { select: { id: true }, take: 2 },
skus: {
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
select: {
id: true,
skuCode: true,
barcode69: true,
specText: true,
price: true,
benefitAmount: true,
status: true,
isDefault: true,
sortOrder: true,
allowOnSitePickup: true,
allowOnlinePurchase: true,
allowCrossCityDelivery: true,
saleUnit: true,
bottlesPerUnit: true,
},
},
specAttrs: { select: { id: true } },
},
}),
@@ -120,13 +150,101 @@ export class AdminProductsService {
: [];
const resourceMap = groupResourcesByProductId(resources);
/** productId → skuIdKey → bottlesskuIdKey 用 '' 表示历史无 sku */
const soldByProductSku = new Map<string, Map<string, number>>();
if (productIds.length) {
const saleOrders = await this.prisma.order.findMany({
where: {
productId: { in: productIds },
payStatus: 'PAID',
isTest: false,
status: { notIn: ['PENDING_PAY', 'CANCELLED', 'REFUNDED'] },
},
select: {
productId: true,
skuId: true,
quantity: true,
bottlesPerUnit: true,
},
});
for (const o of saleOrders) {
const pid = o.productId.toString();
const sid = o.skuId?.toString() ?? '';
const bottles = o.quantity * (o.bottlesPerUnit > 0 ? o.bottlesPerUnit : 1);
let bySku = soldByProductSku.get(pid);
if (!bySku) {
bySku = new Map();
soldByProductSku.set(pid, bySku);
}
bySku.set(sid, (bySku.get(sid) ?? 0) + bottles);
}
}
return serializeBigInt({
items: items.map((p) => {
const { skus, specAttrs, ...rest } = p;
const formatted = this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []);
const prices = skus.length
? skus.map((s) => Number(s.price))
: [Number(p.price)];
const priceMin = Math.min(...prices);
const priceMax = Math.max(...prices);
const pid = p.id.toString();
const bySku = soldByProductSku.get(pid);
let soldBottles = 0;
if (bySku) {
for (const n of bySku.values()) soldBottles += n;
}
const skuRows =
skus.length > 0
? skus.map((s) => ({
id: s.id.toString(),
skuCode: s.skuCode,
barcode69: s.barcode69,
specText: s.specText,
price: Number(s.price),
benefitAmount: Number(s.benefitAmount ?? s.price),
status: s.status,
isDefault: s.isDefault,
sortOrder: s.sortOrder,
allowOnSitePickup: s.allowOnSitePickup,
allowOnlinePurchase: s.allowOnlinePurchase,
allowCrossCityDelivery: s.allowCrossCityDelivery,
saleUnit: s.saleUnit,
bottlesPerUnit: s.bottlesPerUnit,
soldBottles: bySku?.get(s.id.toString()) ?? 0,
virtual: false,
}))
: [
{
id: `virtual-${pid}`,
skuCode: p.skuCode,
barcode69: p.barcode69,
specText: p.spec,
price: Number(p.price),
benefitAmount: Number(p.benefitAmount ?? p.price),
status: p.status,
isDefault: true,
sortOrder: 0,
allowOnSitePickup: p.allowOnSitePickup,
allowOnlinePurchase: p.allowOnlinePurchase,
allowCrossCityDelivery: p.allowCrossCityDelivery,
saleUnit: 'BOTTLE' as const,
bottlesPerUnit: 1,
soldBottles,
virtual: true,
},
];
return {
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
...formatted,
specEnabled: specAttrs.length > 0 || skus.length > 1,
skuCount: skus.length,
priceMin,
priceMax,
soldBottles,
skus: skuRows,
};
}),
total,
@@ -246,10 +364,14 @@ export class AdminProductsService {
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('商品不存在');
const skuCount = await this.prisma.commonProductSku.count({ where: { productId: id } });
/** 多规格时履约只在规格 SKU 上改;忽略基础信息里的履约字段,避免误覆盖默认 SKU 冗余 */
const applyFulfillment = skuCount <= 1;
const fulfillmentTouched =
dto.allowOnlinePurchase !== undefined ||
dto.allowCrossCityDelivery !== undefined ||
dto.allowOnSitePickup !== undefined;
applyFulfillment &&
(dto.allowOnlinePurchase !== undefined ||
dto.allowCrossCityDelivery !== undefined ||
dto.allowOnSitePickup !== undefined);
const flags = fulfillmentTouched
? resolveFulfillmentFlags({
allowOnlinePurchase: dto.allowOnlinePurchase,