2026-10-06 16:48:36 +03:30
|
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
2026-10-12 15:01:32 +03:30
|
|
|
import { Prisma } from '@prisma/client';
|
2026-09-09 06:46:27 +03:30
|
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
2026-10-06 16:48:36 +03:30
|
|
|
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
2026-10-11 14:31:32 +03:30
|
|
|
import { CatalogCacheService } from './catalog-cache.service';
|
2026-10-12 15:01:32 +03:30
|
|
|
import { DEFAULT_PAGE_SIZE, ListProductsDto, type ProductSort } from './dto/list-products.dto';
|
2026-09-09 06:46:27 +03:30
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class CatalogService {
|
2026-10-06 16:48:36 +03:30
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly accounting: AccountingHooksService,
|
2026-10-11 14:31:32 +03:30
|
|
|
private readonly catalogCache: CatalogCacheService,
|
2026-10-06 16:48:36 +03:30
|
|
|
) {}
|
2026-09-09 06:46:27 +03:30
|
|
|
|
2026-10-12 15:01:32 +03:30
|
|
|
/**
|
|
|
|
|
* The whole active catalogue, unpaged.
|
|
|
|
|
*
|
|
|
|
|
* Kept exactly as it was for every existing caller (the admin panel, the smoke
|
|
|
|
|
* scripts, installed app builds). New callers should use `findProducts` with a
|
|
|
|
|
* page — the real catalogue reaches ~16k products, and this response carries
|
|
|
|
|
* all of them with their variants, images and collections.
|
|
|
|
|
*
|
|
|
|
|
* Served from CatalogCacheService; see there for what invalidates it.
|
|
|
|
|
*/
|
2026-10-11 14:31:32 +03:30
|
|
|
listProducts() {
|
|
|
|
|
return this.catalogCache.get(() => this.buildProductList());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 15:01:32 +03:30
|
|
|
/**
|
|
|
|
|
* The storefront list, paged and filtered server-side.
|
|
|
|
|
*
|
|
|
|
|
* Owner, 2026-10-12: the catalogue reaches ~16k products, so returning it
|
|
|
|
|
* whole is wrong. The filters live here rather than in the apps because a
|
|
|
|
|
* client cannot filter data it has not been sent — paging the response while
|
|
|
|
|
* leaving the filter on the client would silently search only the first page.
|
|
|
|
|
*
|
|
|
|
|
* Returns `{ rows, total, page, limit }`. With no `page`/`limit` the caller
|
|
|
|
|
* gets everything that matches, so the filters are usable on their own.
|
|
|
|
|
*
|
|
|
|
|
* NOT cached, deliberately. CatalogCacheService holds one built response and
|
|
|
|
|
* exists because the unpaged query is expensive and every screen asked for
|
|
|
|
|
* it. A paged, filtered query is neither: it is bounded by `limit` and served
|
|
|
|
|
* by Product_isActive_createdAt_idx. Caching it would mean a key per filter
|
|
|
|
|
* combination — unbounded memory for queries that are already cheap, and a
|
|
|
|
|
* second place for stale rows to hide. If this ever needs a cache it should
|
|
|
|
|
* be a shared one (Redis), which is also what the single-instance note on
|
|
|
|
|
* CatalogCacheService points at.
|
|
|
|
|
*/
|
|
|
|
|
async findProducts(query: ListProductsDto) {
|
|
|
|
|
// Specific ids: the cart, favourites and order items each need a handful of
|
|
|
|
|
// known products. Paging and the filters do not apply.
|
|
|
|
|
if (query.ids?.length) {
|
|
|
|
|
const rows = await this.buildProductList({ ids: query.ids });
|
|
|
|
|
return { rows, total: rows.length, page: 1, limit: rows.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const paged = query.page !== undefined || query.limit !== undefined;
|
|
|
|
|
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
|
|
|
|
|
const page = query.page ?? 1;
|
|
|
|
|
|
|
|
|
|
const where = this.storefrontWhere(query);
|
|
|
|
|
// Counted with the same `where`, so `total` describes the filtered set and
|
|
|
|
|
// the app's "N محصول" and end-of-list detection stay truthful.
|
|
|
|
|
const [total, rows] = await Promise.all([
|
|
|
|
|
this.prisma.product.count({ where }),
|
|
|
|
|
this.buildProductList({
|
|
|
|
|
where,
|
|
|
|
|
orderBy: this.orderFor(query.sort),
|
|
|
|
|
...(paged ? { skip: (page - 1) * limit, take: limit } : {}),
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return { rows, total, page: paged ? page : 1, limit: paged ? limit : total };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One product for its own screen, instead of fetching the catalogue to find it. */
|
|
|
|
|
async findProductById(id: string) {
|
|
|
|
|
const [product] = await this.buildProductList({ where: { ...this.storefrontWhere({}), id } });
|
|
|
|
|
if (!product) throw new NotFoundException('محصول یافت نشد');
|
|
|
|
|
return product;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The filter options the search sheet offers.
|
|
|
|
|
*
|
|
|
|
|
* The apps derived these by scanning the whole catalogue in the browser
|
|
|
|
|
* (SearchView.vue:167-181), which is only possible while the whole catalogue
|
|
|
|
|
* is in memory. Each is a small aggregate here.
|
|
|
|
|
*/
|
|
|
|
|
async facets() {
|
|
|
|
|
const where = this.storefrontWhere({});
|
|
|
|
|
const [categories, brands, collections, gram] = await Promise.all([
|
|
|
|
|
this.prisma.category.findMany({
|
|
|
|
|
where: { products: { some: where } },
|
|
|
|
|
select: { id: true, title: true },
|
|
|
|
|
orderBy: { title: 'asc' },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.brand.findMany({
|
|
|
|
|
where: { products: { some: where } },
|
|
|
|
|
select: { id: true, title: true },
|
|
|
|
|
orderBy: { title: 'asc' },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.collection.findMany({
|
|
|
|
|
where: { products: { some: { product: where } } },
|
|
|
|
|
select: { id: true, title: true },
|
|
|
|
|
orderBy: { title: 'asc' },
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.productVariant.aggregate({
|
|
|
|
|
where: { isActive: true, product: where },
|
|
|
|
|
_min: { rashWeightGrams: true },
|
|
|
|
|
_max: { rashWeightGrams: true },
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
categories,
|
|
|
|
|
brands,
|
|
|
|
|
collections,
|
|
|
|
|
// Strings, like every other weight this API returns (Decimal, not float).
|
|
|
|
|
gramRange: {
|
|
|
|
|
min: gram._min.rashWeightGrams?.toString() ?? '0',
|
|
|
|
|
max: gram._max.rashWeightGrams?.toString() ?? '0',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The two rules that decide whether a product is on the storefront at all,
|
|
|
|
|
* plus the caller's filters. Shared so a filtered list, its count, the single
|
|
|
|
|
* product route and the facets can never disagree about what is visible.
|
|
|
|
|
*
|
|
|
|
|
* D27 — a product with no image is never shown in the app. Products also
|
|
|
|
|
* arrive from the accounting software, which carries no pictures; they appear
|
|
|
|
|
* once an admin photographs them.
|
|
|
|
|
*/
|
|
|
|
|
private storefrontWhere(query: ListProductsDto): Prisma.ProductWhereInput {
|
|
|
|
|
const where: Prisma.ProductWhereInput = { isActive: true, images: { some: {} } };
|
|
|
|
|
|
|
|
|
|
if (query.search?.trim()) {
|
|
|
|
|
// `contains`, as the apps' own title filter did. Case-insensitive is a
|
|
|
|
|
// no-op for Persian but matches how every other search in this API reads.
|
|
|
|
|
where.title = { contains: query.search.trim(), mode: 'insensitive' };
|
|
|
|
|
}
|
|
|
|
|
if (query.categoryId) where.categoryId = query.categoryId;
|
|
|
|
|
if (query.brandId) where.brandId = query.brandId;
|
|
|
|
|
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
|
|
|
|
|
|
|
|
|
|
// A product is in range when at least ONE of its variants is, which is what
|
|
|
|
|
// the app's `limit_gram` filter meant (SearchView.vue:189-195).
|
|
|
|
|
if (query.minGram !== undefined || query.maxGram !== undefined) {
|
|
|
|
|
where.variants = {
|
|
|
|
|
some: {
|
|
|
|
|
isActive: true,
|
|
|
|
|
rashWeightGrams: {
|
|
|
|
|
...(query.minGram !== undefined ? { gte: query.minGram } : {}),
|
|
|
|
|
...(query.maxGram !== undefined ? { lte: query.maxGram } : {}),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return where;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private orderFor(sort: ProductSort | undefined): Prisma.ProductOrderByWithRelationInput {
|
|
|
|
|
switch (sort) {
|
|
|
|
|
case 'oldest':
|
|
|
|
|
return { createdAt: 'asc' };
|
|
|
|
|
case 'title':
|
|
|
|
|
return { title: 'asc' };
|
|
|
|
|
default:
|
|
|
|
|
// Newest first, and the half of Product_isActive_createdAt_idx the list
|
|
|
|
|
// is indexed for.
|
|
|
|
|
return { createdAt: 'desc' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async buildProductList(
|
|
|
|
|
opts: {
|
|
|
|
|
where?: Prisma.ProductWhereInput;
|
|
|
|
|
orderBy?: Prisma.ProductOrderByWithRelationInput;
|
|
|
|
|
skip?: number;
|
|
|
|
|
take?: number;
|
|
|
|
|
ids?: string[];
|
|
|
|
|
} = {},
|
|
|
|
|
) {
|
2026-10-06 16:48:36 +03:30
|
|
|
const products = await this.prisma.product.findMany({
|
2026-10-12 15:01:32 +03:30
|
|
|
// Defaults to the unfiltered storefront rule, so the unpaged
|
|
|
|
|
// `listProducts()` above behaves exactly as before.
|
|
|
|
|
where: opts.ids
|
|
|
|
|
? { ...this.storefrontWhere({}), id: { in: opts.ids } }
|
|
|
|
|
: (opts.where ?? { isActive: true, images: { some: {} } }),
|
|
|
|
|
...(opts.orderBy ? { orderBy: opts.orderBy } : {}),
|
|
|
|
|
...(opts.skip !== undefined ? { skip: opts.skip } : {}),
|
|
|
|
|
...(opts.take !== undefined ? { take: opts.take } : {}),
|
2026-09-09 06:46:27 +03:30
|
|
|
include: {
|
2026-09-21 08:32:31 +03:30
|
|
|
// Only orderable variants reach the storefront. An inactive one used to
|
|
|
|
|
// be listed and then rejected by getVariantOrThrow at checkout, so the
|
|
|
|
|
// customer could pick a chip and get a 404 on the order.
|
|
|
|
|
variants: {
|
|
|
|
|
where: { isActive: true },
|
|
|
|
|
// Each variant carries its own gallery picture, so the app can render
|
|
|
|
|
// the chip without a second request per variant.
|
|
|
|
|
include: { image: true },
|
|
|
|
|
},
|
|
|
|
|
// Explicit order: the UI treats images[0] as the product's main photo,
|
|
|
|
|
// which is only meaningful if the rows come back sorted.
|
|
|
|
|
images: { orderBy: { sortOrder: 'asc' } },
|
2026-09-09 06:46:27 +03:30
|
|
|
category: true,
|
|
|
|
|
brand: true,
|
|
|
|
|
collections: { include: { collection: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-10-06 16:48:36 +03:30
|
|
|
|
2026-10-11 14:31:32 +03:30
|
|
|
const stockBlocked = await this.accounting.stockBlocker();
|
2026-10-06 16:48:36 +03:30
|
|
|
const out = [];
|
|
|
|
|
for (const p of products) {
|
|
|
|
|
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p;
|
|
|
|
|
// D27 — out of stock in the accounting software: every variant reads as
|
|
|
|
|
// qty 0, which the apps already render as «ناموجود» and refuse to add to
|
|
|
|
|
// the cart unless it is a pre-order variant. The stock figures themselves
|
|
|
|
|
// stay server-side; this endpoint is public.
|
2026-10-11 14:31:32 +03:30
|
|
|
const blocked = stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
|
2026-10-06 16:48:36 +03:30
|
|
|
out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
2026-09-09 06:46:27 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getVariantOrThrow(variantId: string) {
|
2026-10-06 16:48:36 +03:30
|
|
|
const variant = await this.prisma.productVariant.findUnique({
|
|
|
|
|
where: { id: variantId },
|
|
|
|
|
include: {
|
|
|
|
|
product: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
accountingStockWeight: true,
|
|
|
|
|
accountingStockCount: true,
|
|
|
|
|
accountingStockAt: true,
|
|
|
|
|
_count: { select: { images: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-09 06:46:27 +03:30
|
|
|
if (!variant || !variant.isActive) {
|
|
|
|
|
throw new NotFoundException(`Product variant ${variantId} not found or inactive`);
|
|
|
|
|
}
|
2026-10-06 16:48:36 +03:30
|
|
|
// The same two rules the listing applies, so a stale client cannot order
|
|
|
|
|
// around them.
|
|
|
|
|
if (variant.product._count.images === 0) {
|
|
|
|
|
throw new BadRequestException('این محصول هنوز تصویر ندارد و قابل سفارش نیست');
|
|
|
|
|
}
|
|
|
|
|
// Live from the accounting software at order time; on no answer the last
|
|
|
|
|
// stored figure decides (liveStock).
|
|
|
|
|
if (!variant.isPreOrder && (await this.accounting.stockBlocked(await this.accounting.liveStock(variant.product)))) {
|
|
|
|
|
throw new BadRequestException('این محصول در انبار موجود نیست');
|
|
|
|
|
}
|
|
|
|
|
const { product: _product, ...plain } = variant;
|
|
|
|
|
return plain;
|
2026-09-09 06:46:27 +03:30
|
|
|
}
|
|
|
|
|
}
|