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:
@@ -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 → bottles;skuIdKey 用 '' 表示历史无 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,
|
||||
|
||||
Reference in New Issue
Block a user