feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps

- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -1,13 +1,20 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingHooksService,
) {}
listProducts() {
return this.prisma.product.findMany({
where: { isActive: true },
async listProducts() {
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
@@ -26,13 +33,49 @@ export class CatalogService {
collections: { include: { collection: true } },
},
});
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 = await this.accounting.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 } });
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`);
}
return variant;
// 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;
}
}