90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
|
import { CatalogCacheService } from './catalog-cache.service';
|
|
|
|
@Injectable()
|
|
export class CatalogService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly accounting: AccountingHooksService,
|
|
private readonly catalogCache: CatalogCacheService,
|
|
) {}
|
|
|
|
/** Served from CatalogCacheService; see there for what invalidates it. */
|
|
listProducts() {
|
|
return this.catalogCache.get(() => this.buildProductList());
|
|
}
|
|
|
|
private async buildProductList() {
|
|
const products = await this.prisma.product.findMany({
|
|
// D27 — a product with no image is never shown in the app. Products now
|
|
// also arrive from the accounting software, which carries no pictures;
|
|
// they appear once an admin photographs them.
|
|
where: { isActive: true, images: { some: {} } },
|
|
include: {
|
|
// 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' } },
|
|
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;
|
|
}
|
|
|
|
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 } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
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;
|
|
}
|
|
}
|