Add store-item Excel export/import for bulk price and stock updates.
Dashboard can download grouped product variants and re-upload to update price and stock. Also expose Content-Disposition for downloads and keep related product/customer API docs in sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4a5780e74d
commit
7277817b6e
@@ -30,6 +30,20 @@ type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||
};
|
||||
}>;
|
||||
|
||||
const productListInclude = {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
} satisfies Prisma.ProductInclude;
|
||||
|
||||
const RELATED_PRODUCTS_LIMIT = 8;
|
||||
|
||||
const emptyStoreSummary = {
|
||||
variantCount: 0,
|
||||
minPrice: null as number | null,
|
||||
maxPrice: null as number | null,
|
||||
inStock: false,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
@@ -137,28 +151,7 @@ export class ProductsService {
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const product = await this.serializeProduct(item, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
const store = storeSummaries.get(item.id.toString());
|
||||
return {
|
||||
...product,
|
||||
store: store ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
const serialized = await this.serializePublicListItems(businessId, items);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
@@ -173,31 +166,21 @@ export class ProductsService {
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]);
|
||||
const serialized = await this.serializeProduct(product, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
const [serializedList, relatedProducts] = await Promise.all([
|
||||
this.serializePublicListItems(businessId, [product]),
|
||||
this.loadRelatedPublicProducts(businessId, product),
|
||||
]);
|
||||
|
||||
return {
|
||||
product: {
|
||||
...serialized,
|
||||
store: storeSummaries.get(product.id.toString()) ?? {
|
||||
variantCount: 0,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
inStock: false,
|
||||
},
|
||||
},
|
||||
product: serializedList[0],
|
||||
relatedProducts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -697,6 +680,141 @@ export class ProductsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async serializePublicListItems(
|
||||
businessId: bigint,
|
||||
items: ProductWithRelations[],
|
||||
) {
|
||||
const storeSummaries = await this.loadStoreSummariesForProducts(
|
||||
businessId,
|
||||
items.map((item) => item.id),
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const product = await this.serializeProduct(item, undefined, {
|
||||
approvedCommentsOnly: true,
|
||||
});
|
||||
return {
|
||||
...product,
|
||||
store: storeSummaries.get(item.id.toString()) ?? { ...emptyStoreSummary },
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Related products for the details page.
|
||||
* Relativity: same category first, then same brand.
|
||||
* Availability: in-stock (unlimited or qty > 0) before out-of-stock within each group.
|
||||
*/
|
||||
private async loadRelatedPublicProducts(
|
||||
businessId: bigint,
|
||||
source: ProductWithRelations,
|
||||
limit = RELATED_PRODUCTS_LIMIT,
|
||||
) {
|
||||
const collected: ProductWithRelations[] = [];
|
||||
const seen = new Set<string>([source.id.toString()]);
|
||||
|
||||
const remaining = () => limit - collected.length;
|
||||
|
||||
const append = (items: ProductWithRelations[]) => {
|
||||
for (const item of items) {
|
||||
if (collected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
const id = item.id.toString();
|
||||
if (seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
collected.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPool = async (
|
||||
extraWhere: Prisma.ProductWhereInput,
|
||||
take: number,
|
||||
) => {
|
||||
if (take <= 0) {
|
||||
return [];
|
||||
}
|
||||
return this.prisma.product.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
AND: [{ id: { not: source.id } }, extraWhere],
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take,
|
||||
include: productListInclude,
|
||||
});
|
||||
};
|
||||
|
||||
const inStockWhere = this.publishedInStockWhere();
|
||||
const outOfStockWhere: Prisma.ProductWhereInput = { NOT: inStockWhere };
|
||||
|
||||
const assignment = await this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: source.id,
|
||||
},
|
||||
select: { categoryId: true },
|
||||
});
|
||||
|
||||
if (assignment) {
|
||||
const categoryRows = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
categoryId: assignment.categoryId,
|
||||
entityId: { not: source.id },
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
const categoryIds = categoryRows.map((row) => row.entityId);
|
||||
if (categoryIds.length > 0) {
|
||||
const categoryWhere: Prisma.ProductWhereInput = { id: { in: categoryIds } };
|
||||
const [inStock, outOfStock] = await Promise.all([
|
||||
fetchPool({ AND: [categoryWhere, inStockWhere] }, limit),
|
||||
fetchPool({ AND: [categoryWhere, outOfStockWhere] }, limit),
|
||||
]);
|
||||
append(inStock);
|
||||
append(outOfStock);
|
||||
}
|
||||
}
|
||||
|
||||
if (source.brandId && remaining() > 0) {
|
||||
const brandWhere: Prisma.ProductWhereInput = { brandId: source.brandId };
|
||||
const take = remaining();
|
||||
const [inStock, outOfStock] = await Promise.all([
|
||||
fetchPool({ AND: [brandWhere, inStockWhere] }, take),
|
||||
fetchPool({ AND: [brandWhere, outOfStockWhere] }, take),
|
||||
]);
|
||||
append(inStock);
|
||||
append(outOfStock);
|
||||
}
|
||||
|
||||
return this.serializePublicListItems(businessId, collected);
|
||||
}
|
||||
|
||||
/** Active store variant with unlimited stock (null) or quantity > 0. */
|
||||
private publishedInStockWhere(): Prisma.ProductWhereInput {
|
||||
return {
|
||||
storeItem: {
|
||||
some: {
|
||||
isActive: true,
|
||||
variants: {
|
||||
some: {
|
||||
isActive: true,
|
||||
OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadStoreSummariesForProducts(
|
||||
businessId: bigint,
|
||||
productIds: bigint[],
|
||||
|
||||
Reference in New Issue
Block a user