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:
2026-08-02 18:10:16 +08:00
parent 49a5c057db
commit 847a3f0dc3
15 changed files with 601 additions and 101 deletions
+4
View File
@@ -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: [],