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:
Alireza Hassani
2026-08-19 01:02:43 +03:30
co-authored by Cursor
parent 4a5780e74d
commit 7277817b6e
17 changed files with 1553 additions and 52 deletions
+157 -39
View File
@@ -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[],