v3.5.4版本提交
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-21 15:48:15 +08:00
parent 26334ed072
commit a01217539c
32 changed files with 2278 additions and 147 deletions
@@ -4,7 +4,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminProductsService } from './admin-products.service';
import { AdminProductsQueryDto } from './dto/admin-query.dto';
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import {
CreateProductDto,
SaveProductSkusDto,
SaveProductSpecsDto,
UpdateProductDto,
} from './dto/admin-mutate.dto';
@Controller('admin/products')
@UseGuards(HqAuthGuard)
@@ -33,6 +38,18 @@ export class AdminProductsController {
return this.service.update(BigInt(id), dto);
}
@Put(':id/specs')
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
saveSpecs(@Param('id') id: string, @Body() dto: SaveProductSpecsDto) {
return this.service.saveSpecs(BigInt(id), dto);
}
@Put(':id/skus')
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
saveSkus(@Param('id') id: string, @Body() dto: SaveProductSkusDto) {
return this.service.saveSkus(BigInt(id), dto);
}
@Delete(':id')
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
remove(@Param('id') id: string) {
@@ -1,10 +1,22 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BOTTLES_PER_BOX } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import {
buildSpecKey,
buildSpecText,
mapSkuDto,
mapSpecAttrsDto,
} from '../catalog/product-sku.util';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import type {
CreateProductDto,
SaveProductSkusDto,
SaveProductSpecsDto,
UpdateProductDto,
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
function normalizePhones(phones?: string[]): string[] {
@@ -27,6 +39,8 @@ function normalizePhones(phones?: string[]): string[] {
const SKU_AUTO_PREFIX = 'DK';
const SKU_AUTO_PAD = 6;
const MAX_SPEC_ATTRS = 3;
const MAX_SPEC_VALUES = 10;
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
function resolveFulfillmentFlags(input: {
@@ -80,6 +94,8 @@ export class AdminProductsService {
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
skus: { select: { id: true }, take: 2 },
specAttrs: { select: { id: true } },
},
}),
this.prisma.commonProductItem.count({ where }),
@@ -100,7 +116,14 @@ export class AdminProductsService {
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt({
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
items: items.map((p) => {
const { skus, specAttrs, ...rest } = p;
return {
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
specEnabled: specAttrs.length > 0 || skus.length > 1,
skuCount: skus.length,
};
}),
total,
page,
pageSize,
@@ -113,6 +136,14 @@ export class AdminProductsService {
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
skus: {
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: { skuSpecs: { select: { valueId: true } } },
},
specAttrs: {
orderBy: { sortOrder: 'asc' },
include: { values: { orderBy: { sortOrder: 'asc' } } },
},
},
});
if (!product) throw new NotFoundException('商品不存在');
@@ -127,11 +158,18 @@ export class AdminProductsService {
orderBy: { sortOrder: 'asc' },
});
return serializeBigInt(this.formatProduct(product, resources));
const { skus, specAttrs, ...rest } = product;
return serializeBigInt({
...this.formatProduct(rest as never, resources),
specEnabled: specAttrs.length > 0 || skus.length > 1,
specAttrs: mapSpecAttrsDto(specAttrs),
skus: skus.map(mapSkuDto),
defaultSkuId: skus.find((s) => s.isDefault)?.id.toString() ?? skus[0]?.id.toString(),
});
}
async create(dto: CreateProductDto) {
const barcodeExists = await this.prisma.commonProductItem.findFirst({
const barcodeExists = await this.prisma.commonProductSku.findFirst({
where: { barcode69: dto.barcode69 },
});
if (barcodeExists) throw new BadRequestException('69 码已存在');
@@ -147,7 +185,6 @@ export class AdminProductsService {
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({
@@ -169,6 +206,26 @@ export class AdminProductsService {
: {}),
});
await this.prisma.commonProductSku.create({
data: {
productId: product.id,
skuCode: product.skuCode,
barcode69: product.barcode69,
specKey: '',
specText: product.spec,
price: product.price,
benefitAmount: product.benefitAmount,
status: product.status,
allowOnSitePickup: product.allowOnSitePickup,
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
if (dto.coverUrl) {
await this.syncCover(product.id, dto.coverUrl);
}
@@ -230,7 +287,9 @@ export class AdminProductsService {
if (dto.visibilityWhitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 分实体手机号已废弃;忽略 dto.visibilityPhones
// 无规格 payload 时:同步默认 SKU(兼容旧 admin 表单)
await this.syncDefaultSkuFromProduct(id);
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
@@ -243,6 +302,281 @@ export class AdminProductsService {
return this.detail(id);
}
async saveSpecs(productId: bigint, dto: SaveProductSpecsDto) {
const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId } });
if (!product) throw new NotFoundException('商品不存在');
const attrs = dto.attrs ?? [];
if (attrs.length > MAX_SPEC_ATTRS) {
throw new BadRequestException(`规格轴最多 ${MAX_SPEC_ATTRS}`);
}
for (const attr of attrs) {
if ((attr.values?.length ?? 0) > MAX_SPEC_VALUES) {
throw new BadRequestException(`每个规格轴最多 ${MAX_SPEC_VALUES} 个值`);
}
if (!attr.values?.length) {
throw new BadRequestException(`规格「${attr.name}」至少需要一个值`);
}
}
const existingAttrs = await this.prisma.commonProductSpecAttr.findMany({
where: { productId },
include: { values: true },
});
const existingValueIds = existingAttrs.flatMap((a) => a.values.map((v) => v.id));
const keepValueIds = new Set(
attrs.flatMap((a) => (a.values ?? []).map((v) => v.id).filter(Boolean) as string[]),
);
for (const vid of existingValueIds) {
if (keepValueIds.has(vid.toString())) continue;
const used = await this.prisma.commonProductSkuSpec.count({ where: { valueId: vid } });
if (used > 0) {
const orderCount = await this.prisma.order.count({
where: { sku: { skuSpecs: { some: { valueId: vid } } } },
});
if (orderCount > 0) {
throw new BadRequestException('有订单关联的规格值不可删除');
}
}
}
await this.prisma.$transaction(async (tx) => {
// 删除未保留的轴(级联值);先清 sku_spec 中将被删的 value
const keepAttrIds = new Set(attrs.map((a) => a.id).filter(Boolean) as string[]);
for (const old of existingAttrs) {
if (!keepAttrIds.has(old.id.toString())) {
await tx.commonProductSkuSpec.deleteMany({
where: { valueId: { in: old.values.map((v) => v.id) } },
});
await tx.commonProductSpecAttr.delete({ where: { id: old.id } });
}
}
for (let ai = 0; ai < attrs.length; ai++) {
const attr = attrs[ai];
let attrId: bigint;
if (attr.id) {
attrId = BigInt(attr.id);
await tx.commonProductSpecAttr.update({
where: { id: attrId },
data: { name: attr.name.trim(), sortOrder: attr.sortOrder ?? ai },
});
} else {
const created = await tx.commonProductSpecAttr.create({
data: {
productId,
name: attr.name.trim(),
sortOrder: attr.sortOrder ?? ai,
},
});
attrId = created.id;
}
const oldValues = await tx.commonProductSpecValue.findMany({ where: { attrId } });
const keepVids = new Set((attr.values ?? []).map((v) => v.id).filter(Boolean) as string[]);
for (const ov of oldValues) {
if (!keepVids.has(ov.id.toString())) {
await tx.commonProductSkuSpec.deleteMany({ where: { valueId: ov.id } });
await tx.commonProductSpecValue.delete({ where: { id: ov.id } });
}
}
for (let vi = 0; vi < (attr.values ?? []).length; vi++) {
const val = attr.values[vi];
if (val.id) {
await tx.commonProductSpecValue.update({
where: { id: BigInt(val.id) },
data: { name: val.name.trim(), sortOrder: val.sortOrder ?? vi },
});
} else {
await tx.commonProductSpecValue.create({
data: {
attrId,
name: val.name.trim(),
sortOrder: val.sortOrder ?? vi,
},
});
}
}
}
});
return this.detail(productId);
}
async saveSkus(productId: bigint, dto: SaveProductSkusDto) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: {
specAttrs: { include: { values: true }, orderBy: { sortOrder: 'asc' } },
},
});
if (!product) throw new NotFoundException('商品不存在');
const rows = dto.skus ?? [];
if (!rows.length) throw new BadRequestException('至少保留一个 SKU');
const valueNameById = new Map<string, string>();
const attrValueSets = product.specAttrs.map((a) => {
const set = new Set(a.values.map((v) => v.id.toString()));
for (const v of a.values) valueNameById.set(v.id.toString(), v.name);
return set;
});
let defaultCount = 0;
const seenKeys = new Set<string>();
for (const row of rows) {
const flags = resolveFulfillmentFlags({
allowOnlinePurchase: row.allowOnlinePurchase,
allowCrossCityDelivery: row.allowCrossCityDelivery,
allowOnSitePickup: row.allowOnSitePickup,
defaults: {
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
allowOnSitePickup: product.allowOnSitePickup,
},
});
void flags;
if (row.isDefault) defaultCount += 1;
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
if (attrValueSets.length) {
const idSet = new Set(valueIds.map((id) => id.toString()));
if (idSet.size !== valueIds.length) {
throw new BadRequestException('规格值不可重复');
}
for (const set of attrValueSets) {
const hits = [...set].filter((id) => idSet.has(id));
if (hits.length !== 1) {
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
}
}
if (valueIds.length !== attrValueSets.length) {
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
}
} else if (valueIds.length) {
throw new BadRequestException('商品尚未配置规格轴');
}
const key = buildSpecKey(valueIds);
if (seenKeys.has(key)) throw new BadRequestException('存在重复规格组合');
seenKeys.add(key);
}
if (defaultCount !== 1) {
throw new BadRequestException('请且仅指定一个默认 SKU');
}
const barcodes = rows.map((r) => r.barcode69.trim());
if (new Set(barcodes).size !== barcodes.length) {
throw new BadRequestException('69 码不可重复');
}
await this.prisma.$transaction(async (tx) => {
const existing = await tx.commonProductSku.findMany({ where: { productId } });
const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]);
for (const old of existing) {
if (keepIds.has(old.id.toString())) continue;
const orderCount = await tx.order.count({ where: { skuId: old.id } });
if (orderCount > 0) {
throw new BadRequestException(`SKU ${old.skuCode} 已有订单,无法删除`);
}
await tx.commonProductSkuSpec.deleteMany({ where: { skuId: old.id } });
await tx.commonProductSku.delete({ where: { id: old.id } });
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
const specKey = buildSpecKey(valueIds);
const specText =
buildSpecText(valueIds, valueNameById) || product.spec || row.barcode69;
const flags = resolveFulfillmentFlags({
allowOnlinePurchase: row.allowOnlinePurchase,
allowCrossCityDelivery: row.allowCrossCityDelivery,
allowOnSitePickup: row.allowOnSitePickup,
defaults: {
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
allowOnSitePickup: product.allowOnSitePickup,
},
});
const saleUnit = row.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE';
const bottlesPerUnit =
row.bottlesPerUnit && row.bottlesPerUnit > 0
? Math.floor(row.bottlesPerUnit)
: saleUnit === 'BOX'
? BOTTLES_PER_BOX
: 1;
const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
const skuCode = row.skuCode?.trim() || (await this.nextAutoSkuCodeTx(tx));
let skuId: bigint;
if (row.id) {
skuId = BigInt(row.id);
await tx.commonProductSku.update({
where: { id: skuId },
data: {
skuCode,
barcode69: row.barcode69.trim(),
specKey,
specText,
price: row.price,
benefitAmount: row.benefitAmount ?? row.price,
status,
...flags,
saleUnit,
bottlesPerUnit,
isDefault: !!row.isDefault,
sortOrder: row.sortOrder ?? i,
},
});
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
} else {
const created = await tx.commonProductSku.create({
data: {
productId,
skuCode,
barcode69: row.barcode69.trim(),
specKey,
specText,
price: row.price,
benefitAmount: row.benefitAmount ?? row.price,
status,
...flags,
saleUnit,
bottlesPerUnit,
isDefault: !!row.isDefault,
sortOrder: row.sortOrder ?? i,
},
});
skuId = created.id;
}
if (valueIds.length) {
await tx.commonProductSkuSpec.createMany({
data: valueIds.map((valueId) => ({ skuId, valueId })),
});
}
if (row.isDefault) {
await tx.commonProductItem.update({
where: { id: productId },
data: {
skuCode,
barcode69: row.barcode69.trim(),
spec: specText,
price: row.price,
benefitAmount: row.benefitAmount ?? row.price,
...flags,
},
});
}
}
});
return this.detail(productId);
}
async remove(id: bigint) {
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
if (!product) throw new NotFoundException('商品不存在');
@@ -260,15 +594,82 @@ export class AdminProductsService {
return { ok: true };
}
private async syncDefaultSkuFromProduct(productId: bigint) {
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
const defaultSku =
(await this.prisma.commonProductSku.findFirst({
where: { productId, isDefault: true },
})) ??
(await this.prisma.commonProductSku.findFirst({
where: { productId },
orderBy: { id: 'asc' },
}));
if (!defaultSku) {
await this.prisma.commonProductSku.create({
data: {
productId,
skuCode: product.skuCode,
barcode69: product.barcode69,
specKey: '',
specText: product.spec,
price: product.price,
benefitAmount: product.benefitAmount,
status: product.status,
allowOnSitePickup: product.allowOnSitePickup,
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
return;
}
// 仅当该商品只有 1 个 SKU 时,旧表单字段同步到默认 SKU(避免误改多规格)
const skuCount = await this.prisma.commonProductSku.count({ where: { productId } });
if (skuCount > 1) return;
await this.prisma.commonProductSku.update({
where: { id: defaultSku.id },
data: {
skuCode: product.skuCode,
barcode69: product.barcode69,
specText: product.spec,
price: product.price,
benefitAmount: product.benefitAmount,
status: product.status,
allowOnSitePickup: product.allowOnSitePickup,
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
isDefault: true,
},
});
}
/** 生成 DK + 6 位自增 SKU,冲突重试 */
private async nextAutoSkuCode(): Promise<string> {
const rows = await this.prisma.commonProductItem.findMany({
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
select: { skuCode: true },
});
return this.nextAutoSkuCodeTx(this.prisma);
}
private async nextAutoSkuCodeTx(
db: Prisma.TransactionClient | PrismaService,
): Promise<string> {
const [fromItem, fromSku] = await Promise.all([
db.commonProductItem.findMany({
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
select: { skuCode: true },
}),
db.commonProductSku.findMany({
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
select: { skuCode: true },
}),
]);
let maxSeq = 0;
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
for (const row of rows) {
for (const row of [...fromItem, ...fromSku]) {
const m = re.exec(row.skuCode);
if (!m) continue;
const n = Number(m[1]);
@@ -300,16 +701,6 @@ export class AdminProductsService {
throw new BadRequestException('SKU 生成失败,请重试');
}
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
await this.prisma.$transaction(async (tx) => {
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
if (!phones.length) return;
await tx.commonProductVisibilityPhone.createMany({
data: phones.map((phone) => ({ productId, phone })),
});
});
}
private formatProduct(
product: Prisma.CommonProductItemGetPayload<{
include: {
@@ -13,6 +13,7 @@ import {
Max,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class UpdateStoreStatusDto {
@@ -1302,6 +1303,111 @@ export class UpdateProductDto {
detailContent?: Record<string, unknown>;
}
class AdminSpecValueDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
}
class AdminSpecAttrDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => AdminSpecValueDto)
values: AdminSpecValueDto[];
}
export class SaveProductSpecsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AdminSpecAttrDto)
attrs: AdminSpecAttrDto[];
}
class AdminSkuRowDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
specValueIds?: string[];
@IsOptional()
@IsString()
skuCode?: string;
@IsString()
@IsNotEmpty()
barcode69: string;
@IsNumber()
price: number;
@IsOptional()
@IsNumber()
benefitAmount?: number;
@IsOptional()
@IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
status?: string;
@IsOptional()
@IsBoolean()
allowOnSitePickup?: boolean;
@IsOptional()
@IsBoolean()
allowOnlinePurchase?: boolean;
@IsOptional()
@IsBoolean()
allowCrossCityDelivery?: boolean;
@IsOptional()
@IsIn(['BOTTLE', 'BOX'])
saleUnit?: 'BOTTLE' | 'BOX';
@IsOptional()
@IsNumber()
bottlesPerUnit?: number;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsNumber()
sortOrder?: number;
}
export class SaveProductSkusDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AdminSkuRowDto)
skus: AdminSkuRowDto[];
}
class ProductDetailFeatureInputDto {
@IsString()
icon: string;
@@ -17,6 +17,10 @@ export class HqProxyOrderPreviewDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@@ -76,6 +80,10 @@ export class HqProxyOrderCreateDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)