Files
tehran_taj/apps/api/src/modules/catalog/catalog.service.ts
T

262 lines
10 KiB
TypeScript
Raw Normal View History

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
2026-09-09 06:46:27 +03:30
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CatalogCacheService } from './catalog-cache.service';
import { DEFAULT_PAGE_SIZE, ListProductsDto, type ProductSort } from './dto/list-products.dto';
2026-09-09 06:46:27 +03:30
@Injectable()
export class CatalogService {
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingHooksService,
private readonly catalogCache: CatalogCacheService,
) {}
2026-09-09 06:46:27 +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.
*/
listProducts() {
return this.catalogCache.get(() => this.buildProductList());
}
/**
* 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[];
} = {},
) {
const products = await this.prisma.product.findMany({
// 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 } },
},
});
const stockBlocked = await this.accounting.stockBlocker();
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.
const blocked = stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
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) {
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`);
}
// 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
}
}