feat(catalog): add product fulfillment flags and auto SKU
Enable online/cross-city/on-site purchase switches with trade gates, HQ and proxy UI, and server-generated DK SKUs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -593,6 +593,10 @@ model CommonProductItem {
|
||||
status ProductStatus @default(DRAFT)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
|
||||
/// 允许配送到址(同城线上购买)
|
||||
allowOnlinePurchase Boolean @default(true) @map("allow_online_purchase")
|
||||
/// 允许跨城配送(依附线上购买)
|
||||
allowCrossCityDelivery Boolean @default(true) @map("allow_cross_city_delivery")
|
||||
/// Online test: only listed phones can see/buy when enabled
|
||||
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
|
||||
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
||||
|
||||
@@ -24,6 +24,37 @@ function normalizePhones(phones?: string[]): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const SKU_AUTO_PREFIX = 'DK';
|
||||
const SKU_AUTO_PAD = 6;
|
||||
|
||||
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
|
||||
function resolveFulfillmentFlags(input: {
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
allowOnSitePickup?: boolean;
|
||||
defaults?: {
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
allowOnSitePickup: boolean;
|
||||
};
|
||||
}) {
|
||||
const d = input.defaults ?? {
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
};
|
||||
const allowOnlinePurchase = input.allowOnlinePurchase ?? d.allowOnlinePurchase;
|
||||
const allowOnSitePickup = input.allowOnSitePickup ?? d.allowOnSitePickup;
|
||||
let allowCrossCityDelivery = input.allowCrossCityDelivery ?? d.allowCrossCityDelivery;
|
||||
if (!allowOnlinePurchase) {
|
||||
allowCrossCityDelivery = false;
|
||||
}
|
||||
if (!allowOnlinePurchase && !allowOnSitePickup) {
|
||||
throw new BadRequestException('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||
}
|
||||
return { allowOnlinePurchase, allowCrossCityDelivery, allowOnSitePickup };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -96,39 +127,44 @@ export class AdminProductsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto) {
|
||||
const exists = await this.prisma.commonProductItem.findFirst({
|
||||
where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] },
|
||||
const barcodeExists = await this.prisma.commonProductItem.findFirst({
|
||||
where: { barcode69: dto.barcode69 },
|
||||
});
|
||||
if (barcodeExists) throw new BadRequestException('69 码已存在');
|
||||
|
||||
const flags = resolveFulfillmentFlags({
|
||||
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: dto.allowCrossCityDelivery,
|
||||
allowOnSitePickup: dto.allowOnSitePickup,
|
||||
});
|
||||
if (exists) throw new BadRequestException('SKU 或 69 码已存在');
|
||||
|
||||
const phones = normalizePhones(dto.visibilityPhones);
|
||||
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
|
||||
|
||||
const product = await this.prisma.commonProductItem.create({
|
||||
data: {
|
||||
skuCode: dto.skuCode,
|
||||
barcode69: dto.barcode69,
|
||||
name: dto.name,
|
||||
subtitle: dto.subtitle,
|
||||
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
|
||||
spec: dto.spec,
|
||||
price: dto.price,
|
||||
benefitAmount: dto.benefitAmount ?? dto.price,
|
||||
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
allowOnSitePickup: dto.allowOnSitePickup ?? false,
|
||||
visibilityWhitelistEnabled: whitelistEnabled,
|
||||
...(dto.detailContent !== undefined
|
||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(phones.length
|
||||
? {
|
||||
visibilityPhones: {
|
||||
create: phones.map((phone) => ({ phone })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
const product = await this.createWithGeneratedSku({
|
||||
barcode69: dto.barcode69,
|
||||
name: dto.name,
|
||||
subtitle: dto.subtitle,
|
||||
aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
|
||||
spec: dto.spec,
|
||||
price: dto.price,
|
||||
benefitAmount: dto.benefitAmount ?? dto.price,
|
||||
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
allowOnSitePickup: flags.allowOnSitePickup,
|
||||
allowOnlinePurchase: flags.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: flags.allowCrossCityDelivery,
|
||||
visibilityWhitelistEnabled: whitelistEnabled,
|
||||
...(dto.detailContent !== undefined
|
||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(phones.length
|
||||
? {
|
||||
visibilityPhones: {
|
||||
create: phones.map((phone) => ({ phone })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (dto.coverUrl) {
|
||||
@@ -143,7 +179,26 @@ export class AdminProductsService {
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateProductDto) {
|
||||
await this.detail(id);
|
||||
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('商品不存在');
|
||||
|
||||
const fulfillmentTouched =
|
||||
dto.allowOnlinePurchase !== undefined ||
|
||||
dto.allowCrossCityDelivery !== undefined ||
|
||||
dto.allowOnSitePickup !== undefined;
|
||||
const flags = fulfillmentTouched
|
||||
? resolveFulfillmentFlags({
|
||||
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: dto.allowCrossCityDelivery,
|
||||
allowOnSitePickup: dto.allowOnSitePickup,
|
||||
defaults: {
|
||||
allowOnlinePurchase: existing.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: existing.allowCrossCityDelivery,
|
||||
allowOnSitePickup: existing.allowOnSitePickup,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
await this.prisma.commonProductItem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -154,7 +209,13 @@ export class AdminProductsService {
|
||||
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
|
||||
...(flags
|
||||
? {
|
||||
allowOnSitePickup: flags.allowOnSitePickup,
|
||||
allowOnlinePurchase: flags.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: flags.allowCrossCityDelivery,
|
||||
}
|
||||
: {}),
|
||||
...(dto.visibilityWhitelistEnabled !== undefined
|
||||
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
|
||||
: {}),
|
||||
@@ -196,6 +257,46 @@ export class AdminProductsService {
|
||||
return { ok: 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 },
|
||||
});
|
||||
let maxSeq = 0;
|
||||
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
|
||||
for (const row of rows) {
|
||||
const m = re.exec(row.skuCode);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||
}
|
||||
return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`;
|
||||
}
|
||||
|
||||
private async createWithGeneratedSku(
|
||||
data: Omit<Prisma.CommonProductItemCreateInput, 'skuCode'>,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const skuCode = await this.nextAutoSkuCode();
|
||||
try {
|
||||
return await this.prisma.commonProductItem.create({
|
||||
data: { ...data, skuCode },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
const target = err.meta?.target;
|
||||
const fields = Array.isArray(target) ? target.map(String) : [String(target ?? '')];
|
||||
if (fields.some((f) => f.includes('sku'))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('SKU 生成失败,请重试');
|
||||
}
|
||||
|
||||
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
|
||||
|
||||
@@ -1068,9 +1068,10 @@ export class SaveHqAccountPermissionsDto {
|
||||
}
|
||||
|
||||
export class CreateProductDto {
|
||||
/** 可选;创建时由服务端自增生成,忽略客户端传入 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
skuCode: string;
|
||||
skuCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -1110,6 +1111,14 @@ export class CreateProductDto {
|
||||
@IsBoolean()
|
||||
allowOnSitePickup?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnlinePurchase?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowCrossCityDelivery?: boolean;
|
||||
|
||||
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@@ -1173,6 +1182,14 @@ export class UpdateProductDto {
|
||||
@IsBoolean()
|
||||
allowOnSitePickup?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnlinePurchase?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowCrossCityDelivery?: boolean;
|
||||
|
||||
/** 开启后仅白名单手机号在 C 端可见/可购 */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -86,6 +86,22 @@ export class TradeService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!onSitePickup) {
|
||||
const allowOnline = product.allowOnlinePurchase !== false;
|
||||
const allowCross = product.allowCrossCityDelivery !== false;
|
||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||
throw new BadRequestException('该商品不支持线上购买');
|
||||
}
|
||||
if (deliveryType === 'CROSS_CITY') {
|
||||
if (!allowOnline) {
|
||||
throw new BadRequestException('该商品不支持线上购买');
|
||||
}
|
||||
if (!allowCross) {
|
||||
throw new BadRequestException('该商品不支持跨城配送');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const check = validateMinPurchase(
|
||||
deliveryType,
|
||||
body.quantity,
|
||||
@@ -1121,6 +1137,9 @@ export class TradeService {
|
||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery:
|
||||
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
||||
})),
|
||||
promoCodes,
|
||||
stores: stores.map((s) => ({
|
||||
@@ -1166,6 +1185,19 @@ export class TradeService {
|
||||
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
||||
deliveryType = 'CROSS_CITY';
|
||||
}
|
||||
const allowOnline = product.allowOnlinePurchase !== false;
|
||||
const allowCross = product.allowCrossCityDelivery !== false;
|
||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||
throw new BadRequestException('该商品不支持线上购买');
|
||||
}
|
||||
if (deliveryType === 'CROSS_CITY') {
|
||||
if (!allowOnline) {
|
||||
throw new BadRequestException('该商品不支持线上购买');
|
||||
}
|
||||
if (!allowCross) {
|
||||
throw new BadRequestException('该商品不支持跨城配送');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const check = validateMinPurchase(
|
||||
@@ -1486,6 +1518,9 @@ export class TradeService {
|
||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
||||
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery:
|
||||
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
||||
})),
|
||||
promoCodes,
|
||||
stores: [],
|
||||
|
||||
Reference in New Issue
Block a user